├── .gitignore ├── .rubocop.yml ├── .ruby-version ├── .simplecov ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.md ├── README.md ├── Rakefile ├── aws_assume_role.gemspec ├── bin └── aws-assume-role ├── i18n └── en.yml ├── lib ├── aws_assume_role.rb └── aws_assume_role │ ├── cli.rb │ ├── cli │ ├── actions │ │ ├── abstract_action.rb │ │ ├── configure_profile.rb │ │ ├── configure_role_assumption.rb │ │ ├── console.rb │ │ ├── delete_profile.rb │ │ ├── includes.rb │ │ ├── list_profiles.rb │ │ ├── migrate_profile.rb │ │ ├── reset_environment.rb │ │ ├── run.rb │ │ ├── set_environment.rb │ │ └── test.rb │ ├── commands │ │ ├── configure.rb │ │ ├── console.rb │ │ ├── delete.rb │ │ ├── environment.rb │ │ ├── list.rb │ │ ├── migrate.rb │ │ ├── run.rb │ │ └── test.rb │ └── includes.rb │ ├── configuration.rb │ ├── core_ext │ └── aws-sdk │ │ ├── credential_provider_chain.rb │ │ └── includes.rb │ ├── credentials │ ├── factories.rb │ ├── factories │ │ ├── abstract_factory.rb │ │ ├── assume_role.rb │ │ ├── default_chain_provider.rb │ │ ├── environment.rb │ │ ├── includes.rb │ │ ├── instance_profile.rb │ │ ├── repository.rb │ │ ├── shared.rb │ │ └── static.rb │ ├── includes.rb │ └── providers │ │ ├── assume_role_credentials.rb │ │ ├── includes.rb │ │ ├── mfa_session_credentials.rb │ │ └── shared_keyring_credentials.rb │ ├── includes.rb │ ├── logging.rb │ ├── profile_configuration.rb │ ├── runner.rb │ ├── store │ ├── includes.rb │ ├── keyring.rb │ ├── serialization.rb │ └── shared_config_with_keyring.rb │ ├── types.rb │ ├── ui.rb │ ├── vendored │ ├── aws.rb │ └── aws │ │ ├── README.md │ │ ├── assume_role_credentials.rb │ │ ├── includes.rb │ │ ├── refreshing_credentials.rb │ │ └── shared_config.rb │ └── version.rb └── spec ├── aws_assume_role └── credentials │ └── factories │ ├── README.md │ ├── credential_resolution_chain_spec.rb │ └── default_chain_provider_spec.rb ├── fixtures └── credentials │ ├── README.md │ ├── mock_shared_config │ └── mock_shared_credentials └── spec_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .bundle/ 2 | *~ 3 | *.swp 4 | vendor/ 5 | pkg/ 6 | Gemfile.lock 7 | spec/reports/ 8 | tags 9 | *.gem 10 | coverage 11 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | --- 2 | AllCops: 3 | TargetRubyVersion: 2.2 4 | DisplayCopNames: true 5 | Exclude: 6 | - 'lib/aws_assume_role/vendored/**/*.rb' 7 | - 'vendor/**/*' 8 | 9 | Metrics/MethodLength: 10 | Enabled: false 11 | 12 | Metrics/AbcSize: 13 | Enabled: false 14 | 15 | Metrics/BlockLength: 16 | Enabled: false 17 | 18 | Metrics/ClassLength: 19 | Enabled: false 20 | 21 | Metrics/CyclomaticComplexity: 22 | Enabled: false 23 | 24 | Metrics/PerceivedComplexity: 25 | Enabled: false 26 | 27 | Layout/IndentationWidth: 28 | Width: 4 29 | 30 | Layout/IndentHeredoc: 31 | Enabled: false 32 | 33 | Style/TrailingCommaInArguments: 34 | EnforcedStyleForMultiline: comma 35 | 36 | Style/TrailingCommaInLiteral: 37 | EnforcedStyleForMultiline: comma 38 | 39 | Style/ClassAndModuleChildren: 40 | Enabled: false 41 | 42 | Style/Documentation: 43 | Enabled: false 44 | 45 | Naming/VariableName: 46 | Enabled: false 47 | 48 | Style/StringLiterals: 49 | EnforcedStyle: double_quotes 50 | 51 | Style/MethodMissing: 52 | Enabled: false 53 | 54 | Style/TrivialAccessors: 55 | Enabled: false 56 | 57 | Metrics/LineLength: 58 | Max: 140 59 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.3.8 2 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear_merged! 3 | 4 | SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new([ 5 | SimpleCov::Formatter::HTMLFormatter, 6 | Coveralls::SimpleCov::Formatter 7 | ]) 8 | 9 | SimpleCov.start do 10 | 11 | project_name 'AWS Assume Role' 12 | 13 | add_filter '/spec/' 14 | add_filter 'lib/aws_assume_role/vendored' 15 | 16 | %w(aws_assume_role).each do |group_name| 17 | add_group(group_name, "/#{group_name}/lib") 18 | end 19 | 20 | merge_timeout 60 * 15 # 15 minutes 21 | 22 | end 23 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | sudo: false 3 | script: bundle exec rake 4 | jobs: 5 | include: 6 | - rvm: 2.3 7 | script: bundle exec rake 8 | - rvm: 2.5 9 | script: bundle exec rake 10 | - stage: deploy 11 | rvm: 2.5 12 | script: bundle exec rake setup_credentials && chmod 0600 /home/travis/.gem/credentials && bundle exec rake publish 13 | stages: 14 | - name: test 15 | - name: deploy 16 | if: tag =~ ^v 17 | notifications: 18 | slack: 19 | secure: RmJmJSSDI8qdIpM2KKYoXX2mpcL85YZ9r9gF4rauZH9TuqnYOPP3kQ5iYJsE0VUTuZpuvQ8Axoux5IQr+IDK7kMrmrI0iaZp1dAR9tGK+aLF73sprOmQEou6HIoDs97UQhOWlsAUR8max/7WYdJYJ1o78dqavfFtOy0VcHCkUMRf+WxcKzz+8MunsocIYi0HXuz5vC3RAZCOaK2h8epXzmnWq0ke8YeTmddpDWC85wzeDNjA9T1j5WD+y6gC9F0vyaqVqDCsCXlbRKZl7a1TU9QGDVyBzowoGsWmTpFR80v4CKofAn6nnMRqblwATOS1jMT+HC+Yku29qFzXPugYa2KAUSQaYQiOe+TE5IDa2Exe/57ZQCOq4ve8gKSE9aQXh4Riq3u8qccM+UeoQdcwgXQIciTeWjqi4LQro6Dbyrv8XrUbxdG0VPsWmf49jbWgq6PPAJqdcbXr9eGb+81uJ2REa1vhDYZu4T3JHv4erd5QlyYWzeBJ/LMQav/C5mnMF43jg8DzZ2g0BZBao/reO9xcZre/ka8eOus9Ll1i+8PCxmFZMx2KDPC9i5R7bXL/CwPBjzFInmvHM0cgKjxrRSY6xMWSPyBbgdsKJl2qag74K5xG+2VPlMcVx0ikTKVjsja5iPlOYKhflGAfCKvpzPcd2QoEWg8jZYqZtO9Nj+M= 20 | on_success: change 21 | on_failure: change 22 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.2.3 2 | * Pin dry-eualizer to keep support of Ruby 2.3 3 | 4 | ## 1.2.2 5 | * Pin dry-types to keep support of object type 6 | 7 | ## 1.2.1 8 | * Fix deprecation of int in dry-types to use integer 9 | 10 | ## 1.2.0 11 | * Support latest version of `dry-*` gems 12 | * Requires ruby 2.3 13 | 14 | ## 1.1.2 15 | * Pin breaking upstream `dry-*` gems until code is fixed in aws_assume_role 16 | 17 | ## 1.1.1 18 | * Allow aws-assume-role to retrieve all Yubikey stored OATH tokens (@alanthing) 19 | 20 | ## 1.1.0 21 | * Publish separate gems for Linux, BSD and MacOS (@randomvariable) 22 | 23 | ## 1.0.6 24 | * Determine gem dependencies for OS X & Linux at install time (@randomvariable) 25 | 26 | ## 1.0.5 27 | * Escape run commands properly (@mrprimate) 28 | 29 | ## 1.0.4 30 | * Ensure ~/.aws exists before saving configuration 31 | 32 | ## 1.0.3 33 | * Fix setting environment variable throwing string frozen error (@timbirk) 34 | 35 | ## 1.0.2 36 | * Display credential prompts on stderr to allow shell eval to work (@timbirk) 37 | 38 | ## 1.0.1 39 | * Fix setting environment variable throwing string frozen error (@mrprimate) 40 | * Fix incompatibility with version 0.4 of dry-struct (@tomhaynes) 41 | 42 | ## 1.0.0 43 | * Fix deprecation warnings for dry-types 44 | * Minimum Ruby version is now 2.2 45 | 46 | ## 0.2.2 47 | * Add Yubikey OATH support to the default chain provider (@randomvariable) 48 | 49 | ## 0.2.1 50 | * Loosen requirement on highline to improve compatibility with Puppet tools (@randomvariable) 51 | 52 | ## 0.2.0 53 | 54 | * Add support for Yubikey as a source for MFA (@davbo) 55 | * Remove expired credentials before writing new STS credentials (@davbo) 56 | 57 | ## 0.1.2 58 | 59 | * Become compatible with Ruby 2.1 (@randomvariable) 60 | * Added test suite from AWS SDK for Ruby (@randomvariable) 61 | 62 | ## 0.1.1 63 | 64 | * Fix logging on Ruby 2.2 (@randomvariable) 65 | 66 | ## 0.1.0 67 | 68 | * Complete rewrite with SDK compatible API layer (@randomvariable) 69 | 70 | ## 0.0.3 71 | 72 | * Store master credentials in OS credential store. (@mrprimate) 73 | 74 | ## 0.0.2 75 | 76 | * Add CLI (@mrprimate) 77 | 78 | ## 0.0.1 79 | 80 | * Initial release (@jtopper) 81 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | group :test do 8 | gem "coveralls", require: false 9 | gem "rake" 10 | end 11 | 12 | group :development do 13 | gem "pry", "~> 0.10" 14 | gem "pry-byebug" 15 | gem "pry-rescue", "~> 1.4" 16 | gem "pry-stack_explorer", "~> 0.4" 17 | gem "pry-state" 18 | end 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | aws-assume-role 2 | --------------- 3 | [![Build Status](https://travis-ci.org/scalefactory/aws-assume-role.svg?branch=master)](https://travis-ci.org/scalefactory/aws-assume-role) 4 | [![Coverage Status](https://coveralls.io/repos/github/scalefactory/aws-assume-role/badge.svg?branch=master)](https://coveralls.io/github/scalefactory/aws-assume-role?branch=master) 5 | [![Code Climate](https://codeclimate.com/github/scalefactory/aws-assume-role/badges/gpa.svg)](https://codeclimate.com/github/scalefactory/aws-assume-role) 6 | [![Gem Version](https://badge.fury.io/rb/aws_assume_role.svg)](https://badge.fury.io/rb/aws_assume_role) 7 | 8 | This is an open source project published by The Scale Factory. 9 | 10 | We currently consider this project to be hibernating. 11 | 12 | These are projects that we’re no longer prioritising, but which we keep ticking over for the benefit of the few customers we support who still use them. 13 | 14 | :information_source: We’re not regularly patching these projects, or actively watching for issues or PRs. We’ll periodically make updates or respond to contributions if one of the team has some spare time to invest. 15 | 16 | aws-assume-role is a utility intended for developer and operator environments 17 | who need to use 2FA and role assumption to access AWS services. 18 | 19 | aws-assume-role can store both AWS access keys and ephemeral session tokens in 20 | OS credential vaults - Keychain on OSX and Keyring on Gnome. 21 | 22 | Why? 23 | --- 24 | 25 | This keeps your credentials safe in the keystore, and exist as 26 | environment variables for the duration and context of the executing command. 27 | This helps prevent credential leaking and theft, and means they aren't stored on 28 | disk as unencrypted files. 29 | 30 | It allows easy credential management and role assumption with a 2FA/MFA device. 31 | 32 | For more information on role assumption, see the [AWS documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use.html). 33 | 34 | Requirements 35 | ------------ 36 | * Ruby ≥ 2.3 37 | * macOS Keychain / GNOME Keyring 38 | * At least one account with Amazon Web Services 39 | * An IAM role configured in the target account 40 | * An IAM user with rights to assume that role 41 | 42 | Install 43 | ------- 44 | 45 | ```sh 46 | gem install aws_assume_role 47 | ``` 48 | 49 | ### Platform notes 50 | 51 | Gnome Keyring uses the [GirFFI](https://github.com/mvz/gir_ffi) bindings, which 52 | require introspection bindings as well as Gnone Keyring, by installing one of the following packages: 53 | 54 | ``` sh 55 | # Debian/Ubuntu 56 | apt-get install gnome-keyring libgirepository1.0-dev libgnome-keyring-common libgnome-keyring-dev 57 | 58 | # Fedora 59 | dnf install gobject-introspection-devel 60 | 61 | # CentOS 62 | yum install gobject-introspection-devel 63 | ``` 64 | Setup 65 | ----- 66 | 67 | You should already have an IAM user that you can log in to via AWS' console. 68 | If you do not already have an AWS access key and matching secret key for your 69 | own IAM user, use the AWS console to create that credential pair. 70 | 71 | aws-assume-role works best if you also store permanent credentials in your keystore: 72 | 73 | ``` sh 74 | > aws-assume-role configure 75 | Enter the profile name to save into configuration 76 | company_sso 77 | Enter the AWS region you would like to default to: 78 | eu-west-1 79 | Enter the AWS Access Key ID to use for this profile: 80 | 1234567890010 81 | Enter the AWS Secret Access Key to use for this profile: 82 | abcdefghijklmnopqrstuvwzyx1 83 | Profile `company_sso` saved to '/home/growthsmith/.aws/config' 84 | ``` 85 | 86 | ### Configuring roles 87 | Now that you've set up permanent credentials in your OS credential store, you can now 88 | set up a role that you will assume in every day use: 89 | 90 | ``` sh 91 | > aws-assume-role configure role -p company-dev --source-profile company_sso \ 92 | --role-arn=arn:aws:iam::000000000001:role/ViewEC2 --role-session-name=growthsmith \ 93 | --mfa-serial automatic 94 | ``` 95 | `--mfa-serial automatic` will look up your default attached multi-factor device, but you can specify a specific ARN. 96 | 97 | More options are available in the application help. 98 | Use `> aws-assume-role --help ` for help at any time. 99 | 100 | Using MFA TOTP with a Yubikey 101 | ----------------------------- 102 | 103 | [Yubikeys support TOTP](https://developers.yubico.com/OATH/) this offers some 104 | benefits over using a phone. One benefit is the TOTP token can be retrieved by 105 | an API call rather than a user reading the token from the device. 106 | 107 | This allows developers to call AWS through aws-assume-role, providing an MFA 108 | token without prompting for user input. To use this specify 109 | `--yubikey-oath-name` when calling configure role. 110 | 111 | ``` sh 112 | > aws-assume-role configure role -p company-dev --source-profile company_sso \ 113 | --role-arn=arn:aws:iam::000000000001:role/ViewEC2 --role-session-name=growthsmith \ 114 | --mfa-serial automatic --yubikey-oath-name "Amazon Web Services:myuser@company_sso" 115 | ``` 116 | 117 | _Yubikey Support_: `aws-assume-role` uses the [smartcard gem](https://rubygems.org/gems/smartcard) 118 | to connect to the Yubikey, this itself depends upon some C libraries being installed. They provide 119 | [platform specific instructions](https://github.com/costan/smartcard/blob/master/BUILD#L19) 120 | for installing these libraries PC/SC. 121 | 122 | Testing a profile 123 | ----------------- 124 | You can test a profile using 125 | ```sh 126 | > aws-assume-role test -p company_sso 127 | Logged in as: 128 | User: 9999999999 129 | Account: arn:aws:iam::3333333333:user/username 130 | ARN: AIDAIOSWINGTB 131 | 132 | ``` 133 | 134 | Running applications 135 | -------------------- 136 | 137 | You can run another application using 138 | 139 | ``` sh 140 | aws-assume-role run -p company-dev -- aws ec2 describe-instances --query \ 141 | "Reservations[*].Instances[*].PrivateIpAddress" --output=text 142 | 10.254.4.20 143 | 10.254.4.15 144 | 10.254.0.10 145 | 10.254.4.5 146 | ``` 147 | 148 | Because we've enabled MFA, aws-assume-role will ask for your MFA token: 149 | ``` 150 | Please provide an MFA token 151 | 000000 152 | ``` 153 | 154 | Listing available profiles 155 | -------------------------- 156 | Configured profiles can be listed: 157 | ```sh 158 | > aws-assume-role list 159 | company_sso 160 | company2_sso 161 | company3_sso 162 | ``` 163 | 164 | Deleting a profile 165 | ------------------ 166 | If a set of credentials key needs revoking, or the profile isn't relevant anymore: 167 | ``` sh 168 | > aws-assume-role delete -p company_sso 169 | Please type the name of the profile, i.e. company_sso , to continue deletion. 170 | company_sso 171 | Profile company_sso deleted 172 | ``` 173 | 174 | Migrating AWS CLI profiles 175 | ------------------ 176 | It's better to revoke the existing keys and generate new ones. We try to overwrite the plaintext configuration 177 | file with random data, but this does not take care of ~/.aws/credentials and does not account for SSD wear 178 | levelling or copy-on-write snapshots. 179 | ``` 180 | aws-assume-role migrate -p company_sso 181 | Profile 'company_sso' migrated to keyring. 182 | ``` 183 | 184 | Exporting environment variables 185 | ------------------------------- 186 | You can use a session token in your shell any supported application without using 187 | `aws-assume-role`. 188 | 189 | You can also remove environment variables after finishing using the reset command. 190 | 191 | #### Bourne Shell and friends 192 | ``` sh 193 | > eval `./bin/aws-assume-role environment set -p company-dev` 194 | > eval `./bin/aws-assume-role environment reset` 195 | ``` 196 | 197 | #### fish 198 | ``` fish 199 | > set creds (bin/aws-assume-role environment set -s fish -p company-dev); eval $creds; set -e creds 200 | > set creds (bin/aws-assume-role environment reset -s fish); eval $creds; set -e creds 201 | ``` 202 | 203 | #### PowerShell 204 | ``` powershell 205 | > aws-assume-role environment set -s powershell -p company-dev | invoke-expression 206 | > aws-assume-role environment reset -s powershell | invoke-expression 207 | ``` 208 | 209 | Launch the AWS console 210 | --------------------- 211 | Given that `aws-assume-role` has knowledge of your role ARNs via AWS CLI profiles, you can 212 | get to the AWS console for that role/account using 213 | 214 | ``` sh 215 | > aws-assume-role console -p company_sso 216 | ``` 217 | 218 | `aws-assume-role` will first attempt to log in and get a federated UI link, and 219 | otherwise fall back to the "switch role" page. 220 | 221 | Using inside Ruby 222 | ----------------- 223 | To get a set of credentials via the OS credential store, or using console-based MFA, use 224 | the following: 225 | ``` 226 | require "aws_assume_role" 227 | 228 | AwsAssumeRole::DefaultProvider.new(options).resolve 229 | ``` 230 | where options is a hash with the following symbol keys: 231 | * `access_key_id` 232 | * `secret_access_key` 233 | * `session_token` 234 | * `persist_session` 235 | * `duration_seconds` 236 | * `role_arn` 237 | * `role_session_name` 238 | * `serial_number` 239 | * `source_profile` 240 | * `region` 241 | 242 | `aws_assume_role` resolves credentials in almost the same way as the AWS SDK, i.e.: 243 | 244 | ```no-highlight 245 | static credentials ⟶ environment variables ⟶ configured profiles role ⟶ assumption (look up source profile and check for 2FA) 246 | ``` 247 | 248 | Any of the above may get chained to do MFA or role assumption, or both, 249 | in the following order: 250 | 251 | ```no-highlight 252 | second factor ⟶ ecs/instance profile 253 | ``` 254 | 255 | These are the same as the AWS SDK equivalents whereever possible. The command line help will give an explanation of the rest. 256 | 257 | ### Monkeypatching the AWS SDK 258 | You can also override the standard AWS SDK credential resolution system by including the following: 259 | ``` 260 | require "aws_assume_role/core_ext/aws-sdk/credential_provider_chain" 261 | ``` 262 | 263 | Using any standard AWS SDK for Ruby v2 client will then use aws_assume_role for credential resolution. 264 | 265 | 266 | Please do not use this in production systems. 267 | 268 | Other keyring backends 269 | ---------------------- 270 | `aws-assume-role` uses the Keyring gem for secure secret storage. By default, this will use OS X keycain 271 | or GNOME Keyring. To load alternatives, set the following environment variables: 272 | 273 | * `AWS_ASSUME_ROLE_KEYRING_BACKEND`: Which backend to use, as the name of the Ruby class. 274 | * `AWS_ASSUME_ROLE_KEYRING_PLUGIN` : Name of a gem to load. 275 | 276 | These are also available in Ruby as the `AwsAssumeRole.Config.backend_plugin` and 277 | `AwsAssumeRole.Config.backend_plugin` attributes. 278 | 279 | 280 | Development 281 | ----------- 282 | 283 | Tests are conducted by Travis. 284 | 285 | You can run these locally using Rake: 286 | 287 | ```shell 288 | bundle exec rake test 289 | ``` 290 | 291 | License 292 | ------- 293 | 294 | This library and program is distributed under the 295 | [Apache License, version 2.0](http://www.apache.org/licenses/LICENSE-2.0.html) 296 | 297 | ```no-highlight 298 | Copyright 2017. The Scale Factory Ltd. All Rights Reserved. 299 | Portions Copyright 2013. Amazon Web Services, Inc. All Rights Reserved. 300 | 301 | licensed under the apache license, version 2.0 (the "license"); 302 | you may not use this file except in compliance with the license. 303 | you may obtain a copy of the license at 304 | 305 | http://www.apache.org/licenses/license-2.0 306 | 307 | unless required by applicable law or agreed to in writing, software 308 | distributed under the license is distributed on an "as is" basis, 309 | without warranties or conditions of any kind, either express or implied. 310 | see the license for the specific language governing permissions and 311 | limitations under the license. 312 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "aws_assume_role/version" 4 | require "bundler/gem_tasks" 5 | require "yaml" 6 | 7 | task default: :test 8 | 9 | begin 10 | require "rspec/core/rake_task" 11 | RSpec::Core::RakeTask.new(:spec) 12 | rescue LoadError # rubocop:disable Lint/HandleExceptions 13 | end 14 | 15 | begin 16 | require "rubocop/rake_task" 17 | RuboCop::RakeTask.new(:rubocop) 18 | rescue LoadError # rubocop:disable Lint/HandleExceptions 19 | end 20 | 21 | task test: %i[no_pry rubocop spec] 22 | 23 | DISTRIBUTIONS = [ 24 | "universal-linux", 25 | "universal-freebsd", 26 | "universal-darwin", 27 | "universal-openbsd", 28 | ].freeze 29 | 30 | CREDENTIALS = { 31 | rubygems_api_key: ENV.fetch("API_KEY", "null"), 32 | }.freeze 33 | 34 | task :setup_credentials do 35 | FileUtils.mkdir_p(File.expand_path("~/.gem")) 36 | File.write(File.expand_path("~/.gem/credentials"), CREDENTIALS.to_yaml) 37 | end 38 | 39 | task publish: %i[build build_generic] do 40 | Dir.glob("#{File.dirname(__FILE__)}/pkg/*.gem") do |g| 41 | sh "gem push #{g}" 42 | end 43 | end 44 | 45 | namespace :build_arch do 46 | DISTRIBUTIONS.each do |arch| 47 | desc "build binary gem for #{arch}" 48 | task arch do 49 | sh "cd #{File.dirname(__FILE__)} && PLATFORM=#{arch} gem build aws_assume_role.gemspec" 50 | FileUtils.mkdir_p(File.join(File.dirname(__FILE__), "pkg")) 51 | sh "cd #{File.dirname(__FILE__)} && mv *.gem pkg/" 52 | end 53 | end 54 | end 55 | 56 | task build: DISTRIBUTIONS.map { |d| "build_arch:#{d}" } 57 | 58 | task :build_generic do 59 | sh "cd #{File.dirname(__FILE__)} && GENERIC_GEM=true gem build aws_assume_role.gemspec" 60 | FileUtils.mkdir_p(File.join(File.dirname(__FILE__), "pkg")) 61 | sh "cd #{File.dirname(__FILE__)} && mv *.gem pkg/" 62 | end 63 | 64 | task :no_pry do 65 | files = Dir.glob("**/**").reject { |x| x.match(/^spec|Gemfile|coverage|\.gemspec$|Rakefile|vendor/) || File.directory?(x) } 66 | files.each do |file| 67 | raise "Use of pry found in #{file}." if File.read(file) =~ /"pry"/ 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /aws_assume_role.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH << File.expand_path("../lib", __FILE__) 4 | require "aws_assume_role/version" 5 | 6 | PLATFORM = ENV.fetch("PLATFORM", Gem::Platform.local.os) 7 | 8 | Gem::Specification.new do |spec| 9 | spec.name = "aws_assume_role" 10 | spec.version = AwsAssumeRole::VERSION 11 | spec.authors = [ 12 | "Jon Topper", 13 | "Jack Thomas", 14 | "Naadir Jeewa", 15 | "David King", 16 | "Tim Bannister", 17 | "Phil Potter", 18 | "Tom Haynes", 19 | "Alan Ivey", 20 | "David O'Rourke", 21 | "Marko Bevc", 22 | ] 23 | spec.email = [ 24 | "jon@scalefactory.com", 25 | "jack@scalefactory.com", 26 | "naadir@scalefactory.com", 27 | "tim@scalefactory.com", 28 | "david@scalefactory.com", 29 | "marko@scalefactory.com", 30 | ] 31 | 32 | spec.description = "Used to fetch multiple AWS Role Credential "\ 33 | "Keys using different Session Keys "\ 34 | "and store them securely using Gnome Keyring "\ 35 | "or OSX keychain" 36 | spec.summary = "Manage AWS STS credentials with MFA" 37 | spec.homepage = "https://github.com/scalefactory/aws-assume-role" 38 | spec.license = "Apache-2.0" 39 | 40 | spec.files = `git ls-files -z`.split("\x0").reject { |f| 41 | f.match(%r{^(test|spec|features)/}) 42 | } 43 | spec.bindir = "bin" 44 | spec.executables = spec.files.grep(%r{^bin/aws}) { |f| File.basename(f) } 45 | spec.require_paths = ["lib"] 46 | spec.platform = PLATFORM unless ENV.fetch("GENERIC_GEM", false) 47 | spec.add_runtime_dependency "activesupport", "~> 4.2" 48 | spec.add_runtime_dependency "aws-sdk", "~> 2.7" 49 | spec.add_runtime_dependency "dry-configurable", "~> 0.5" 50 | spec.add_runtime_dependency "dry-equalizer", "~> 0.2.2" # 0.3.0 requires Ruby 2.4 51 | spec.add_runtime_dependency "dry-struct", "~> 0.5" 52 | spec.add_runtime_dependency "dry-types", "~> 0.13", "< 0.15" 53 | spec.add_runtime_dependency "dry-validation", "~> 0.11" 54 | spec.add_runtime_dependency "gli", "~> 2.15" 55 | spec.add_runtime_dependency "highline", "~> 1.6" 56 | spec.add_runtime_dependency "i18n", "~> 0.7" 57 | spec.add_runtime_dependency "inifile", "~> 3.0" 58 | spec.add_runtime_dependency "launchy", "~> 2.4" 59 | spec.add_runtime_dependency "keyring", "~> 0.4", ">= 0.4.1" 60 | spec.add_runtime_dependency "pastel", "~> 0.7" 61 | spec.add_runtime_dependency "smartcard", "~> 0.5.6" 62 | spec.add_runtime_dependency "yubioath", "~> 1.2", ">= 1.2.1" 63 | spec.add_development_dependency "rspec", "~> 3.5" 64 | spec.add_development_dependency "rubocop", "0.50" 65 | spec.add_development_dependency "yard", "~> 0.9" 66 | spec.add_development_dependency "simplecov", "~> 0.13" 67 | spec.add_development_dependency "webmock", "~> 2.3" 68 | 69 | case PLATFORM 70 | when /linux|bsd/ 71 | spec.add_dependency "gir_ffi-gnome_keyring", "~> 0.0", ">= 0.0.9" 72 | when /darwin/ 73 | spec.add_dependency "ruby-keychain", "~> 0.3", ">= 0.3.2" 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /bin/aws-assume-role: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require_relative "../lib/aws_assume_role/cli" 5 | -------------------------------------------------------------------------------- /i18n/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | commands: 3 | configure: 4 | desc: Configure AWS 5 | long_desc: | 6 | Configure AWS profiles. If this command is run with no arguments, 7 | you will be prompted for configuration values such as your AWS Access 8 | Key Id and you AWS Secret Access Key. You can configure a named pro- 9 | file using the --profile argument. If your config file does not exist 10 | (the default location is ~/.aws/config), the AWS CLI will create it for 11 | you. To keep an existing value, hit enter when prompted for the value. 12 | When you are prompted for information, the current value will be dis- 13 | played in [brackets]. If the config item has no value, it be displayed 14 | as [None]. Note that the configure command only work with values from 15 | the config file. It does not use any configuration values from envi- 16 | ronment variables or the IAM role. 17 | 18 | Note: The values you provide for the AWS Access Key ID and the AWS 19 | Secret Access Key will be written to your keyring backend. 20 | saved: Profile %s saved to %s 21 | console: 22 | desc: Launch the AWS console to switch to the profile role. 23 | long_desc: | 24 | Looks up the Role ARN in the profile, constructs the AWS console 25 | URL and launches your browser with it. It's a convenience, doesn't 26 | use any of the credentials. 27 | set_environment: 28 | desc: Export assumed credentials to your shell environment. 29 | long_desc: | 30 | Set up environment variables in your shell so that you can run AWS CLI 31 | or other apps using those credentials without running aws-assume-role 32 | again. 33 | Supports Bourne, CSh, Fish and PowerShell. 34 | shells: 35 | powershell: Use `aws-assume-role environment set -s powershell -p | Invoke-Expression` to load into environment 36 | others: Use `eval aws-assume-role environment set -p ` to load into environment 37 | fish: Use `set creds (bin/aws-assume-role environment set -s fish); eval $creds; set -e creds` 38 | reset_environment: 39 | desc: Delete AWS environment variables. 40 | long_desc: | 41 | Cleans up your shell environment by removing the following environment variables: 42 | AWS_ACCESS_KEY_ID 43 | AWS_SECRET_ACCESS_KEY 44 | AWS_DEFAULT_REGION 45 | AWS_PROFILE 46 | AWS_ASSUME_ROLE_LOG_LEVEL 47 | GLI_DEBUG 48 | Supports Bourne, CSh, Fish and PowerShell. 49 | shells: 50 | powershell: Use `aws-assume-role environment reset -s powershell -p | Invoke-Expression` to load into environment 51 | others: Use `eval aws-assume-role environment reset -p ` to load into environment 52 | fish: Use `set creds (bin/aws-assume-role environment reset -s fish); eval $creds; set -e creds` 53 | run: 54 | desc: Run a program with credentials set in the environment. 55 | delete: 56 | desc: Delete a profile 57 | completed: "Profile %s deleted" 58 | not_found: "Cannot find profile %s. Try running `aws-assume-role list`" 59 | list: 60 | desc: List configured profiles 61 | migrate: 62 | desc: Migrate a store to secure storage. 63 | not_found: "Cannot find profile %s. Try running `aws-assume-role list`" 64 | saved: Profile %s migrated within %s 65 | test: 66 | desc: Check that credentials work 67 | output: | 68 | Logged in as: 69 | User: %s 70 | Account: %s 71 | ARN: %s 72 | options: 73 | aws_access_key_id: "Enter the AWS Access Key ID to use for this profile" 74 | aws_secret_access_key: "Enter the AWS Secret Access Key to use for this profile" 75 | region: Enter the AWS region you would like to default to 76 | profile_name: Enter the profile name to save into configuration 77 | mfa_token: 78 | first_time: "Please provide an MFA token" 79 | other_times: "Credentials have expired, please provide another MFA" 80 | smartcard_not_supported: "Smartcard drivers not installed, see https://github.com/scalefactory/aws-assume-role/blob/master/README.md for details" 81 | default_role: "A default role to assume (leave blank to not use)" 82 | external_id: String provided by the external account holder to uniquely identify you. 83 | source_profile: Which profile to use to assume this role. 84 | role_session_name: Name to uniquely identify your session 85 | mfa_serial: The identification number of the MFA device. Leave blank to determine dynamically at run time. 86 | role_arn: The Amazon Resource Name (ARN) of the role to assume. 87 | duration_seconds: Default session length 88 | shell_type: What type of shell to use. 89 | name_to_delete: Please type the name of the profile, i.e. %s , to continue deletion. 90 | yubikey_oath_name: Identifier of the OATH / TOTP secret stored on the yubikey. 91 | program_description: "A tool for AWS credential management" 92 | errors: 93 | NoSuchProfileError: Profile %s not found in shared configuration. 94 | SmartcardException: No YubiKey found! 95 | MissingCredentialsError: No credentials found! 96 | rules: 97 | profile: 98 | filled?: --profile must be specified. 99 | role-arn: 100 | format?: "--role-arn must be specified as an ARN in the format `arn:aws:iam::account-id:role/role-name`" 101 | filled?: --role-arn is required. 102 | serial-number: 103 | format?: "--mfa-serial must be specified as an ARN in the format `arn:aws:iam::account-id:mfa/virtual-device-name`" 104 | filled?: --mfa-serial is required. 105 | region: 106 | format?: "--region must be a valid AWS Standard, China or GovCloud region" 107 | filled?: --region is required 108 | role_specification: 109 | filled?: "Either specify --profile OR (--role-arn AND --role-session-name, or neither)" 110 | -------------------------------------------------------------------------------- /lib/aws_assume_role.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "aws_assume_role/store/shared_config_with_keyring" 4 | require_relative "aws_assume_role/credentials/factories/default_chain_provider" 5 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "ui" 5 | require_relative "logging" 6 | 7 | module AwsAssumeRole::Cli 8 | include AwsAssumeRole 9 | include AwsAssumeRole::Ui 10 | include AwsAssumeRole::Logging 11 | logger.debug "Bootstrapping" 12 | include GLI::DSL 13 | include GLI::App 14 | extend self # rubocop:disable Style/ModuleFunction 15 | 16 | commands_from File.join(File.realpath(__dir__), "cli", "commands") 17 | program_desc t "program_description" 18 | 19 | exit run(ARGV) 20 | end 21 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/abstract_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../profile_configuration" 5 | 6 | class AwsAssumeRole::Cli::Actions::AbstractAction 7 | include AwsAssumeRole 8 | include AwsAssumeRole::Types 9 | include AwsAssumeRole::Ui 10 | include AwsAssumeRole::Logging 11 | CommandSchema = proc { raise "CommandSchema Not implemented" } 12 | 13 | def initialize(global_options, options, args) 14 | config = ProfileConfiguration.new_from_cli(global_options, options, args) 15 | logger.debug "Config initialized with #{config.to_hash}" 16 | result = validate_options(config.to_hash) 17 | logger.debug "Config validated as #{result.to_hash}" 18 | result.success? ? act_on(config) : Ui.show_validation_errors(result) 19 | end 20 | 21 | private 22 | 23 | def try_for_credentials(config) 24 | @provider ||= AwsAssumeRole::Credentials::Factories::DefaultChainProvider.new(config.to_hash) 25 | creds = @provider.resolve(nil_with_role_not_set: true) 26 | logger.debug "Got credentials #{creds}" 27 | return creds unless creds.nil? 28 | rescue Smartcard::PCSC::Exception 29 | error t("errors.SmartcardException") 30 | exit 403 31 | rescue NoMethodError 32 | error t("errors.MissingCredentialsError") 33 | exit 404 34 | end 35 | 36 | def resolved_region 37 | @provider.region 38 | end 39 | 40 | def resolved_profile 41 | @provider.profile 42 | end 43 | 44 | def validate_options(options) 45 | command_schema = self.class::CommandSchema 46 | ::Dry::Validation.Schema do 47 | configure { config.messages = :i18n } 48 | instance_eval(&command_schema) 49 | end.call(options) 50 | end 51 | 52 | def prompt_for_option(key, option_name, validator, fmt: nil) 53 | text_lookup = t("options.#{key}") 54 | text = fmt.nil? ? text_lookup : format(text_lookup, fmt) 55 | Ui.ask_with_validation(option_name, text) { instance_eval(&validator) } 56 | end 57 | 58 | def act_on(_options) 59 | raise "Act On Not Implemented" 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/configure_profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_action" 4 | require_relative "../../store/shared_config_with_keyring" 5 | 6 | class AwsAssumeRole::Cli::Actions::ConfigureProfile < AwsAssumeRole::Cli::Actions::AbstractAction 7 | CommandSchema = proc do 8 | required(:profile) 9 | optional(:region) { filled? > format?(REGION_REGEX) } 10 | optional(:mfa_serial) 11 | optional(:profile_name) 12 | optional(:yubikey_oath_name) 13 | end 14 | 15 | def act_on(config) 16 | new_hash = config.to_h 17 | profile = config.profile || prompt_for_option(:profile_name, "profile", proc { filled? }) 18 | new_hash[:region] = prompt_for_option(:region, "region", proc { filled? > format?(REGION_REGEX) }) 19 | new_hash[:aws_access_key_id] = prompt_for_option(:aws_access_key_id, "aws_access_key_id", ACCESS_KEY_VALIDATOR) 20 | new_hash[:aws_secret_access_key] = prompt_for_option(:aws_secret_access_key, "aws_secret_access_key", proc { filled? }) 21 | AwsAssumeRole.shared_config.save_profile(profile, new_hash) 22 | out format(t("commands.configure.saved"), profile, AwsAssumeRole.shared_config.config_path) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/configure_role_assumption.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_action" 4 | 5 | class AwsAssumeRole::Cli::Actions::ConfigureRoleAssumption < AwsAssumeRole::Cli::Actions::AbstractAction 6 | CommandSchema = proc do 7 | required(:profile) 8 | required(:source_profile) { str? } 9 | optional(:region) { filled? > format?(REGION_REGEX) } 10 | optional(:serial_number) { filled? > format?(MFA_REGEX) } 11 | required(:role_session_name).filled? 12 | required(:role_arn) { filled? & format?(ROLE_REGEX) } 13 | required(:external_id).filled? 14 | required(:duration_seconds).filled? 15 | optional(:yubikey_oath_name) 16 | end 17 | 18 | def act_on(config) 19 | AwsAssumeRole.shared_config.save_profile(config.profile, config.to_h.compact) 20 | out format(t("commands.configure.saved"), config.profile, AwsAssumeRole.shared_config.config_path) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/console.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../runner" 5 | require "cgi" 6 | require "json" 7 | 8 | class AwsAssumeRole::Cli::Actions::Console < AwsAssumeRole::Cli::Actions::AbstractAction 9 | include AwsAssumeRole::Ui 10 | include AwsAssumeRole::Logging 11 | 12 | FEDERATION_URL = "https://signin.aws.amazon.com/federation".freeze 13 | CONSOLE_URL = "https://console.aws.amazon.com".freeze 14 | GENERIC_SIGNIN_URL = "https://signin.aws.amazon.com/console".freeze 15 | SIGNIN_URL = [FEDERATION_URL, "?Action=getSigninToken", "&Session=%s"].join 16 | LOGIN_URL = [FEDERATION_URL, "?Action=login", "&Destination=%s", "&SigninToken=%s"].join 17 | 18 | CommandSchema = proc do 19 | required(:profile).maybe 20 | optional(:region) { filled? > format?(REGION_REGEX) } 21 | optional(:serial_number) { filled? > format?(MFA_REGEX) } 22 | required(:role_arn).maybe 23 | required(:role_session_name).maybe 24 | required(:duration_seconds).maybe 25 | rule(role_specification: %i[profile role_arn role_session_name duration_seconds]) do |p, r, s, d| 26 | (p.filled? | p.empty? & r.filled?) & (r.filled? > s.filled? & d.filled?) 27 | end 28 | end 29 | 30 | def try_federation(config) 31 | credentials = try_for_credentials config.to_h 32 | return unless credentials.set? 33 | session = session_json(credentials) 34 | signin_url = format SIGNIN_URL, CGI.escape(session) 35 | sso_token = JSON.parse(URI.parse(signin_url).read)["SigninToken"] 36 | format LOGIN_URL, CGI.escape(CONSOLE_URL), CGI.escape(sso_token) 37 | rescue OpenURI::HTTPError 38 | error "Error getting federated session, forming simple switch URL instead" 39 | end 40 | 41 | def session_json(credentials) 42 | { 43 | sessionId: credentials.credentials.access_key_id, 44 | sessionKey: credentials.credentials.secret_access_key, 45 | sessionToken: credentials.credentials.session_token, 46 | }.to_json 47 | end 48 | 49 | def try_switch_url(config) 50 | profile = AwsAssumeRole.shared_config.determine_profile(profile_name: config.profile) 51 | config_section = AwsAssumeRole.shared_config.parsed_config[profile] 52 | raise Aws::Errors::NoSuchProfileError if config_section.nil? 53 | resolved_role_arn = config.role_arn || config_section.fetch("role_arn", nil) 54 | return unless resolved_role_arn 55 | components = resolved_role_arn.split(":") 56 | account = components[4] 57 | role = components[5].split("/").last 58 | display_name = config.profile || "#{account}_#{role}" 59 | format "https://signin.aws.amazon.com/switchrole?account=%s&roleName=%s&displayName=%s", account, role, display_name 60 | end 61 | 62 | def act_on(config) 63 | final_url = try_federation(config) || try_switch_url(config) || CONSOLE_URL 64 | Launchy.open final_url 65 | rescue KeyError, Aws::Errors::NoSuchProfileError 66 | error format(t("errors.NoSuchProfileError"), config.profile) 67 | rescue Aws::Errors::MissingCredentialsError 68 | error t("errors.MissingCredentialsError") 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/delete_profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../store/shared_config_with_keyring" 5 | 6 | class AwsAssumeRole::Cli::Actions::DeleteProfile < AwsAssumeRole::Cli::Actions::AbstractAction 7 | CommandSchema = proc do 8 | required(:profile).value(:filled?) 9 | end 10 | 11 | def act_on(config) 12 | prompt_for_option(:name_to_delete, "Name", proc { eql? config.profile }, fmt: config.profile) 13 | AwsAssumeRole.shared_config.delete_profile config.profile 14 | out format t("commands.delete.completed"), config.profile 15 | rescue KeyError, Aws::Errors::NoSuchProfileError 16 | error format(t("errors.NoSuchProfileError"), config.profile) 17 | raise 18 | rescue Aws::Errors::MissingCredentialsError 19 | error t("errors.MissingCredentialsError") 20 | raise 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../includes" 4 | require_relative "../../types" 5 | require_relative "../../../aws_assume_role" 6 | 7 | module AwsAssumeRole 8 | module Cli 9 | module Actions 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/list_profiles.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | 5 | class AwsAssumeRole::Cli::Actions::ListProfiles < AwsAssumeRole::Cli::Actions::AbstractAction 6 | CommandSchema = proc do 7 | end 8 | 9 | def act_on(_options) 10 | AwsAssumeRole.shared_config.profiles.each { |p| puts p } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/migrate_profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | 5 | class AwsAssumeRole::Cli::Actions::MigrateProfile < AwsAssumeRole::Cli::Actions::AbstractAction 6 | CommandSchema = proc do 7 | required(:profile).value(:filled?) 8 | end 9 | 10 | def act_on(config) 11 | AwsAssumeRole.shared_config.migrate_profile config.profile 12 | out format(t("commands.configure.saved"), config[:profile], AwsAssumeRole.shared_config.config_path) 13 | rescue KeyError, Aws::Errors::NoSuchProfileError 14 | error format(t("errors.NoSuchProfileError"), config.profile) 15 | raise 16 | rescue Aws::Errors::MissingCredentialsError 17 | error t("errors.MissingCredentialsError") 18 | raise 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/reset_environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | 5 | class AwsAssumeRole::Cli::Actions::ResetEnvironment < AwsAssumeRole::Cli::Actions::AbstractAction 6 | include AwsAssumeRole::Ui 7 | 8 | SHELL_STRINGS = { 9 | sh: { 10 | env_command: "unset %s; ", 11 | }, 12 | csh: { 13 | env_command: "unset %s; ", 14 | }, 15 | fish: { 16 | env_command: "set -ex %s; ", 17 | footer: "commands.reset_environment.shells.fish", 18 | }, 19 | powershell: { 20 | env_command: "remove-item ENV:%s; ", 21 | footer: "commands.reset_environment.shells.powershell", 22 | }, 23 | }.freeze 24 | 25 | CommandSchema = proc do 26 | required(:shell_type).value(included_in?: SHELL_STRINGS.stringify_keys.keys) 27 | end 28 | 29 | def act_on(config) 30 | shell_strings = SHELL_STRINGS[config.shell_type.to_sym] 31 | str = String.new("") 32 | %w[AWS_ACCESS_KEY_ID 33 | AWS_SECRET_ACCESS_KEY 34 | AWS_SESSION_TOKEN 35 | AWS_PROFILE 36 | AWS_ASSUME_ROLE_LOG_LEVEL 37 | GLI_DEBUG 38 | AWS_ASSUME_ROLE_KEYRING_BACKEND].each do |key| 39 | str << format(shell_strings[:env_command], key: key) if ENV.fetch(key, false) 40 | end 41 | str << "# #{pastel.yellow t(shell_strings.fetch(:footer, 'commands.set_environment.shells.others'))}" 42 | puts str 43 | rescue KeyError, Aws::Errors::NoSuchProfileError 44 | error format(t("errors.NoSuchProfileError"), config.profile) 45 | raise 46 | rescue Aws::Errors::MissingCredentialsError 47 | error t("errors.MissingCredentialsError") 48 | raise 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/run.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../runner" 5 | require_relative "../../credentials/factories/default_chain_provider" 6 | 7 | class AwsAssumeRole::Cli::Actions::Run < AwsAssumeRole::Cli::Actions::AbstractAction 8 | include AwsAssumeRole::Ui 9 | 10 | CommandSchema = proc do 11 | required(:profile).maybe 12 | optional(:region) { filled? > format?(REGION_REGEX) } 13 | optional(:serial_number) { filled? > format?(MFA_REGEX) } 14 | required(:role_arn).maybe 15 | required(:role_session_name).maybe 16 | required(:duration_seconds).maybe 17 | rule(role_specification: %i[profile role_arn role_session_name duration_seconds]) do |p, r, s, d| 18 | (p.filled? | p.empty? & r.filled?) & (r.filled? > s.filled? & d.filled?) 19 | end 20 | end 21 | 22 | def act_on(config) 23 | credentials = try_for_credentials config.to_h 24 | unless config.args.empty? 25 | Runner.new(command: config.args, 26 | environment: { "AWS_DEFAULT_REGION" => resolved_region }, 27 | credentials: credentials) 28 | end 29 | rescue KeyError, Aws::Errors::NoSuchProfileError 30 | error format(t("errors.NoSuchProfileError"), config.profile) 31 | raise 32 | rescue Aws::Errors::MissingCredentialsError 33 | error t("errors.MissingCredentialsError") 34 | raise 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/set_environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../credentials/factories/default_chain_provider" 5 | 6 | class AwsAssumeRole::Cli::Actions::SetEnvironment < AwsAssumeRole::Cli::Actions::AbstractAction 7 | include AwsAssumeRole::Ui 8 | 9 | SHELL_STRINGS = { 10 | sh: { 11 | env_command: "%s=%s; export %s; ", 12 | }, 13 | csh: { 14 | env_command: "setenv %s %s; ", 15 | }, 16 | fish: { 17 | env_command: "set -x %s %s; ", 18 | footer: "commands.set_environment.shells.fish", 19 | }, 20 | powershell: { 21 | env_command: "set-item ENV:%s %s; ", 22 | footer: "commands.set_environment.shells.powershell", 23 | }, 24 | }.freeze 25 | 26 | CommandSchema = proc do 27 | optional(:profile).filled? 28 | optional(:region) { filled? > format?(REGION_REGEX) } 29 | optional(:serial_number) { filled? > format?(MFA_REGEX) } 30 | optional(:external_id) { filled? > format?(EXTERNAL_ID_REGEX) } 31 | required(:shell_type).value(included_in?: SHELL_STRINGS.stringify_keys.keys) 32 | required(:role_arn).maybe { filled? > format?(ROLE_REGEX) } 33 | required(:role_session_name).maybe { filled? > format?(ROLE_SESSION_NAME_REGEX) } 34 | required(:duration_seconds).maybe 35 | rule(role_specification: %i[profile role_arn role_session_name duration_seconds]) do |p, r, s, d| 36 | (p.filled? | p.empty? & r.filled?) & (r.filled? > s.filled? & d.filled?) 37 | end 38 | end 39 | 40 | def act_on(config) 41 | credentials = try_for_credentials config.to_h 42 | shell_strings = SHELL_STRINGS[config.shell_type.to_sym] 43 | str = String.new("") 44 | [ 45 | [:access_key_id, "AWS_ACCESS_KEY_ID"], 46 | [:secret_access_key, "AWS_SECRET_ACCESS_KEY"], 47 | [:session_token, "AWS_SESSION_TOKEN"], 48 | ].each do |key| 49 | value = credentials.credentials.send key[0] 50 | next if value.blank? 51 | str << format(shell_strings[:env_command], key: key[1], value: value) 52 | end 53 | str << "# #{pastel.yellow t(shell_strings.fetch(:footer, 'commands.set_environment.shells.others'))}" 54 | puts str 55 | rescue KeyError, Aws::Errors::NoSuchProfileError 56 | error format(t("errors.NoSuchProfileError"), config.profile) 57 | raise 58 | rescue Aws::Errors::MissingCredentialsError 59 | error t("errors.MissingCredentialsError") 60 | raise 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/actions/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../credentials/factories/default_chain_provider" 5 | 6 | class AwsAssumeRole::Cli::Actions::Test < AwsAssumeRole::Cli::Actions::AbstractAction 7 | include AwsAssumeRole::Ui 8 | 9 | CommandSchema = proc do 10 | required(:profile).maybe 11 | optional(:region) { filled? > format?(REGION_REGEX) } 12 | optional(:serial_number) { filled? > format?(MFA_REGEX) } 13 | required(:role_arn).maybe 14 | required(:role_session_name).maybe 15 | required(:duration_seconds).maybe 16 | rule(role_specification: %i[profile role_arn role_session_name duration_seconds]) do |p, r, s, d| 17 | (p.filled? | p.empty? & r.filled?) & (r.filled? > s.filled? & d.filled?) 18 | end 19 | end 20 | 21 | def act_on(config) 22 | logger.debug "Will try for credentials" 23 | credentials = try_for_credentials config 24 | logger.debug "Got credentials #{credentials}" 25 | client = Aws::STS::Client.new(credentials: credentials, region: resolved_region) 26 | identity = client.get_caller_identity 27 | out format(t("commands.test.output"), identity.account, identity.arn, identity.user_id) 28 | rescue KeyError, Aws::Errors::NoSuchProfileError 29 | error format(t("errors.NoSuchProfileError"), config.profile) 30 | raise 31 | rescue Aws::Errors::MissingCredentialsError 32 | error t("errors.MissingCredentialsError") 33 | raise 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/configure.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/configure_profile" 4 | require_relative "../actions/configure_role_assumption" 5 | 6 | module AwsAssumeRole::Cli 7 | desc t "commands.configure.desc" 8 | long_desc t "commands.configure.long_desc" 9 | command :configure do |c| 10 | c.flag [:p, "profile"], desc: t("options.profile_name") 11 | c.action do |global_options, options, args| 12 | AwsAssumeRole::Cli::Actions::ConfigureProfile.new(global_options, options, args) 13 | end 14 | 15 | c.desc t "commands.configure.desc" 16 | c.long_desc t "commands.configure.long_desc" 17 | c.command :role do |r| 18 | r.flag ["source-profile"], desc: t("options.source_profile") 19 | r.flag ["role-session-name"], desc: t("options.role_session_name") 20 | r.flag ["role-arn"], desc: t("options.role_arn") 21 | r.flag ["mfa-serial"], desc: t("options.mfa_serial") 22 | r.flag ["region"], desc: t("options.region") 23 | r.flag ["external-id"], desc: t("options.external_id") 24 | r.flag ["duration-seconds"], desc: t("options.duration_seconds"), default_value: 3600 25 | r.flag ["yubikey-oath-name"], desc: t("options.yubikey_oath_name") 26 | 27 | r.action do |global_options, options, args| 28 | AwsAssumeRole::Cli::Actions::ConfigureRoleAssumption.new(global_options, options, args) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/console.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/console" 4 | 5 | module AwsAssumeRole::Cli 6 | desc t "commands.console.desc" 7 | command :console do |c| 8 | c.flag [:p, "profile"], desc: t("options.profile_name") 9 | c.flag ["role-session-name"], desc: t("options.role_session_name") 10 | c.flag ["role-arn"], desc: t("options.role_arn") 11 | c.flag ["mfa-serial"], desc: t("options.mfa_serial") 12 | c.flag ["region"], desc: t("options.region") 13 | c.flag ["external-id"], desc: t("options.external_id") 14 | c.flag ["duration-seconds"], desc: t("options.duration_seconds"), default_value: 3600 15 | c.action do |global_options, options, args| 16 | AwsAssumeRole::Cli::Actions::Console.new(global_options, options, args) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/delete.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/delete_profile" 4 | 5 | module AwsAssumeRole::Cli 6 | desc t "commands.delete.desc" 7 | command :delete do |c| 8 | c.flag [:p, "profile"], desc: t("options.profile_name") 9 | c.action do |global_options, options, args| 10 | AwsAssumeRole::Cli::Actions::DeleteProfile.new(global_options, options, args) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/set_environment" 4 | require_relative "../actions/reset_environment" 5 | 6 | module AwsAssumeRole::Cli 7 | desc t "commands.set_environment.desc" 8 | long_desc t "commands.set_environment.long_desc" 9 | command :environment do |c| 10 | desc t "commands.set_environment.desc" 11 | long_desc t "commands.set_environment.long_desc" 12 | c.command :set do |s| 13 | s.flag [:p, "profile"], desc: t("options.profile_name") 14 | s.flag [:s, "shell-type"], desc: t("options.shell_type"), default_value: "sh" 15 | s.flag ["role-session-name"], desc: t("options.role_session_name") 16 | s.flag ["role-arn"], desc: t("options.role_arn") 17 | s.flag ["mfa-serial"], desc: t("options.mfa_serial") 18 | s.flag ["region"], desc: t("options.region") 19 | s.flag ["external-id"], desc: t("options.external_id") 20 | s.flag ["duration-seconds"], desc: t("options.duration_seconds"), default_value: 3600 21 | s.action do |global_options, options, args| 22 | AwsAssumeRole::Cli::Actions::SetEnvironment.new(global_options, options, args) 23 | end 24 | end 25 | 26 | desc t "commands.reset_environment.desc" 27 | long_desc t "commands.reset_environment.long_desc" 28 | c.command :reset do |s| 29 | s.action do |global_options, options, args| 30 | AwsAssumeRole::Cli::Actions::ResetEnvironment.new(global_options, options, args) 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/list.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/list_profiles" 4 | 5 | module AwsAssumeRole::Cli 6 | desc t "commands.list.desc" 7 | command :list do |c| 8 | c.action do |global_options, options, args| 9 | AwsAssumeRole::Cli::Actions::ListProfiles.new(global_options, options, args) 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/migrate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/migrate_profile" 4 | 5 | module AwsAssumeRole::Cli 6 | desc t "commands.migrate.desc" 7 | command :migrate do |c| 8 | c.flag [:p, "profile"], desc: t("options.profile_name") 9 | c.action do |global_options, options, args| 10 | AwsAssumeRole::Cli::Actions::MigrateProfile.new(global_options, options, args) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/run.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/run" 4 | 5 | module AwsAssumeRole::Cli 6 | desc t "commands.run.desc" 7 | command :run do |c| 8 | c.flag [:p, "profile"], desc: t("options.profile_name") 9 | c.flag ["role-session-name"], desc: t("options.role_session_name") 10 | c.flag ["role-arn"], desc: t("options.role_arn") 11 | c.flag ["mfa-serial"], desc: t("options.mfa_serial") 12 | c.flag ["region"], desc: t("options.region") 13 | c.flag ["external-id"], desc: t("options.external_id") 14 | c.flag ["duration-seconds"], desc: t("options.duration_seconds"), default_value: 3600 15 | c.action do |global_options, options, args| 16 | AwsAssumeRole::Cli::Actions::Run.new(global_options, options, args) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/commands/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../actions/test" 4 | 5 | module AwsAssumeRole::Cli 6 | desc t "commands.test.desc" 7 | command :test do |c| 8 | c.flag [:p, "profile"], desc: t("options.profile_name") 9 | c.flag ["role-session-name"], desc: t("options.role_session_name") 10 | c.flag ["role-arn"], desc: t("options.role_arn") 11 | c.flag ["mfa-serial"], desc: t("options.mfa_serial") 12 | c.flag ["region"], desc: t("options.region") 13 | c.flag ["external-id"], desc: t("options.external_id") 14 | c.flag ["duration-seconds"], desc: t("options.duration_seconds"), default_value: 3600 15 | c.switch ["no-profile"], desc: t("options.duration_seconds"), default_value: false 16 | c.action do |global_options, options, args| 17 | AwsAssumeRole::Cli::Actions::Test.new(global_options, options, args) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/aws_assume_role/cli/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../includes" 4 | -------------------------------------------------------------------------------- /lib/aws_assume_role/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | 5 | module AwsAssumeRole 6 | class Configuration 7 | extend Dry::Configurable 8 | Types = Dry::Types.module 9 | 10 | setting(:backend_plugin, ENV.fetch("AWS_ASSUME_ROLE_KEYRING_PLUGIN", nil)) do |value| 11 | Types::Coercible::String[value] 12 | end 13 | 14 | setting(:backend, ENV.fetch("AWS_ASSUME_ROLE_KEYRING_BACKEND", "automatic")) do |value| 15 | value == "automatic" ? nil : Types::Coercible::String[value] 16 | end 17 | 18 | setting(:log_level, ENV.fetch("AWS_ASSUME_ROLE_LOG_LEVEL", "WARN")) do |value| 19 | { 20 | DEBUG: 0, 21 | INFO: 1, 22 | WARN: 2, 23 | ERROR: 3, 24 | FATAL: 4, 25 | UNKNOWN: 5, 26 | }[value.to_sym] || 2 27 | end 28 | end 29 | Config = Configuration.config 30 | end 31 | -------------------------------------------------------------------------------- /lib/aws_assume_role/core_ext/aws-sdk/credential_provider_chain.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../credentials/factories/default_chain_provider" 4 | Aws.const_set :CredentialProviderChain, AwsAssumeRole::Credentials::Factories::DefaultChainProvider 5 | -------------------------------------------------------------------------------- /lib/aws_assume_role/core_ext/aws-sdk/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../../includes" 4 | module AwsAssumeRole 5 | module CoreExt 6 | module Aws 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "factories/repository" 4 | require_relative "factories/abstract_factory" 5 | require_relative "factories/default_chain_provider" 6 | require_relative "factories/assume_role" 7 | require_relative "factories/environment" 8 | require_relative "factories/instance_profile" 9 | require_relative "factories/shared_keyring" 10 | require_relative "factories/shared" 11 | require_relative "factories/static" 12 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/abstract_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "repository" 5 | require_relative "../../profile_configuration" 6 | 7 | class AwsAssumeRole::Credentials::Factories::AbstractFactory 8 | include AwsAssumeRole 9 | include AwsAssumeRole::Credentials::Factories 10 | include AwsAssumeRole::Logging 11 | 12 | Dry::Types.register("aws.sharedcredentials", Aws::SharedCredentials) 13 | attr_reader :credentials, :region, :profile, :role_arn 14 | 15 | def initialize(_options) 16 | raise "Not implemented" 17 | end 18 | 19 | def self.type(str) 20 | @type = Types::Strict::Symbol.enum(:credential_provider, :second_factor_provider, :instance_role_provider)[str] 21 | register_if_complete 22 | end 23 | 24 | def self.priority(i) 25 | @priority = Types::Strict::Integer[i] 26 | register_if_complete 27 | end 28 | 29 | def self.register_if_complete 30 | return unless @type && @priority 31 | Repository.register_factory(self, @type, @priority) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/assume_role.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_factory" 4 | require_relative "../providers/assume_role_credentials" 5 | require_relative "../providers/mfa_session_credentials" 6 | 7 | class AwsAssumeRole::Credentials::Factories::AssumeRole < AwsAssumeRole::Credentials::Factories::AbstractFactory 8 | include AwsAssumeRole::Credentials::Factories 9 | type :credential_provider 10 | priority 20 11 | 12 | def initialize(options) 13 | logger.debug "AwsAssumeRole::Credentials::Factories::AssumeRole initiated with #{options}" 14 | return unless options[:profile] || options[:role_arn] 15 | if options[:profile] 16 | logger.debug "AwsAssumeRole: #{options[:profile]} found. Trying with profile" 17 | try_with_profile(options) 18 | else 19 | if options[:use_mfa] 20 | options[:credentials] = AwsAssumeRole::Credentials::Providers::MfaSessionCredentials.new(options).credentials 21 | end 22 | @credentials = AwsAssumeRole::Credentials::Providers::AssumeRoleCredentials.new(options) 23 | end 24 | end 25 | 26 | def try_with_profile(options) 27 | return unless AwsAssumeRole.shared_config.config_enabled? 28 | logger.debug "AwsAssumeRole: Shared Config enabled" 29 | @profile = options[:profile] 30 | @region = options[:region] 31 | @credentials = assume_role_with_profile(options) 32 | @region ||= AwsAssumeRole.shared_config.profile_region(@profile) 33 | @role_arn ||= AwsAssumeRole.shared_config.profile_role(@profile) 34 | end 35 | 36 | def assume_role_with_profile(options) 37 | AwsAssumeRole.shared_config.assume_role_credentials_from_config(options) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/default_chain_provider.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../logging" 5 | require_relative "../../profile_configuration" 6 | require_relative "abstract_factory" 7 | require_relative "environment" 8 | require_relative "repository" 9 | require_relative "instance_profile" 10 | require_relative "assume_role" 11 | require_relative "shared" 12 | require_relative "static" 13 | 14 | class AwsAssumeRole::Credentials::Factories::DefaultChainProvider < Dry::Struct 15 | include AwsAssumeRole::Credentials::Factories 16 | include AwsAssumeRole::Logging 17 | 18 | transform_types { |t| t.meta(omittable: true) } 19 | 20 | attribute :access_key_id, Dry::Types["strict.string"].optional 21 | attribute :credentials, Dry::Types["object"].optional 22 | attribute :duration_seconds, Dry::Types["coercible.integer"].optional 23 | attribute :external_id, Dry::Types["strict.string"].optional 24 | attribute :instance_profile_credentials_retries, Dry::Types["strict.integer"].default(0) 25 | attribute :instance_profile_credentials_timeout, Dry::Types["coercible.float"].default(1.0) 26 | attribute :mfa_serial, Dry::Types["strict.string"].optional 27 | attribute :no_profile, Dry::Types["strict.bool"].default(false) 28 | attribute :path, Dry::Types["strict.string"].optional 29 | attribute :persist_session, Dry::Types["strict.bool"].default(true) 30 | attribute :profile_name, Dry::Types["strict.string"].optional 31 | attribute :profile, Dry::Types["strict.string"].optional 32 | attribute :region, Dry::Types["strict.string"].optional 33 | attribute :role_arn, Dry::Types["strict.string"].optional 34 | attribute :role_session_name, Dry::Types["strict.string"].optional 35 | attribute :secret_access_key, Dry::Types["strict.string"].optional 36 | attribute :serial_number, Dry::Types["strict.string"].optional 37 | attribute :session_token, Dry::Types["strict.string"].optional 38 | attribute :source_profile, Dry::Types["strict.string"].optional 39 | attribute :use_mfa, Dry::Types["strict.bool"].default(false) 40 | attribute :yubikey_oath_name, Dry::Types["strict.string"].optional 41 | 42 | def self.new(options) 43 | if options.respond_to? :resolve 44 | finalize_instance new_with_seahorse(options) 45 | else 46 | finalize_instance(options) 47 | end 48 | end 49 | 50 | def self.finalize_instance(options) 51 | new_opts = options.to_h 52 | new_opts[:profile_name] ||= new_opts[:profile] 53 | new_opts[:original_profile] = new_opts[:profile_name] 54 | instance = allocate 55 | instance.send(:initialize, new_opts) 56 | instance 57 | end 58 | 59 | def self.new_with_seahorse(resolver) 60 | keys = resolver.resolve 61 | options = keys.map do |k| 62 | [k, resolver.send(k)] 63 | end 64 | finalize_instance(options.to_h) 65 | end 66 | 67 | def resolve(nil_with_role_not_set: false, explicit_default_profile: false) 68 | resolve_final_credentials(explicit_default_profile) 69 | # nil_creds = Aws::Credentials.new(nil, nil, nil) 70 | return nil if (nil_with_role_not_set && 71 | @role_arn && 72 | @credentials.credentials.session_token.nil?) || @credentials.nil? 73 | @credentials 74 | end 75 | 76 | def to_h 77 | to_hash 78 | end 79 | 80 | private 81 | 82 | def resolve_final_credentials(explicit_default_profile = false) 83 | resolve_credentials(:credential_provider, true, explicit_default_profile) 84 | return @credentials if @credentials && @credentials.set? && !use_mfa && !role_arn 85 | resolve_credentials(:second_factor_provider, true, explicit_default_profile) 86 | return @credentials if @credentials && @credentials.set? 87 | resolve_credentials(:instance_role_provider, true, explicit_default_profile) 88 | return @credentials if @credentials && @credentials.set? 89 | nil 90 | end 91 | 92 | def resolve_credentials(type, break_if_successful = false, explicit_default_profile = false) 93 | factories_to_try = Repository.factories[type] 94 | factories_to_try.each do |x| 95 | options = to_h 96 | options[:credentials] = credentials if credentials && credentials.set? 97 | logger.debug "About to try credential lookup with #{x}" 98 | factory = x.new(options) 99 | @region ||= factory.region 100 | @profile ||= factory.profile 101 | @role_arn ||= factory.role_arn 102 | next unless factory.credentials && factory.credentials.set? 103 | logger.debug "Profile currently #{@profile}" 104 | next if explicit_default_profile && (@profile == "default") && (@profile != @original_profile) 105 | @credentials ||= factory.credentials 106 | logger.debug "Got #{@credentials}" 107 | break if break_if_successful 108 | end 109 | end 110 | end 111 | 112 | module AwsAssumeRole 113 | DefaultProvider = AwsAssumeRole::Credentials::Factories::DefaultChainProvider 114 | end 115 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_factory" 4 | 5 | class AwsAssumeRole::Credentials::Factories::Environment < AwsAssumeRole::Credentials::Factories::AbstractFactory 6 | type :credential_provider 7 | priority 10 8 | 9 | def initialize(_options, **) 10 | key = %w[AWS_ACCESS_KEY_ID AMAZON_ACCESS_KEY_ID AWS_ACCESS_KEY] 11 | secret = %w[AWS_SECRET_ACCESS_KEY AMAZON_SECRET_ACCESS_KEY AWS_SECRET_KEY] 12 | token = %w[AWS_SESSION_TOKEN AMAZON_SESSION_TOKEN] 13 | region = %w[AWS_DEFAULT_REGION] 14 | profile = %w[AWS_PROFILE] 15 | @credentials = Aws::Credentials.new(envar(key), envar(secret), envar(token)) 16 | @region = envar(region) 17 | @profile = envar(profile) 18 | end 19 | 20 | def envar(keys) 21 | keys.each do |key| 22 | return ENV[key] if ENV.key?(key) 23 | end 24 | nil 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../includes" 4 | require_relative "../../logging" 5 | require_relative "../../vendored/aws" 6 | require_relative "../../../aws_assume_role" 7 | 8 | module AwsAssumeRole::Credentials 9 | module Factories 10 | Types = Dry::Types.module 11 | include AwsAssumeRole 12 | include AwsAssumeRole::Logging 13 | include AwsAssumeRole::Vendored::Aws 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/instance_profile.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_factory" 4 | 5 | class AwsAssumeRole::Credentials::Factories::InstanceProfile < AwsAssumeRole::Credentials::Factories::AbstractFactory 6 | type :instance_role_provider 7 | priority 40 8 | 9 | def initialize(options = {}) 10 | options[:retries] ||= options[:instance_profile_credentials_retries] || 0 11 | options[:http_open_timeout] ||= options[:instance_profile_credentials_timeout] || 1 12 | options[:http_read_timeout] ||= options[:instance_profile_credentials_timeout] || 1 13 | @credentials = if ENV["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] 14 | Aws::ECSCredentials.new(options) 15 | else 16 | Aws::InstanceProfileCredentials.new(options) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/repository.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "abstract_factory" 5 | 6 | class AwsAssumeRole::Credentials::Factories::Repository 7 | include AwsAssumeRole::Credentials::Factories 8 | 9 | SubFactoryRepositoryType = Types::Hash.schema( 10 | Types::Coercible::Integer => Types::Strict::Array.meta(omittable: true), 11 | ) 12 | 13 | FactoryRepositoryType = Types::Hash.schema( 14 | credential_provider: SubFactoryRepositoryType, 15 | second_factor_provider: SubFactoryRepositoryType, 16 | instance_role_provider: SubFactoryRepositoryType, 17 | ) 18 | 19 | def self.factories 20 | repository.keys.map { |t| [t, flatten_factory_type_list(t)] }.to_h 21 | end 22 | 23 | def self.repository 24 | @repository ||= FactoryRepositoryType[ 25 | credential_provider: {}, 26 | second_factor_provider: {}, 27 | instance_role_provider: {}, 28 | ] 29 | end 30 | 31 | def self.register_factory(klass, type, priority) 32 | repository[type][priority] ||= [] 33 | repository[type][priority] << klass 34 | end 35 | 36 | def self.flatten_factory_type_list(type) 37 | repository[type].keys.sort.map { |x| @repository[type][x] }.flatten 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/shared.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_factory" 4 | require_relative "../providers/shared_keyring_credentials" 5 | 6 | class AwsAssumeRole::Credentials::Factories::Shared < AwsAssumeRole::Credentials::Factories::AbstractFactory 7 | type :credential_provider 8 | priority 30 9 | 10 | def initialize(options = {}) 11 | logger.debug "Shared Factory initiated with #{options}" 12 | @profile = options[:profile] 13 | @credentials = AwsAssumeRole::Credentials::Providers::SharedKeyringCredentials.new(options) 14 | @region = @credentials.region 15 | @role_arn = @credentials.role_arn 16 | rescue Aws::Errors::NoSuchProfileError 17 | nil 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/factories/static.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "abstract_factory" 4 | 5 | class AwsAssumeRole::Credentials::Factories::Static < AwsAssumeRole::Credentials::Factories::AbstractFactory 6 | type :credential_provider 7 | priority 0 8 | 9 | def initialize(options = {}) 10 | @credentials = Aws::Credentials.new( 11 | options[:access_key_id], 12 | options[:secret_access_key], 13 | options[:session_token], 14 | ) 15 | @region = options[:region] 16 | @profile = options[:profile] 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../includes" 4 | module AwsAssumeRole 5 | module Credentials end 6 | end 7 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/providers/assume_role_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require "set" 5 | 6 | class AwsAssumeRole::Credentials::Providers::AssumeRoleCredentials 7 | include AwsAssumeRole::Vendored::Aws::CredentialProvider 8 | include AwsAssumeRole::Vendored::Aws::RefreshingCredentials 9 | 10 | # @option options [required, String] :role_arn 11 | # @option options [required, String] :role_session_name 12 | # @option options [String] :policy 13 | # @option options [Integer] :duration_seconds 14 | # @option options [String] :external_id 15 | # @option options [STS::Client] :client 16 | # 17 | # 18 | 19 | STS_KEYS = %i[role_arn role_session_name policy duration_seconds external_id client credentials region].freeze 20 | 21 | def initialize(options = {}) 22 | client_opts = {} 23 | @assume_role_params = {} 24 | options.each_pair do |key, value| 25 | if self.class.assume_role_options.include?(key) 26 | @assume_role_params[key] = value 27 | else 28 | next unless STS_KEYS.include?(key) 29 | client_opts[key] = value 30 | end 31 | end 32 | @client = client_opts[:client] || ::Aws::STS::Client.new(client_opts) 33 | super 34 | end 35 | 36 | # @return [STS::Client] 37 | attr_reader :client 38 | 39 | private 40 | 41 | def refresh 42 | c = @client.assume_role(@assume_role_params).credentials 43 | @credentials = ::Aws::Credentials.new( 44 | c.access_key_id, 45 | c.secret_access_key, 46 | c.session_token, 47 | ) 48 | @expiration = c.expiration 49 | end 50 | 51 | class << self 52 | # @api private 53 | def assume_role_options 54 | @aro ||= begin 55 | input = ::Aws::STS::Client.api.operation(:assume_role).input 56 | Set.new(input.shape.member_names) 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/providers/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../includes" 4 | require_relative "../../vendored/aws" 5 | require_relative "../../ui" 6 | require_relative "../../logging" 7 | module AwsAssumeRole::Credentials 8 | module Providers end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/providers/mfa_session_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../types" 5 | require_relative "../../configuration" 6 | begin 7 | require "smartcard" 8 | require "yubioath" 9 | SMARTCARD_SUPPORT = true 10 | rescue LoadError 11 | SMARTCARD_SUPPORT = false 12 | end 13 | 14 | class AwsAssumeRole::Credentials::Providers::MfaSessionCredentials < Dry::Struct 15 | include AwsAssumeRole::Vendored::Aws::CredentialProvider 16 | include AwsAssumeRole::Vendored::Aws::RefreshingCredentials 17 | include AwsAssumeRole::Ui 18 | include AwsAssumeRole::Logging 19 | 20 | transform_types { |t| t.meta(omittable: true) } 21 | 22 | attribute :permanent_credentials, Dry::Types["object"] 23 | attribute :credentials, Dry::Types["object"] 24 | attribute :expiration, (Dry::Types["strict.time"] 25 | .default(Time.now) 26 | .constructor { |v| v.nil? ? Dry::Types::Undefined : v }) 27 | attribute :first_time, (Dry::Types["strict.bool"] 28 | .default(true) 29 | .constructor { |v| v.nil? ? Dry::Types::Undefined : v }) 30 | attribute :persist_session, (Dry::Types["strict.bool"] 31 | .default(true) 32 | .constructor { |v| v.nil? ? Dry::Types::Undefined : v }) 33 | attribute :duration_seconds, (Dry::Types["coercible.integer"] 34 | .default(3600) 35 | .constructor { |v| v.nil? ? Dry::Types::Undefined : v }) 36 | attribute :region, AwsAssumeRole::Types::Region 37 | attribute :serial_number, (AwsAssumeRole::Types::MfaSerial 38 | .default("automatic") 39 | .constructor { |v| v.nil? ? Dry::Types::Undefined : v }) 40 | attribute :yubikey_oath_name, Dry::Types["strict.string"].optional 41 | 42 | def initialize(options) 43 | options.each { |key, value| instance_variable_set("@#{key}", value) } 44 | @permanent_credentials ||= @credentials 45 | @credentials = nil 46 | @serial_number = resolve_serial_number(@serial_number) 47 | AwsAssumeRole::Vendored::Aws::RefreshingCredentials.instance_method(:initialize).bind(self).call(options) 48 | end 49 | 50 | private 51 | 52 | def keyring_username 53 | @keyring_username ||= "#{@identity.to_json}|#{@serial_number}" 54 | end 55 | 56 | def sts_client 57 | @sts_client ||= Aws::STS::Client.new(region: @region, credentials: @permanent_credentials) 58 | end 59 | 60 | def prompt_for_token 61 | text = @first_time ? t("options.mfa_token.first_time") : t("options.mfa_token.other_times") 62 | Ui.input.ask text 63 | end 64 | 65 | def initialized 66 | @first_time = false 67 | end 68 | 69 | def refresh 70 | return set_credentials_from_keyring if @persist_session && @first_time 71 | refresh_using_mfa if near_expiration? 72 | broadcast(:mfa_completed) 73 | end 74 | 75 | def retrieve_yubikey_token 76 | raise t("options.mfa_token.smartcard_not_supported") unless SMARTCARD_SUPPORT 77 | context = Smartcard::PCSC::Context.new 78 | raise "Yubikey not found" unless context.readers.length == 1 79 | reader_name = context.readers.first 80 | card = Smartcard::PCSC::Card.new(context, reader_name, :shared) 81 | YubiOATH.new(card).calculate(name: @yubikey_oath_name, timestamp: Time.now) 82 | end 83 | 84 | def refresh_using_mfa 85 | token_code = @yubikey_oath_name ? retrieve_yubikey_token : prompt_for_token 86 | token = sts_client.get_session_token( 87 | duration_seconds: @duration_seconds, 88 | serial_number: @serial_number, 89 | token_code: token_code, 90 | ) 91 | initialized 92 | instance_credentials token.credentials 93 | persist_credentials if @persist_session 94 | end 95 | 96 | def credentials_from_keyring 97 | @credentials_from_keyring ||= AwsAssumeRole::Store::Keyring.fetch keyring_username 98 | rescue Aws::Errors::NoSuchProfileError 99 | logger.debug "Key not found" 100 | @credentials_from_keyring = nil 101 | return nil 102 | end 103 | 104 | def persist_credentials 105 | AwsAssumeRole::Store::Keyring.save_credentials keyring_username, @credentials, expiration: @expiration 106 | end 107 | 108 | def instance_credentials(credentials) 109 | return unless credentials 110 | @credentials = AwsAssumeRole::Store::Serialization.credentials_from_hash(credentials) 111 | @expiration = credentials.respond_to?(:expiration) ? credentials.expiration : Time.parse(credentials[:expiration]) 112 | end 113 | 114 | def set_credentials_from_keyring 115 | instance_credentials credentials_from_keyring if credentials_from_keyring 116 | initialized 117 | refresh_using_mfa unless @credentials && !near_expiration? 118 | end 119 | 120 | def identity 121 | @identity ||= sts_client.get_caller_identity 122 | end 123 | 124 | def resolve_serial_number(serial_number) 125 | return serial_number unless serial_number.nil? || serial_number == "automatic" 126 | user_name = identity.arn.split("/")[1] 127 | "arn:aws:iam::#{identity.account}:mfa/#{user_name}" 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/aws_assume_role/credentials/providers/shared_keyring_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../../types" 5 | 6 | class AwsAssumeRole::Credentials::Providers::SharedKeyringCredentials < ::Aws::SharedCredentials 7 | include AwsAssumeRole::Logging 8 | attr_reader :region, :role_arn 9 | 10 | def initialize(options = {}) 11 | logger.debug "SharedKeyringCredentials initiated with #{options}" 12 | @path = options[:path] 13 | @path ||= AwsAssumeRole.shared_config.credentials_path 14 | @profile_name = options[:profile_name] ||= options[:profile] 15 | @profile_name ||= ENV["AWS_PROFILE"] 16 | @profile_name ||= AwsAssumeRole.shared_config.profile_name 17 | logger.debug "SharedKeyringCredentials resolved profile name #{@profile_name}" 18 | config = determine_config(@path, @profile_name) 19 | @role_arn = config.profile_hash(@profile_name) 20 | @region = config.profile_region(@profile_name) 21 | @role_arn = config.profile_role(@profile_name) 22 | attempted_credential = config.credentials(options) 23 | return unless attempted_credential && attempted_credential.set? 24 | @credentials = attempted_credential 25 | end 26 | 27 | private 28 | 29 | def determine_config(path, profile_name) 30 | if path && path == AwsAssumeRole.shared_config.credentials_path 31 | logger.debug "SharedKeyringCredentials found shared credential path" 32 | AwsAssumeRole.shared_config 33 | else 34 | logger.debug "SharedKeyringCredentials found custom credential path" 35 | AwsAssumeRole::Store::SharedConfigWithKeyring.new( 36 | credentials_path: path, 37 | profile_name: profile_name, 38 | ) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/aws_assume_role/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "i18n" 4 | require "active_support/json" 5 | require "active_support/core_ext/object/blank" 6 | require "active_support/core_ext/string/inflections" 7 | require "active_support/core_ext/hash/compact" 8 | require "active_support/core_ext/hash/keys" 9 | require "active_support/core_ext/hash/slice" 10 | require "aws-sdk" 11 | require "aws-sdk-core/ini_parser" 12 | require "dry-configurable" 13 | require "dry-struct" 14 | require "dry-validation" 15 | require "dry-types" 16 | require "English" 17 | require "gli" 18 | require "highline" 19 | require "inifile" 20 | require "json" 21 | require "keyring" 22 | require "launchy" 23 | require "logger" 24 | require "open-uri" 25 | require "pastel" 26 | require "securerandom" 27 | require "set" 28 | require "thread" 29 | require "time" 30 | 31 | module AwsAssumeRole 32 | module_function 33 | 34 | def shared_config 35 | enabled = ENV["AWS_SDK_CONFIG_OPT_OUT"] ? false : true 36 | @shared_config ||= SharedConfigWithKeyring.new(config_enabled: enabled) 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/aws_assume_role/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "configuration" 5 | module AwsAssumeRole::Logging 6 | module ClassMethods 7 | def logger 8 | @logger ||= begin 9 | logger = Logger.new($stderr) 10 | logger.level = AwsAssumeRole::Config.log_level 11 | ENV["GLI_DEBUG"] = "true" if AwsAssumeRole::Config.log_level.zero? 12 | logger 13 | end 14 | end 15 | end 16 | 17 | module InstanceMethods 18 | def logger 19 | self.class.logger 20 | end 21 | end 22 | 23 | def self.included(base) 24 | base.extend ClassMethods 25 | base.include InstanceMethods 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/aws_assume_role/profile_configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "logging" 5 | 6 | class AwsAssumeRole::ProfileConfiguration < Dry::Struct 7 | include AwsAssumeRole::Logging 8 | transform_types { |t| t.meta(omittable: true) } 9 | 10 | attribute :access_key_id, Dry::Types["strict.string"].optional 11 | attribute :credentials, Dry::Types["object"].optional 12 | attribute :secret_access_key, Dry::Types["strict.string"].optional 13 | attribute :session_token, Dry::Types["strict.string"].optional 14 | attribute :duration_seconds, Dry::Types["coercible.integer"].optional 15 | attribute :external_id, Dry::Types["strict.string"].optional 16 | attribute :path, Dry::Types["strict.string"].optional 17 | attribute :persist_session, Dry::Types["strict.bool"].optional.default(true) 18 | attribute :profile, Dry::Types["strict.string"].optional 19 | attribute :region, Dry::Types["strict.string"].optional 20 | attribute :role_arn, Dry::Types["strict.string"].optional 21 | attribute :role_session_name, Dry::Types["strict.string"].optional 22 | attribute :serial_number, Dry::Types["strict.string"].optional 23 | attribute :mfa_serial, Dry::Types["strict.string"].optional 24 | attribute :yubikey_oath_name, Dry::Types["strict.string"].optional 25 | attribute :use_mfa, Dry::Types["strict.bool"].optional.default(false) 26 | attribute :no_profile, Dry::Types["strict.bool"].optional.default(false) 27 | attribute :shell_type, Dry::Types["strict.string"].optional 28 | attribute :source_profile, Dry::Types["strict.string"].optional 29 | attribute :args, Dry::Types["strict.array"].optional.default([]) 30 | attribute :instance_profile_credentials_retries, Dry::Types["strict.integer"].default(0) 31 | attribute :instance_profile_credentials_timeout, Dry::Types["coercible.float"].default(1.0) 32 | 33 | attr_writer :credentials 34 | 35 | def self.merge_mfa_variable(options) 36 | new_hash = options.key?(:mfa_serial) ? options.merge(serial_number: options[:mfa_serial]) : options 37 | new_hash[:use_mfa] ||= new_hash.fetch(:serial_number, nil) ? true : false 38 | if new_hash.key?(:serial_number) && new_hash[:serial_number] == "automatic" 39 | new_hash.delete(:serial_number) 40 | end 41 | new_hash 42 | end 43 | 44 | def self.new_from_cli(global_options, options, args) 45 | options = global_options.merge options 46 | options = options.map do |k, v| 47 | [k.to_s.underscore.to_sym, v] 48 | end.to_h 49 | options[:args] = args 50 | new merge_mfa_variable(options) 51 | end 52 | 53 | def self.new_from_credential_provider_initialization(options) 54 | logger.debug "new_from_credential_provider_initialization with #{options.to_h}" 55 | new_from_credential_provider(options, credentials: nil, delete: []) 56 | end 57 | 58 | def self.new_from_credential_provider(options = {}, credentials: nil, delete: []) 59 | option_hash = options.to_h 60 | config = option_hash.fetch(:config, {}).to_h 61 | hash_to_merge = option_hash.merge config 62 | hash_to_merge.merge(credentials: credentials) if credentials 63 | delete.each do |k| 64 | hash_to_merge.delete k 65 | end 66 | hash = merge_mfa_variable(hash_to_merge) 67 | logger.debug "new_from_credential_provider with #{hash}" 68 | new hash 69 | end 70 | 71 | def to_h 72 | to_hash 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/aws_assume_role/runner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "logging" 5 | 6 | class AwsAssumeRole::Runner < Dry::Struct 7 | include AwsAssumeRole::Logging 8 | attribute :command, Dry::Types["coercible.array"].of(Dry::Types["strict.string"]).default([]) 9 | attribute :exit_on_error, Dry::Types["strict.bool"].default(true) 10 | attribute :expected_exit_code, Dry::Types["strict.integer"].default(0) 11 | attribute :environment, Dry::Types["strict.hash"].default({}) 12 | attribute :credentials, Dry::Types["object"].optional 13 | 14 | transform_types { |t| t.meta(omittable: true) } 15 | 16 | def initialize(options) 17 | super(options) 18 | command_to_exec = command.map(&:shellescape).join(" ") 19 | process_credentials unless credentials.blank? 20 | system @environment, command_to_exec 21 | exit_status = $CHILD_STATUS.exitstatus 22 | process_error(exit_status) if exit_status != expected_exit_code 23 | end 24 | 25 | private 26 | 27 | def process_credentials 28 | cred_env = { 29 | "AWS_ACCESS_KEY_ID" => credentials.credentials.access_key_id, 30 | "AWS_SECRET_ACCESS_KEY" => credentials.credentials.secret_access_key, 31 | "AWS_SESSION_TOKEN" => credentials.credentials.session_token, 32 | } 33 | @environment = environment.merge cred_env 34 | end 35 | 36 | def process_error(exit_status) 37 | logger.error "#{command} failed with #{exit_status}" 38 | exit exit_status if exit_on_error 39 | raise "#{command} failed with #{exit_status}" 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/aws_assume_role/store/includes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../includes" 4 | 5 | module AwsAssumeRole 6 | module Store 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/aws_assume_role/store/keyring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "serialization" 5 | require_relative "../configuration" 6 | require_relative "../logging" 7 | 8 | module AwsAssumeRole::Store::Keyring 9 | include AwsAssumeRole 10 | include AwsAssumeRole::Store 11 | include AwsAssumeRole::Logging 12 | 13 | module_function 14 | 15 | KEYRING_KEY = "AwsAssumeRole".freeze 16 | 17 | def semaphore 18 | @semaphore ||= Mutex.new 19 | end 20 | 21 | def keyrings 22 | @keyrings ||= {} 23 | end 24 | 25 | def try_backend_plugin 26 | return if AwsAssumeRole::Config.backend_plugin.blank? 27 | logger.info "Attempting to load #{AwsAssumeRole::Config.backend_plugin} plugin" 28 | require AwsAssumeRole::Config.backend_plugin 29 | end 30 | 31 | def keyring(backend = AwsAssumeRole::Config.backend) 32 | keyrings[backend] ||= begin 33 | try_backend_plugin 34 | klass = backend ? "Keyring::Backend::#{backend}".constantize : nil 35 | logger.debug "Initializing #{klass} backend" 36 | ::Keyring.new(klass) 37 | end 38 | end 39 | 40 | def fetch(id, backend: nil) 41 | logger.debug "Fetching #{id} from keyring" 42 | fetched = keyring(backend).get_password(KEYRING_KEY, id) 43 | raise Aws::Errors::NoSuchProfileError if fetched == "null" || fetched.nil? || !fetched 44 | JSON.parse(fetched, symbolize_names: true) 45 | end 46 | 47 | def delete_credentials(id, backend: nil) 48 | semaphore.synchronize do 49 | keyring(backend).delete_password(KEYRING_KEY, id) 50 | end 51 | end 52 | 53 | def save_credentials(id, credentials, expiration: nil, backend: nil) 54 | credentials_to_persist = Serialization.credentials_to_hash(credentials) 55 | credentials_to_persist[:expiration] = expiration if expiration 56 | semaphore.synchronize do 57 | keyring(backend).delete_password(KEYRING_KEY, id) 58 | keyring(backend).set_password(KEYRING_KEY, id, credentials_to_persist.to_json) 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/aws_assume_role/store/serialization.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AwsAssumeRole::Store::Serialization 4 | module_function 5 | 6 | def credentials_from_hash(credentials) 7 | creds_for_deserialization = credentials.respond_to?("[]") ? credentials : credentials_to_hash(credentials) 8 | Aws::Credentials.new(creds_for_deserialization[:access_key_id], 9 | creds_for_deserialization[:secret_access_key], 10 | creds_for_deserialization[:session_token]) 11 | end 12 | 13 | def credentials_to_hash(credentials) 14 | { 15 | access_key_id: credentials.access_key_id, 16 | secret_access_key: credentials.secret_access_key, 17 | session_token: credentials.session_token, 18 | } 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/aws_assume_role/store/shared_config_with_keyring.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | require_relative "../logging" 5 | require_relative "keyring" 6 | require_relative "../profile_configuration" 7 | require_relative "../credentials/providers/mfa_session_credentials" 8 | 9 | class AwsAssumeRole::Store::SharedConfigWithKeyring < AwsAssumeRole::Vendored::Aws::SharedConfig 10 | include AwsAssumeRole::Store 11 | include AwsAssumeRole::Logging 12 | 13 | attr_reader :parsed_config 14 | 15 | # @param [Hash] options 16 | # @option options [String] :credentials_path Path to the shared credentials 17 | # file. Defaults to "#{Dir.home}/.aws/credentials". 18 | # @option options [String] :config_path Path to the shared config file. 19 | # Defaults to "#{Dir.home}/.aws/config". 20 | # @option options [String] :profile_name The credential/config profile name 21 | # to use. If not specified, will check `ENV['AWS_PROFILE']` before using 22 | # the fixed default value of 'default'. 23 | # @option options [Boolean] :config_enabled If true, loads the shared config 24 | # file and enables new config values outside of the old shared credential 25 | # spec. 26 | def initialize(options = {}) 27 | @profile_name = determine_profile(options) 28 | @config_enabled = options[:config_enabled] 29 | @credentials_path = options[:credentials_path] || 30 | determine_credentials_path 31 | @parsed_credentials = {} 32 | load_credentials_file if loadable?(@credentials_path) 33 | return unless @config_enabled 34 | @config_path = options[:config_path] || determine_config_path 35 | load_config_file if loadable?(@config_path) 36 | end 37 | 38 | # @api private 39 | def fresh(options = {}) 40 | @configuration = nil 41 | @semaphore = nil 42 | @assume_role_shared_config = nil 43 | @profile_name = nil 44 | @credentials_path = nil 45 | @config_path = nil 46 | @parsed_credentials = {} 47 | @parsed_config = nil 48 | @config_enabled = options[:config_enabled] ? true : false 49 | @profile_name = determine_profile(options) 50 | @credentials_path = options[:credentials_path] || 51 | determine_credentials_path 52 | load_credentials_file if loadable?(@credentials_path) 53 | return unless @config_enabled 54 | @config_path = options[:config_path] || determine_config_path 55 | load_config_file if loadable?(@config_path) 56 | end 57 | 58 | def credentials(opts = {}) 59 | logger.debug "SharedConfigWithKeyring asked for credentials with opts #{opts}" 60 | p = opts[:profile] || @profile_name 61 | validate_profile_exists(p) if credentials_present? 62 | credentials_from_keyring(p, opts) || credentials_from_shared(p, opts) || credentials_from_config(p, opts) 63 | end 64 | 65 | def save_profile(profile_name, hash) 66 | ckey = "profile #{profile_name}" 67 | merged_config = configuration[ckey].deep_symbolize_keys.merge hash.to_h 68 | merged_config[:mfa_serial] = merged_config[:serial_number] if merged_config[:serial_number] 69 | credentials = Aws::Credentials.new(merged_config.delete(:aws_access_key_id), 70 | merged_config.delete(:aws_secret_access_key)) 71 | semaphore.synchronize do 72 | Keyring.save_credentials profile_name, credentials if credentials.set? 73 | merged_config = merged_config.slice :region, :role_arn, :mfa_serial, :source_profile, 74 | :role_session_name, :external_id, :duration_seconds, 75 | :yubikey_oath_name 76 | configuration.delete_section ckey 77 | configuration[ckey] = merged_config.compact 78 | save_configuration 79 | end 80 | end 81 | 82 | def profiles 83 | configuration.sections.map { |c| c.gsub("profile ", "") } 84 | end 85 | 86 | def delete_profile(profile_name) 87 | # Keyring does not return errors for non-existent things, so always attempt. 88 | Keyring.delete_credentials(profile_name) 89 | semaphore.synchronize do 90 | raise KeyError if configuration["profile #{profile_name}"].blank? 91 | configuration.delete_section("profile #{profile_name}") 92 | save_configuration 93 | end 94 | end 95 | 96 | def migrate_profile(profile_name) 97 | validate_profile_exists(profile_name) 98 | save_profile(profile_name, configuration["profile #{profile_name}"]) 99 | end 100 | 101 | def profile_region(profile_name) 102 | resolve_profile_parameter(profile_name, "region") 103 | end 104 | 105 | def profile_role(profile_name) 106 | resolve_profile_parameter(profile_name, "role_arn") 107 | end 108 | 109 | def profile_hash(profile_name) 110 | {} || @parsed_config[profile_key(profile_name)] 111 | end 112 | 113 | private 114 | 115 | def profile_key(profile) 116 | logger.debug "About to lookup #{profile}" 117 | if profile == "default" || profile.nil? || profile == "" 118 | "default" 119 | else 120 | profile 121 | end 122 | end 123 | 124 | def resolve_profile_parameter(profile_name, param) 125 | return unless @parsed_config 126 | prof_cfg = @parsed_config[profile_key(profile_name)] 127 | resolve_parameter(param, @parsed_config, prof_cfg) 128 | end 129 | 130 | def resolve_parameter(param, cfg, prof_cfg) 131 | return unless prof_cfg && cfg 132 | return prof_cfg[param] if prof_cfg.key? param 133 | source_profile = prof_cfg["source_profile"] 134 | return unless source_profile 135 | source_cfg = cfg[source_profile] 136 | return unless source_cfg 137 | cfg[prof_cfg["source_profile"]][param] if source_cfg.key?(param) 138 | end 139 | 140 | def resolve_region(cfg, prof_cfg) 141 | resolve_parameter("region", cfg, prof_cfg) 142 | end 143 | 144 | def resolve_arn(cfg, prof_cfg) 145 | resolve_parameter("role_arn", cfg, prof_cfg) 146 | end 147 | 148 | def assume_role_from_profile(cfg, profile, opts) 149 | logger.debug "Entering assume_role_from_profile with #{cfg}, #{profile}, #{opts}" 150 | prof_cfg = cfg[profile] 151 | return unless cfg && prof_cfg 152 | opts[:source_profile] ||= prof_cfg["source_profile"] 153 | if opts[:source_profile] 154 | opts[:credentials] = credentials(profile: opts[:source_profile]) 155 | if opts[:credentials] 156 | opts[:role_session_name] ||= prof_cfg["role_session_name"] 157 | opts[:role_session_name] ||= "default_session" 158 | opts[:role_arn] ||= prof_cfg["role_arn"] 159 | opts[:external_id] ||= prof_cfg["external_id"] 160 | opts[:serial_number] ||= prof_cfg["mfa_serial"] 161 | opts[:yubikey_oath_name] ||= prof_cfg["yubikey_oath_name"] 162 | opts[:region] ||= profile_region(profile) 163 | if opts[:serial_number] 164 | mfa_opts = { 165 | credentials: opts[:credentials], 166 | region: opts[:region], 167 | serial_number: opts[:serial_number], 168 | yubikey_oath_name: opts[:yubikey_oath_name], 169 | } 170 | mfa_creds = mfa_session(cfg, opts[:source_profile], mfa_opts) 171 | opts.delete :serial_number 172 | end 173 | opts[:credentials] = mfa_creds if mfa_creds 174 | opts[:profile] = opts.delete(:source_profile) 175 | AwsAssumeRole::Credentials::Providers::AssumeRoleCredentials.new(opts) 176 | else 177 | raise ::Aws::Errors::NoSourceProfileError, "Profile #{profile} has a role_arn, and source_profile, but the"\ 178 | " source_profile does not have credentials." 179 | end 180 | elsif prof_cfg["role_arn"] 181 | raise ::Aws::Errors::NoSourceProfileError, "Profile #{profile} has a role_arn, but no source_profile." 182 | end 183 | end 184 | 185 | def mfa_session(cfg, profile, opts) 186 | prof_cfg = cfg[profile] 187 | return unless cfg && prof_cfg 188 | opts[:serial_number] ||= opts[:mfa_serial] || prof_cfg["mfa_serial"] 189 | opts[:source_profile] ||= prof_cfg["source_profile"] 190 | opts[:region] ||= profile_region(profile) 191 | return unless opts[:serial_number] 192 | opts[:credentials] ||= credentials(profile: opts[:profile]) 193 | AwsAssumeRole::Credentials::Providers::MfaSessionCredentials.new(opts) 194 | end 195 | 196 | def credentials_from_keyring(profile, opts) 197 | logger.debug "Entering credentials_from_keyring" 198 | return unless @parsed_config 199 | logger.debug "credentials_from_keyring: @parsed_config found" 200 | prof_cfg = @parsed_config[profile] 201 | return unless prof_cfg 202 | logger.debug "credentials_from_keyring: prof_cfg found" 203 | opts[:serial_number] ||= opts[:mfa_serial] || prof_cfg[:mfa_serial] || prof_cfg[:serial_number] 204 | if opts[:serial_number] 205 | logger.debug "credentials_from_keyring detected mfa requirement" 206 | mfa_session(@parsed_config, profile, opts) 207 | else 208 | logger.debug "Attempt to fetch #{profile} from keyring" 209 | keyring_creds = Keyring.fetch(profile) 210 | return unless keyring_creds 211 | creds = Serialization.credentials_from_hash Keyring.fetch(profile) 212 | creds if credentials_complete(creds) 213 | end 214 | rescue Aws::Errors::NoSourceProfileError, Aws::Errors::NoSuchProfileError 215 | nil 216 | end 217 | 218 | def semaphore 219 | @semaphore ||= Mutex.new 220 | end 221 | 222 | def configuration 223 | @configuration ||= IniFile.new(filename: determine_config_path, default: "default") 224 | end 225 | 226 | # Please run in a mutex 227 | def save_configuration 228 | if File.exist? determine_config_path 229 | bytes_required = File.size(determine_config_path) 230 | # Overwrite the current .config file with random bytes to eliminate 231 | # unencrypted credentials. 232 | # This won't account for COW filesystems or SSD wear-levelling but 233 | # is a best effort protection. 234 | random_bytes = SecureRandom.random_bytes(bytes_required) 235 | File.write(determine_config_path, random_bytes) 236 | else 237 | FileUtils.mkdir_p(Pathname.new(determine_config_path).dirname) 238 | end 239 | configuration.save 240 | end 241 | end 242 | 243 | module AwsAssumeRole 244 | module_function 245 | 246 | def shared_config 247 | enabled = ENV["AWS_SDK_CONFIG_OPT_OUT"] ? false : true 248 | @assume_role_shared_config ||= ::AwsAssumeRole::Store::SharedConfigWithKeyring.new(config_enabled: enabled) 249 | end 250 | end 251 | -------------------------------------------------------------------------------- /lib/aws_assume_role/types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | module AwsAssumeRole 5 | module Types 6 | Dry = Dry::Types.module 7 | 8 | ::Dry::Types.register("aws.credentials", ::Aws::Credentials) 9 | AwsAssumeRole::Types::Credentials = ::Dry::Types["aws.credentials"] 10 | 11 | ACCESS_KEY_REGEX = /[\w]+/ 12 | ACCESS_KEY_VALIDATOR = proc { filled? & str? & format?(ACCESS_KEY_REGEX) & min_size?(16) & max_size?(32) } 13 | ARN_REGEX = %r{arn:[\w+=\/,.@-]+:[\w+=\/,.@-]+:[\w+=\/,.@-]*:[0-9]+:[\w+=,.@-]+(\/[\w+=\/,.@-]+)*} 14 | EXTERNAL_ID_REGEX = %r{[\w+=,.@:\/-]*} 15 | MFA_REGEX = %r{arn:aws:iam::[0-9]+:mfa\/([\w+=,.@-]+)*|automatic} 16 | REGION_REGEX = /^(us|eu|ap|sa|ca)\-\w+\-\d+$|^cn\-\w+\-\d+$|^us\-gov\-\w+\-\d+$/ 17 | REGION_VALIDATOR = proc { filled? & str? & format?(REGION_REGEX) } 18 | ROLE_REGEX = %r{arn:aws:iam::[0-9]+:role\/([\w+=,.@-]+)*} 19 | ROLE_SESSION_NAME_REGEX = /[\w+=,.@-]*/ 20 | SECRET_ACCESS_KEY_REGEX = // 21 | SECRET_ACCESS_KEY_VALIDATOR = proc { filled? & str? & format?(SECRET_ACCESS_KEY_REGEX) } 22 | 23 | AwsAssumeRole::Types::Region = Dry::Strict::String.constrained( 24 | format: REGION_REGEX, 25 | ) 26 | 27 | AwsAssumeRole::Types::MfaSerial = Dry::Strict::String.constrained( 28 | format: MFA_REGEX, 29 | ) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/aws_assume_role/ui.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "includes" 4 | 5 | module AwsAssumeRole::Ui 6 | include AwsAssumeRole 7 | 8 | ::I18n.load_path += Dir.glob(File.join(File.realpath(__dir__), "..", "..", "i18n", "*.{rb,yml,yaml}")) 9 | ::I18n.locale = ENV.fetch("LANG", nil)&.split(".")&.first&.split("_")&.first || I18n.default_locale 10 | 11 | module_function 12 | 13 | def out(text) 14 | puts text 15 | end 16 | 17 | def pastel 18 | @pastel ||= Pastel.new 19 | end 20 | 21 | def input 22 | @input ||= HighLine.new($stdin, $stderr) 23 | end 24 | 25 | def validation_errors_to_s(result) 26 | text = result.errors.keys.map do |k| 27 | result.errors[k].join(";") 28 | end.join(" ") 29 | text 30 | end 31 | 32 | def error(text) 33 | puts pastel.red(text) 34 | end 35 | 36 | def show_validation_errors(result) 37 | error validation_errors_to_s(result) 38 | end 39 | 40 | def ask_with_validation(variable_name, question, type: Dry::Types["coercible.string"], &block) 41 | STDOUT.puts pastel.yellow question 42 | validator = Dry::Validation.Schema do 43 | configure do 44 | config.messages = :i18n 45 | end 46 | required(variable_name) { instance_eval(&block) } 47 | end 48 | result = validator.call(variable_name => type[(STDIN.gets || "").chomp]) 49 | return result.to_h[variable_name] if result.success? 50 | show_validation_errors result 51 | ask_with_validation variable_name, question, &block 52 | end 53 | 54 | def t(*options) 55 | ::I18n.t(options).first 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/aws_assume_role/vendored/aws.rb: -------------------------------------------------------------------------------- 1 | require_relative "aws/assume_role_credentials" 2 | require_relative "aws/refreshing_credentials" 3 | require_relative "../store/shared_config_with_keyring" 4 | require_relative "aws/shared_config" 5 | -------------------------------------------------------------------------------- /lib/aws_assume_role/vendored/aws/README.md: -------------------------------------------------------------------------------- 1 | These are copies of the private API from the AWS SDK v2, as partial protection to changes, for which the source is available at 2 | https://github.com/aws/aws-sdk-ruby 3 | -------------------------------------------------------------------------------- /lib/aws_assume_role/vendored/aws/assume_role_credentials.rb: -------------------------------------------------------------------------------- 1 | require_relative "includes" 2 | 3 | module AwsAssumeRole::Vendored::Aws 4 | # An auto-refreshing credential provider that works by assuming 5 | # a role via {Aws::STS::Client#assume_role}. 6 | # 7 | # role_credentials = Aws::AssumeRoleCredentials.new( 8 | # client: Aws::STS::Client.new(...), 9 | # role_arn: "linked::account::arn", 10 | # role_session_name: "session-name" 11 | # ) 12 | # 13 | # ec2 = Aws::EC2::Client.new(credentials: role_credentials) 14 | # 15 | # If you omit `:client` option, a new {STS::Client} object will be 16 | # constructed. 17 | class AssumeRoleCredentials 18 | include ::Aws::CredentialProvider 19 | include ::Aws::RefreshingCredentials 20 | 21 | # @option options [required, String] :role_arn 22 | # @option options [required, String] :role_session_name 23 | # @option options [String] :policy 24 | # @option options [Integer] :duration_seconds 25 | # @option options [String] :external_id 26 | # @option options [STS::Client] :client 27 | def initialize(options = {}, **) 28 | 29 | client_opts = {} 30 | @assume_role_params = {} 31 | options.each_pair do |key, value| 32 | if self.class.assume_role_options.include?(key) 33 | @assume_role_params[key] = value 34 | else 35 | client_opts[key] = value 36 | end 37 | end 38 | @client = client_opts[:client] || ::Aws::STS::Client.new(client_opts) 39 | super 40 | end 41 | 42 | # @return [STS::Client] 43 | attr_reader :client 44 | 45 | private 46 | 47 | def refresh 48 | c = @client.assume_role(@assume_role_params).credentials 49 | @credentials = ::Aws::Credentials.new( 50 | c.access_key_id, 51 | c.secret_access_key, 52 | c.session_token, 53 | ) 54 | @expiration = c.expiration 55 | end 56 | 57 | class << self 58 | # @api private 59 | def assume_role_options 60 | @aro ||= begin 61 | input = ::Aws::STS::Client.api.operation(:assume_role).input 62 | Set.new(input.shape.member_names) 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/aws_assume_role/vendored/aws/includes.rb: -------------------------------------------------------------------------------- 1 | require_relative "../../includes" 2 | 3 | module AwsAssumeRole 4 | module Vendored 5 | module Aws 6 | include ::Aws 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/aws_assume_role/vendored/aws/refreshing_credentials.rb: -------------------------------------------------------------------------------- 1 | module AwsAssumeRole::Vendored::Aws 2 | # Base class used credential classes that can be refreshed. This 3 | # provides basic refresh logic in a thread-safe manor. Classes mixing in 4 | # this module are expected to implement a #refresh method that populates 5 | # the following instance variables: 6 | # 7 | # * `@access_key_id` 8 | # * `@secret_access_key` 9 | # * `@session_token` 10 | # * `@expiration` 11 | # 12 | # @api private 13 | module RefreshingCredentials 14 | def initialize(_options = {}) 15 | @mutex = Mutex.new 16 | refresh 17 | end 18 | 19 | # @return [Credentials] 20 | def credentials 21 | refresh_if_near_expiration 22 | @credentials 23 | end 24 | 25 | # @return [Time,nil] 26 | def expiration 27 | refresh_if_near_expiration 28 | @expiration 29 | end 30 | 31 | # Refresh credentials. 32 | # @return [void] 33 | def refresh! 34 | @mutex.synchronize { refresh } 35 | end 36 | 37 | private 38 | 39 | # Refreshes instance metadata credentials if they are within 40 | # 5 minutes of expiration. 41 | def refresh_if_near_expiration 42 | if near_expiration? 43 | @mutex.synchronize do 44 | refresh if near_expiration? 45 | end 46 | end 47 | end 48 | 49 | def near_expiration? 50 | if @expiration 51 | # are we within 5 minutes of expiration? 52 | (Time.now.to_i + 5 * 60) > @expiration.to_i 53 | else 54 | true 55 | end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/aws_assume_role/vendored/aws/shared_config.rb: -------------------------------------------------------------------------------- 1 | require_relative "includes" 2 | module AwsAssumeRole::Vendored::Aws 3 | # @api private 4 | class SharedConfig 5 | include AwsAssumeRole::Logging 6 | # @return [String] 7 | attr_reader :credentials_path 8 | 9 | # @return [String] 10 | attr_reader :config_path 11 | 12 | # @return [String] 13 | attr_reader :profile_name 14 | 15 | # Constructs a new SharedConfig provider object. This will load the shared 16 | # credentials file, and optionally the shared configuration file, as ini 17 | # files which support profiles. 18 | # 19 | # By default, the shared credential file (the default path for which is 20 | # `~/.aws/credentials`) and the shared config file (the default path for 21 | # which is `~/.aws/config`) are loaded. However, if you set the 22 | # `ENV['AWS_SDK_CONFIG_OPT_OUT']` environment variable, only the shared 23 | # credential file will be loaded. 24 | # 25 | # The default profile name is 'default'. You can specify the profile name 26 | # with the `ENV['AWS_PROFILE']` environment variable or with the 27 | # `:profile_name` option. 28 | # 29 | # @param [Hash] options 30 | # @option options [String] :credentials_path Path to the shared credentials 31 | # file. Defaults to "#{Dir.home}/.aws/credentials". 32 | # @option options [String] :config_path Path to the shared config file. 33 | # Defaults to "#{Dir.home}/.aws/config". 34 | # @option options [String] :profile_name The credential/config profile name 35 | # to use. If not specified, will check `ENV['AWS_PROFILE']` before using 36 | # the fixed default value of 'default'. 37 | # @option options [Boolean] :config_enabled If true, loads the shared config 38 | # file and enables new config values outside of the old shared credential 39 | # spec. 40 | def initialize(options = {}) 41 | @profile_name = determine_profile(options) 42 | @config_enabled = options[:config_enabled] 43 | @credentials_path = options[:credentials_path] || 44 | determine_credentials_path 45 | @parsed_credentials = {} 46 | load_credentials_file if loadable?(@credentials_path) 47 | if @config_enabled 48 | @config_path = options[:config_path] || determine_config_path 49 | load_config_file if loadable?(@config_path) 50 | end 51 | end 52 | 53 | # @api private 54 | def fresh(options = {}) 55 | @profile_name = nil 56 | @credentials_path = nil 57 | @config_path = nil 58 | @parsed_credentials = {} 59 | @parsed_config = nil 60 | @config_enabled = options[:config_enabled] ? true : false 61 | @profile_name = determine_profile(options) 62 | @credentials_path = options[:credentials_path] || 63 | determine_credentials_path 64 | load_credentials_file if loadable?(@credentials_path) 65 | if @config_enabled 66 | @config_path = options[:config_path] || determine_config_path 67 | load_config_file if loadable?(@config_path) 68 | end 69 | end 70 | 71 | # @return [Boolean] Returns `true` if a credential file 72 | # exists and has appropriate read permissions at {#path}. 73 | # @note This method does not indicate if the file found at {#path} 74 | # will be parsable, only if it can be read. 75 | def loadable?(path) 76 | !path.nil? && File.exist?(path) && File.readable?(path) 77 | end 78 | 79 | # @return [Boolean] returns `true` if use of the shared config file is 80 | # enabled. 81 | def config_enabled? 82 | @config_enabled ? true : false 83 | end 84 | 85 | # Sources static credentials from shared credential/config files. 86 | # 87 | # @param [Hash] options 88 | # @option options [String] :profile the name of the configuration file from 89 | # which credentials are being sourced. 90 | # @return [Aws::Credentials] credentials sourced from configuration values, 91 | # or `nil` if no valid credentials were found. 92 | def credentials(opts = {}) 93 | p = opts[:profile] || @profile_name 94 | validate_profile_exists(p) if credentials_present? 95 | if credentials = credentials_from_shared(p, opts) 96 | credentials 97 | elsif credentials = credentials_from_config(p, opts) 98 | credentials 99 | end 100 | end 101 | 102 | # Attempts to assume a role from shared config or shared credentials file. 103 | # Will always attempt first to assume a role from the shared credentials 104 | # file, if present. 105 | def assume_role_credentials_from_config(opts = {}) 106 | logger.debug "Entered assume_role_credentials_from_config with #{opts}" 107 | p = opts.delete(:profile) || @profile_name 108 | credentials = assume_role_from_profile(@parsed_credentials, p, opts) 109 | if @parsed_config 110 | logger.debug "Parsed config loaded, testing" 111 | credentials ||= assume_role_from_profile(@parsed_config, p, opts) 112 | end 113 | credentials 114 | end 115 | 116 | def region(opts = {}) 117 | p = opts[:profile] || @profile_name 118 | if @config_enabled 119 | if @parsed_credentials 120 | region = @parsed_credentials.fetch(p, {})["region"] 121 | end 122 | region ||= @parsed_config.fetch(p, {})["region"] if @parsed_config 123 | region 124 | end 125 | end 126 | 127 | private 128 | 129 | def credentials_present? 130 | (@parsed_credentials && !@parsed_credentials.empty?) || 131 | (@parsed_config && !@parsed_config.empty?) 132 | end 133 | 134 | def assume_role_from_profile(cfg, profile, opts) 135 | if cfg && prof_cfg = cfg[profile] 136 | opts[:source_profile] ||= prof_cfg["source_profile"] 137 | if opts[:source_profile] 138 | opts[:credentials] = credentials(profile: opts[:source_profile]) 139 | if opts[:credentials] 140 | opts[:role_session_name] ||= prof_cfg["role_session_name"] 141 | opts[:role_session_name] ||= "default_session" 142 | opts[:role_arn] ||= prof_cfg["role_arn"] 143 | opts[:external_id] ||= prof_cfg["external_id"] 144 | opts[:serial_number] ||= prof_cfg["mfa_serial"] 145 | opts[:profile] = opts.delete(:source_profile) 146 | AssumeRoleCredentials.new(opts) 147 | else 148 | raise ::Aws::Errors::NoSourceProfileError, "Profile #{profile} has a role_arn, and source_profile, but the"\ 149 | " source_profile does not have credentials." 150 | end 151 | elsif prof_cfg["role_arn"] 152 | raise ::Aws::Errors::NoSourceProfileError, "Profile #{profile} has a role_arn, but no source_profile." 153 | end 154 | end 155 | end 156 | 157 | def credentials_from_shared(profile, _opts) 158 | if @parsed_credentials && prof_config = @parsed_credentials[profile] 159 | credentials_from_profile(prof_config) 160 | end 161 | end 162 | 163 | def credentials_from_config(profile, _opts) 164 | if @parsed_config && prof_config = @parsed_config[profile] 165 | credentials_from_profile(prof_config) 166 | end 167 | end 168 | 169 | def credentials_from_profile(prof_config) 170 | creds = Aws::Credentials.new( 171 | prof_config["aws_access_key_id"], 172 | prof_config["aws_secret_access_key"], 173 | prof_config["aws_session_token"], 174 | ) 175 | creds if credentials_complete(creds) 176 | end 177 | 178 | def credentials_complete(creds) 179 | creds.set? 180 | end 181 | 182 | def load_credentials_file 183 | @parsed_credentials = Aws::IniParser.ini_parse( 184 | File.read(@credentials_path), 185 | ) 186 | end 187 | 188 | def load_config_file 189 | @parsed_config = Aws::IniParser.ini_parse(File.read(@config_path)) 190 | end 191 | 192 | def determine_credentials_path 193 | default = default_shared_config_path("credentials") 194 | end 195 | 196 | def determine_config_path 197 | default = default_shared_config_path("config") 198 | end 199 | 200 | def default_shared_config_path(file) 201 | File.join(Dir.home, ".aws", file) 202 | rescue ArgumentError 203 | # Dir.home raises ArgumentError when ENV['home'] is not set 204 | nil 205 | end 206 | 207 | def validate_profile_exists(profile) 208 | unless (@parsed_credentials && @parsed_credentials[profile]) || 209 | (@parsed_config && @parsed_config[profile]) 210 | msg = "Profile `#{profile}' not found in #{@credentials_path}" 211 | msg << " or #{@config_path}" if @config_path 212 | raise ::Aws::Errors::NoSuchProfileError, msg 213 | end 214 | end 215 | 216 | def determine_profile(options) 217 | ret = options[:profile_name] 218 | ret ||= ENV["AWS_PROFILE"] 219 | ret ||= "default" 220 | ret 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /lib/aws_assume_role/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module AwsAssumeRole 4 | VERSION = "1.2.3".freeze 5 | end 6 | -------------------------------------------------------------------------------- /spec/aws_assume_role/credentials/factories/README.md: -------------------------------------------------------------------------------- 1 | These tests based on https://github.com/aws/aws-sdk-ruby/blob/master/aws-sdk-core/spec/aws/credential_provider_chain_spec.rb 2 | 3 | and 4 | 5 | https://github.com/aws/aws-sdk-ruby/blob/master/aws-sdk-core/spec/aws/credential_resolution_chain_spec.rb 6 | -------------------------------------------------------------------------------- /spec/aws_assume_role/credentials/factories/credential_resolution_chain_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | # rubocop:disable Metrics/ModuleLength 6 | module AwsAssumeRole 7 | describe "Credential Resolution Chain" do 8 | let(:mock_credential_file) do 9 | File.expand_path(File.join(File.dirname(__FILE__), 10 | "..", "..", "..", "fixtures", "credentials", "mock_shared_credentials")) 11 | end 12 | 13 | let(:mock_config_file) do 14 | File.expand_path(File.join(File.dirname(__FILE__), 15 | "..", "..", "..", "fixtures", "credentials", "mock_shared_config")) 16 | end 17 | 18 | describe "default behavior" do 19 | before(:each) do 20 | stub_const("ENV", {}) 21 | # AWS_SDK_CONFIG_OPT_OUT not present 22 | Aws.shared_config.fresh( 23 | config_enabled: true, 24 | credentials_path: mock_credential_file, 25 | config_path: mock_config_file, 26 | ) 27 | AwsAssumeRole.shared_config.fresh( 28 | config_enabled: true, 29 | credentials_path: mock_credential_file, 30 | config_path: mock_config_file, 31 | ) 32 | end 33 | 34 | it "prefers direct credentials above other sources" do 35 | stub_const("ENV", "AWS_ACCESS_KEY_ID" => "AKID_ENV_STUB", 36 | "AWS_SECRET_ACCESS_KEY" => "SECRET_ENV_STUB") 37 | client = Aws::S3::Client.new( 38 | access_key_id: "ACCESS_DIRECT", 39 | secret_access_key: "SECRET_DIRECT", 40 | profile: "fooprofile", 41 | region: "us-east-1", 42 | ) 43 | expect(client.config.credentials.access_key_id).to eq("ACCESS_DIRECT") 44 | end 45 | 46 | it "prefers ENV credentials over assume role and shared config" do 47 | stub_const("ENV", "AWS_ACCESS_KEY_ID" => "AKID_ENV_STUB", 48 | "AWS_SECRET_ACCESS_KEY" => "SECRET_ENV_STUB") 49 | client = Aws::S3::Client.new(profile: "assumerole_sc", region: "us-east-1") 50 | expect(client.config.credentials.access_key_id).to eq("AKID_ENV_STUB") 51 | end 52 | 53 | it "prefers assume role over shared config" do 54 | assume_role_stub( 55 | "arn:aws:iam:123456789012:role/bar", 56 | "ACCESS_KEY_1", # from 'fooprofile' 57 | "AR_AKID", 58 | "AR_SECRET", 59 | "AR_TOKEN", 60 | ) 61 | client = Aws::S3::Client.new(profile: "ar_plus_creds", region: "us-east-1") 62 | expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID") 63 | end 64 | 65 | it "prefers shared credential file static credentials over shared config" do 66 | client = Aws::S3::Client.new(profile: "credentials_first", region: "us-east-1") 67 | expect(client.config.credentials.credentials.access_key_id).to eq("ACCESS_KEY_CRD") 68 | end 69 | 70 | it "will source static credentials from shared config after shared credentials" do 71 | client = Aws::S3::Client.new(profile: "incomplete_cred", region: "us-east-1") 72 | expect(client.config.credentials.credentials.access_key_id).to eq("ACCESS_KEY_SC1") 73 | end 74 | 75 | it "attempts to fetch metadata credentials last" do 76 | stub_request( 77 | :get, 78 | "http://169.254.169.254/latest/meta-data/iam/security-credentials/", 79 | ).to_return(status: 200, body: "profile-name\n") 80 | stub_request( 81 | :put, 82 | "http://169.254.169.254/latest/api/token", 83 | ).with(headers: { 'Accept': "*/*", 84 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 85 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 86 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 87 | .to_return(status: 200, body: "", headers: {}) 88 | stub_request( 89 | :get, 90 | "http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name", 91 | ).to_return(status: 200, body: <<-JSON.strip) 92 | { 93 | "Code" : "Success", 94 | "LastUpdated" : "2013-11-22T20:03:48Z", 95 | "Type" : "AWS-HMAC", 96 | "AccessKeyId" : "akid-md", 97 | "SecretAccessKey" : "secret-md", 98 | "Token" : "session-token-md", 99 | "Expiration" : "#{(Time.now.utc + 3600).strftime('%Y-%m-%dT%H:%M:%SZ')}" 100 | } 101 | JSON 102 | client = Aws::S3::Client.new(profile: "nonexistant", region: "us-east-1") 103 | expect(client.config.credentials.credentials.access_key_id).to eq("akid-md") 104 | end 105 | 106 | describe "Assume Role Resolution" do 107 | it "will not assume a role without source_profile present" do 108 | expect do 109 | Aws::S3::Client.new(profile: "ar_no_src", region: "us-east-1") 110 | end.to raise_error(Aws::Errors::NoSourceProfileError) 111 | end 112 | 113 | it "will explicitly raise if source_profile is present but invalid" do 114 | expect do 115 | Aws::S3::Client.new(profile: "ar_bad_src", region: "us-east-1") 116 | end.to raise_error(Aws::Errors::NoSourceProfileError) 117 | end 118 | 119 | it "will assume a role from shared credentials before shared config" do 120 | assume_role_stub( 121 | "arn:aws:iam:123456789012:role/bar", 122 | "ACCESS_KEY_1", # from 'fooprofile' 123 | "AR_AKID", 124 | "AR_SECRET", 125 | "AR_TOKEN", 126 | ) 127 | client = Aws::S3::Client.new(profile: "assumerole_sc", region: "us-east-1") 128 | expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID") 129 | end 130 | 131 | it "will then try to assume a role from shared config" do 132 | assume_role_stub( 133 | "arn:aws:iam:123456789012:role/bar", 134 | "ACCESS_KEY_ARPC", # from 'ar_from_self' 135 | "AR_AKID", 136 | "AR_SECRET", 137 | "AR_TOKEN", 138 | ) 139 | client = Aws::S3::Client.new(profile: "ar_from_self", region: "us-east-1") 140 | expect(client.config.credentials.credentials.access_key_id).to eq("AR_AKID") 141 | end 142 | end 143 | end 144 | 145 | describe "AWS_SDK_CONFIG_OPT_OUT set" do 146 | before(:each) do 147 | stub_const("ENV", {}) 148 | stub_request( 149 | :put, 150 | "http://169.254.169.254/latest/api/token", 151 | ).with(headers: { 'Accept': "*/*", 152 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 153 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 154 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 155 | .to_return(status: 200, body: "", headers: {}) 156 | Aws.shared_config.fresh( 157 | config_enabled: false, 158 | credentials_path: mock_credential_file, 159 | # The config file exists but should not be used. 160 | config_path: mock_config_file, 161 | ) 162 | AwsAssumeRole.shared_config.fresh( 163 | config_enabled: false, 164 | credentials_path: mock_credential_file, 165 | # The config file exists but should not be used. 166 | config_path: mock_config_file, 167 | ) 168 | end 169 | 170 | it "prefers direct credentials above other sources" do 171 | stub_const("ENV", "AWS_ACCESS_KEY_ID" => "AKID_ENV_STUB", 172 | "AWS_SECRET_ACCESS_KEY" => "SECRET_ENV_STUB") 173 | client = Aws::S3::Client.new( 174 | access_key_id: "ACCESS_DIRECT", 175 | secret_access_key: "SECRET_DIRECT", 176 | profile: "fooprofile", 177 | region: "us-east-1", 178 | ) 179 | expect(client.config.credentials.access_key_id).to eq("ACCESS_DIRECT") 180 | end 181 | 182 | it "prefers ENV credentials over shared config" do 183 | stub_const("ENV", "AWS_ACCESS_KEY_ID" => "AKID_ENV_STUB", 184 | "AWS_SECRET_ACCESS_KEY" => "SECRET_ENV_STUB") 185 | client = Aws::S3::Client.new(profile: "fooprofile", region: "us-east-1") 186 | expect(client.config.credentials.access_key_id).to eq("AKID_ENV_STUB") 187 | end 188 | 189 | it "will not load credentials from shared config" do 190 | client = Aws::S3::Client.new(profile: "creds_from_cfg", region: "us-east-1") 191 | expect(client.config.credentials).to eq(nil) 192 | end 193 | 194 | it "will not attempt to assume a role" do 195 | client = Aws::S3::Client.new(profile: "assumerole_sc", region: "us-east-1") 196 | expect(client.config.credentials).to eq(nil) 197 | end 198 | 199 | it "attempts to fetch metadata credentials last" do 200 | stub_request( 201 | :get, 202 | "http://169.254.169.254/latest/meta-data/iam/security-credentials/", 203 | ).to_return(status: 200, body: "profile-name\n") 204 | stub_request( 205 | :get, 206 | "http://169.254.169.254/latest/meta-data/iam/security-credentials/profile-name", 207 | ).to_return(status: 200, body: <<-JSON.strip) 208 | { 209 | "Code" : "Success", 210 | "LastUpdated" : "2013-11-22T20:03:48Z", 211 | "Type" : "AWS-HMAC", 212 | "AccessKeyId" : "akid-md", 213 | "SecretAccessKey" : "secret-md", 214 | "Token" : "session-token-md", 215 | "Expiration" : "#{(Time.now.utc + 3600).strftime('%Y-%m-%dT%H:%M:%SZ')}" 216 | } 217 | JSON 218 | client = Aws::S3::Client.new(profile: "nonexistant", region: "us-east-1") 219 | expect(client.config.credentials.credentials.access_key_id).to eq("akid-md") 220 | end 221 | end 222 | 223 | def assume_role_stub(role_arn, input_access_key, access_key, secret_key, token) 224 | stub_request(:post, "https://sts.amazonaws.com/") 225 | .with(headers: { "authorization" => /Credential=#{input_access_key}/ }) 226 | .to_return(body: <<-RESP) 227 | 228 | 229 | 230 | #{role_arn} 231 | ASSUMEROLEID:session 232 | 233 | 234 | #{access_key} 235 | #{secret_key} 236 | #{token} 237 | #{(Time.now + 3600).utc.iso8601} 238 | 239 | 240 | 241 | MyStubRequest 242 | 243 | 244 | RESP 245 | end 246 | end 247 | end 248 | -------------------------------------------------------------------------------- /spec/aws_assume_role/credentials/factories/default_chain_provider_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Originally from https://github.com/aws/aws-sdk-ruby/blob/master/aws-sdk-core/spec/aws/credential_provider_chain_spec.rb 4 | require "spec_helper" 5 | 6 | # rubocop:disable Metrics/ModuleLength 7 | module AwsAssumeRole::Credentials::Factories 8 | describe DefaultChainProvider do 9 | let(:env) { {} } 10 | 11 | let(:config) do 12 | double("config", 13 | access_key_id: nil, 14 | secret_access_key: nil, 15 | session_token: nil, 16 | profile: nil, 17 | instance_profile_credentials_timeout: 1, 18 | instance_profile_credentials_retries: 0, 19 | resolve: %i[ 20 | access_key_id 21 | secret_access_key 22 | session_token 23 | profile 24 | instance_profile_credentials_timeout 25 | instance_profile_credentials_retries 26 | ]) 27 | end 28 | 29 | let(:chain) { DefaultChainProvider.new(config) } 30 | 31 | let(:credentials) { chain.resolve } 32 | 33 | before(:each) do 34 | stub_const("ENV", env) 35 | end 36 | 37 | it "defaults to nil when credentials not set" do 38 | stub_request( 39 | :put, 40 | "http://169.254.169.254/latest/api/token", 41 | ).with(headers: { 'Accept': "*/*", 42 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 43 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 44 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 45 | .to_return(status: 200, body: "", headers: {}) 46 | expect(credentials).to be(nil) 47 | end 48 | 49 | it "hydrates credentials from config options" do 50 | allow(config).to receive(:access_key_id).and_return("akid") 51 | allow(config).to receive(:secret_access_key).and_return("secret") 52 | allow(config).to receive(:session_token).and_return("session") 53 | expect(credentials.set?).to be(true) 54 | expect(credentials.access_key_id).to eq("akid") 55 | expect(credentials.secret_access_key).to eq("secret") 56 | expect(credentials.session_token).to eq("session") 57 | end 58 | 59 | it "hydrates credentials from ENV with prefix AWS_" do 60 | env["AWS_ACCESS_KEY_ID"] = "akid" 61 | env["AWS_SECRET_ACCESS_KEY"] = "secret" 62 | env["AWS_SESSION_TOKEN"] = "token" 63 | expect(credentials.set?).to be(true) 64 | expect(credentials.access_key_id).to eq("akid") 65 | expect(credentials.secret_access_key).to eq("secret") 66 | expect(credentials.session_token).to eq("token") 67 | end 68 | 69 | it "hydrates credentials from ENV with prefix AMAZON_" do 70 | env["AMAZON_ACCESS_KEY_ID"] = "akid2" 71 | env["AMAZON_SECRET_ACCESS_KEY"] = "secret2" 72 | env["AMAZON_SESSION_TOKEN"] = "token2" 73 | expect(credentials.set?).to be(true) 74 | expect(credentials.access_key_id).to eq("akid2") 75 | expect(credentials.secret_access_key).to eq("secret2") 76 | expect(credentials.session_token).to eq("token2") 77 | end 78 | 79 | it "hydrates credentials from ENV at AWS_ACCESS_KEY & AWS_SECRET_KEY" do 80 | env["AWS_ACCESS_KEY"] = "akid3" 81 | env["AWS_SECRET_KEY"] = "secret3" 82 | expect(credentials.set?).to be(true) 83 | expect(credentials.access_key_id).to eq("akid3") 84 | expect(credentials.secret_access_key).to eq("secret3") 85 | expect(credentials.session_token).to be(nil) 86 | end 87 | 88 | it "hydrates credentials from ENV at AWS_ACCESS_KEY_ID & AWS_SECRET_KEY" do 89 | env["AWS_ACCESS_KEY_ID"] = "akid4" 90 | env["AWS_SECRET_KEY"] = "secret4" 91 | expect(credentials.set?).to be(true) 92 | expect(credentials.access_key_id).to eq("akid4") 93 | expect(credentials.secret_access_key).to eq("secret4") 94 | expect(credentials.session_token).to be(nil) 95 | end 96 | 97 | it "hydrates credentials from the shared credentials file" do 98 | mock_path = File.join( 99 | File.dirname(__FILE__), "..", "..", "..", "fixtures", "credentials", 100 | "mock_shared_credentials" 101 | ) 102 | path = File.join("HOME", ".aws", "credentials") 103 | allow(Dir).to receive(:home).and_return("HOME") 104 | allow(File).to receive(:exist?).with(path).and_return(true) 105 | allow(File).to receive(:readable?).with(path).and_return(true) 106 | expect(File).to receive(:read).with(path).and_return(File.read(mock_path)) 107 | expect(credentials).to be_kind_of(SharedCredentials) 108 | expect(credentials.set?).to be(true) 109 | expect(credentials.credentials.access_key_id).to eq("ACCESS_KEY_0") 110 | expect(credentials.credentials.secret_access_key).to eq("SECRET_KEY_0") 111 | expect(credentials.credentials.session_token).to eq("TOKEN_0") 112 | end 113 | 114 | it "hydrates credentials from the instance profile service" do 115 | stub_request( 116 | :put, 117 | "http://169.254.169.254/latest/api/token", 118 | ).with(headers: { 'Accept': "*/*", 119 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 120 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 121 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 122 | .to_return(status: 200, body: "", headers: {}) 123 | path = "/latest/meta-data/iam/security-credentials/" 124 | resp = <<-JSON.strip 125 | { 126 | "Code" : "Success", 127 | "LastUpdated" : "2013-11-22T20:03:48Z", 128 | "Type" : "AWS-HMAC", 129 | "AccessKeyId" : "akid", 130 | "SecretAccessKey" : "secret", 131 | "Token" : "token", 132 | "Expiration" : "#{Time.now.strftime('%Y-%m-%dT%H:%M:%SZ')}" 133 | } 134 | JSON 135 | stub_request(:get, "http://169.254.169.254#{path}") 136 | .to_return(status: 200, body: "profile-name\n") 137 | stub_request(:get, "http://169.254.169.254#{path}profile-name") 138 | .to_return(status: 200, body: resp) 139 | expect(credentials).to be_kind_of(InstanceProfileCredentials) 140 | expect(credentials.set?).to be(true) 141 | expect(credentials.credentials.access_key_id).to eq("akid") 142 | expect(credentials.credentials.secret_access_key).to eq("secret") 143 | expect(credentials.credentials.session_token).to eq("token") 144 | end 145 | 146 | describe "with config set to nil" do 147 | let(:config) { nil } 148 | 149 | it "defaults to nil" do 150 | stub_request( 151 | :put, 152 | "http://169.254.169.254/latest/api/token", 153 | ).with(headers: { 'Accept': "*/*", 154 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 155 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 156 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 157 | .to_return(status: 200, body: "", headers: {}) 158 | expect(credentials).to be(nil) 159 | end 160 | end 161 | describe "with shared credentials" do 162 | let(:path) { File.join("HOME", ".aws", "credentials") } 163 | 164 | before(:each) do 165 | allow(File).to receive(:exist?).with(path).and_return(true) 166 | allow(File).to receive(:readable?).with(path).and_return(true) 167 | allow(Dir).to receive(:home).and_return("HOME") 168 | end 169 | 170 | it "returns no credentials when the shared file is empty" do 171 | stub_request( 172 | :put, 173 | "http://169.254.169.254/latest/api/token", 174 | ).with(headers: { 'Accept': "*/*", 175 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 176 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 177 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 178 | .to_return(status: 200, body: "", headers: {}) 179 | expect(File).to receive(:read).with(path).and_return("") 180 | expect(chain.resolve).to be(nil) 181 | end 182 | 183 | it "returns no credentials when the shared file profile is missing" do 184 | stub_request( 185 | :put, 186 | "http://169.254.169.254/latest/api/token", 187 | ).with(headers: { 'Accept': "*/*", 188 | 'Accept-Encoding': "gzip;q=1.0,deflate;q=0.6,identity;q=0.3", 189 | 'User-Agent': "aws-sdk-ruby2/2.11.458", 190 | 'X-Aws-Ec2-Metadata-Token-Ttl-Seconds': "21600" }) 191 | .to_return(status: 200, body: "", headers: {}) 192 | no_default = <<-CREDS.strip 193 | [fooprofile] 194 | aws_access_key_id = ACCESS_KEY_1 195 | aws_secret_access_key = SECRET_KEY_1 196 | aws_session_token = TOKEN_1 197 | CREDS 198 | expect(File).to receive(:read).with(path).and_return(no_default) 199 | expect(chain.resolve).to be(nil) 200 | end 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /spec/fixtures/credentials/README.md: -------------------------------------------------------------------------------- 1 | Originally from https://github.com/aws/aws-sdk-ruby/blob/master/aws-sdk-core/spec/fixtures/credentials/mock_shared_config 2 | -------------------------------------------------------------------------------- /spec/fixtures/credentials/mock_shared_config: -------------------------------------------------------------------------------- 1 | [profile default] 2 | s3 = 3 | max_concurrent_requests = 13 4 | max_queue_size = 1234 5 | region = us-east-1 6 | aws_access_key_id = ACCESS_KEY_SHARED 7 | aws_secret_access_key = SECRET_KEY_SHARED 8 | aws_session_token = TOKEN_SHARED 9 | 10 | [profile assumerole_prof] 11 | role_arn = arn:aws:iam:123456789012:role/foo 12 | source_profile = default 13 | 14 | [profile assumerole_mfa] 15 | role_arn = arn:aws:iam:123456789012:role/foo 16 | source_profile = default 17 | s3 = 18 | foo = bar 19 | mfa_serial = bar123456 20 | 21 | [profile ar_plus_creds] 22 | source_profile = fooprofile 23 | role_arn = arn:aws:iam:123456789012:role/bar 24 | aws_access_key_id = ACCESS_KEY_ARPC 25 | aws_secret_access_key = SECRET_KEY_ARPC 26 | aws_session_token = TOKEN_ARPC 27 | 28 | [profile credentials_first] 29 | aws_access_key_id = ACCESS_FAIL_CFG 30 | aws_secret_access_key = SECRET_FAIL_CFG 31 | aws_session_token = TOKEN_FAIL_CFG 32 | region = us-west-1 33 | 34 | [profile assumerole_sc] 35 | source_profile = default 36 | role_arn = arn:aws:iam:123456789012:role/foo 37 | 38 | [profile creds_from_cfg] 39 | aws_access_key_id = ACCESS_KEY_SC0 40 | aws_secret_access_key = SECRET_KEY_SC0 41 | aws_session_token = TOKEN_SC0 42 | 43 | [profile incomplete_cred] 44 | aws_access_key_id = ACCESS_KEY_SC1 45 | aws_secret_access_key = SECRET_KEY_SC1 46 | aws_session_token = TOKEN_SC1 47 | 48 | [profile incomplete_cfg] 49 | aws_secret_access_key = SECRET_KEY_INC_CFG 50 | 51 | [profile ar_no_src] 52 | role_arn = arn:aws:iam::123456789012:role/fail 53 | 54 | [profile ar_bad_src] 55 | role_arn = arn:aws:iam::123456789012:role/fail 56 | source_profile = bad_src 57 | 58 | [profile bad_src] 59 | region = us-east-1 60 | 61 | [profile ar_from_self] 62 | source_profile = ar_from_self 63 | role_arn = arn:aws:iam:123456789012:role/bar 64 | aws_access_key_id = ACCESS_KEY_ARPC 65 | aws_secret_access_key = SECRET_KEY_ARPC 66 | aws_session_token = TOKEN_ARPC 67 | -------------------------------------------------------------------------------- /spec/fixtures/credentials/mock_shared_credentials: -------------------------------------------------------------------------------- 1 | [default] 2 | aws_access_key_id = ACCESS_KEY_0 3 | aws_secret_access_key = SECRET_KEY_0 4 | aws_session_token = TOKEN_0 5 | 6 | [fooprofile] 7 | aws_access_key_id = ACCESS_KEY_1 8 | aws_secret_access_key = SECRET_KEY_1 9 | aws_session_token = TOKEN_1 10 | 11 | [barprofile] 12 | aws_access_key_id = ACCESS_KEY_2 13 | aws_secret_access_key = SECRET_KEY_2 14 | aws_session_token = TOKEN_2 15 | 16 | [credentials_first] 17 | aws_access_key_id = ACCESS_KEY_CRD 18 | aws_secret_access_key = SECRET_KEY_CRD 19 | aws_session_token = TOKEN_CRD 20 | region = us-west-2 21 | 22 | [assumerole_sc] 23 | source_profile = fooprofile 24 | role_arn = arn:aws:iam:123456789012:role/bar 25 | 26 | [incomplete_cred] 27 | aws_secret_access_key = SECRET_KEY_INC_CRED 28 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Originally from https://github.com/aws/aws-sdk-ruby/blob/master/aws-sdk-core/spec/spec_helper.rb 4 | 5 | require "simplecov" 6 | require "rspec" 7 | require "webmock/rspec" 8 | require_relative "../lib/aws_assume_role" 9 | require_relative "../lib/aws_assume_role/core_ext/aws-sdk/credential_provider_chain" 10 | 11 | SimpleCov.command_name("test:unit:aws_assume_role") 12 | 13 | # Prevent the SDK unit tests from loading actual credentials while under test. 14 | # By default the SDK attempts to load credentials from: 15 | # 16 | # * ENV, e.g. ENV['AWS_ACCESS_KEY_ID'] 17 | # * Shared credentials file, e.g. ~/.aws/credentials 18 | # * EC2 instance metadata server running at 169.254.169.254 19 | # 20 | RSpec.configure do |config| 21 | config.before(:each) do 22 | # Note that failure to do this could trigger ECS Credentials, which are 23 | # gated by an environment variable 24 | stub_const("ENV", {}) 25 | 26 | # disable loading credentials from shared file 27 | allow(Dir).to receive(:home).and_raise(ArgumentError) 28 | 29 | # disable instance profile credentials 30 | ec2_md_path = "/latest/meta-data/iam/security-credentials/" 31 | stub_request(:get, "http://169.254.169.254#{ec2_md_path}").to_raise(SocketError) 32 | 33 | Aws.shared_config.fresh 34 | AwsAssumeRole.shared_config.fresh 35 | end 36 | end 37 | 38 | RSpec::Matchers.define :match_example do |expected| 39 | match do |actual| 40 | actual.to_s.strip == expected.to_s.strip 41 | end 42 | failure_message do |actual| 43 | <<-MSG 44 | expected: 45 | 46 | #{expected.to_s.strip} 47 | 48 | got: 49 | 50 | #{actual.to_s.strip} 51 | MSG 52 | end 53 | end 54 | 55 | # Simply returns the request context without any http response info. 56 | class NoSendHandler < Seahorse::Client::Handler 57 | def call(context) 58 | Seahorse::Client::Response.new(context: context) 59 | end 60 | end 61 | 62 | # A helper :send_handler that does not send the request, it simply 63 | # returns an empty response. 64 | class DummySendHandler < Seahorse::Client::Handler 65 | def call(context) 66 | headers = context.config.response_headers 67 | headers = Seahorse::Client::Http::Headers.new(headers) 68 | context.http_response.headers = headers 69 | context.http_response.status_code = context.config.response_status_code 70 | context.http_response.body = StringIO.new(context.config.response_body) 71 | Seahorse::Client::Response.new(context: context) 72 | end 73 | end 74 | 75 | def call_handler(klass, opts = {}) 76 | operation_name = opts.delete(:operation_name) || "operation" 77 | params = opts.delete(:params) || {} 78 | 79 | config = opts.delete(:config) || Seahorse::Client::Configuration.new 80 | config.add_option(:response_status_code, 200) 81 | config.add_option(:response_headers, {}) 82 | config.add_option(:response_body, "") 83 | opts.each_key { |opt_name| config.add_option(opt_name) } 84 | 85 | context = Seahorse::Client::RequestContext.new( 86 | operation_name: operation_name, 87 | config: config.build!(opts), 88 | params: params, 89 | ) 90 | 91 | yield(context) if block_given? 92 | 93 | klass.new(DummySendHandler.new).call(context) 94 | end 95 | 96 | class DummySendPlugin < Seahorse::Client::Plugin 97 | class Handler < Seahorse::Client::Handler 98 | def call(context) 99 | Seahorse::Client::Response.new( 100 | context: context, 101 | data: context.config.response_data, 102 | ) 103 | end 104 | end 105 | option(:response_data) { { result: "success" } } 106 | handler Handler, step: :send 107 | end 108 | 109 | def client_class_with_plugin(&block) 110 | client = Seahorse::Client::Base.define 111 | client.set_plugins([Class.new(Seahorse::Client::Plugin, &block)]) 112 | client 113 | end 114 | 115 | def client_with_plugin(options = {}, &block) 116 | client_class_with_plugin(&block).new(options) 117 | end 118 | 119 | def data_to_hash(obj) 120 | case obj 121 | when Struct 122 | obj.members.each.with_object({}) do |member, hash| 123 | value = obj[member] 124 | hash[member] = data_to_hash(value) unless value.nil? 125 | end 126 | when Hash 127 | obj.each.with_object({}) do |(key, value), hash| 128 | hash[key] = data_to_hash(value) 129 | end 130 | when Array then obj.collect { |value| data_to_hash(value) } 131 | when IO, StringIO then obj.read 132 | else obj 133 | end 134 | end 135 | 136 | module ApiHelper 137 | class << self 138 | def sample_shapes 139 | { 140 | "StructureShape" => { 141 | "type" => "structure", 142 | "members" => { 143 | # complex members 144 | "Nested" => { "shape" => "StructureShape" }, 145 | "NestedList" => { "shape" => "StructureList" }, 146 | "NestedMap" => { "shape" => "StructureMap" }, 147 | "NumberList" => { "shape" => "IntegerList" }, 148 | "StringMap" => { "shape" => "StringMap" }, 149 | # scalar members 150 | "Blob" => { "shape" => "BlobShape" }, 151 | "Byte" => { "shape" => "ByteShape" }, 152 | "Boolean" => { "shape" => "BooleanShape" }, 153 | "Character" => { "shape" => "CharacterShape" }, 154 | "Double" => { "shape" => "DoubleShape" }, 155 | "Float" => { "shape" => "FloatShape" }, 156 | "Integer" => { "shape" => "IntegerShape" }, 157 | "Long" => { "shape" => "LongShape" }, 158 | "String" => { "shape" => "StringShape" }, 159 | "Timestamp" => { "shape" => "TimestampShape" }, 160 | }, 161 | }, 162 | "StructureList" => { 163 | "type" => "list", 164 | "member" => { "shape" => "StructureShape" }, 165 | }, 166 | "StructureMap" => { 167 | "type" => "map", 168 | "key" => { "shape" => "StringShape" }, 169 | "value" => { "shape" => "StructureShape" }, 170 | }, 171 | "IntegerList" => { 172 | "type" => "list", 173 | "member" => { "shape" => "IntegerShape" }, 174 | }, 175 | "StringMap" => { 176 | "type" => "map", 177 | "key" => { "shape" => "StringShape" }, 178 | "value" => { "shape" => "StringShape" }, 179 | }, 180 | "BlobShape" => { "type" => "blob" }, 181 | "ByteShape" => { "type" => "byte" }, 182 | "BooleanShape" => { "type" => "boolean" }, 183 | "CharacterShape" => { "type" => "character" }, 184 | "DoubleShape" => { "type" => "double" }, 185 | "FloatShape" => { "type" => "float" }, 186 | "IntegerShape" => { "type" => "integer" }, 187 | "LongShape" => { "type" => "long" }, 188 | "StringShape" => { "type" => "string" }, 189 | "TimestampShape" => { "type" => "timestamp" }, 190 | } 191 | end 192 | 193 | def sample_api(shapes = sample_shapes) 194 | api = { 195 | "operations" => { 196 | "ExampleOperation" => { 197 | "input" => { "shape" => "StructureShape" }, 198 | "output" => { "shape" => "StructureShape" }, 199 | }, 200 | }, 201 | "shapes" => shapes, 202 | } 203 | Aws::Api::Builder.build(api) 204 | end 205 | end 206 | end 207 | --------------------------------------------------------------------------------