diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7757772 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +*.cov +*.out +*.test +*.xml +.coverage.out +.tmp/ +/assume-role +/vendor diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5f878d8 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: go +go: +- 1.10.x +before_install: +- curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh +install: +- make vendor +cache: +- vendor +script: +- make lint +- go test -v ./... +env: + global: + - secure: MTgowTKGnUpre8ZL4oBcBtU0dRp3ntBET3u9XoLOby6PQ6mPn3GTDhb/drSVC0n70MBY/+i7zsUMYESHBZSFMP4KPYBOP5miTXZ8ujl65BaXW3Z5kyi+nCuxNQS1jaFYH3a46YbOrEtkc/q2PRvy8+oCx7sj1cpFDv11sTN5T6an0YtOJgYtu+QCK5IuNziGn35ZVhZS116KcOfvoN6LacwWrXGzDbbzPYrty7kjPRKWe0A9EiaUIvQdqcQ6Syfq99lfP3YFamXdqFRGE/ardwGfbjR+Lsv9pP3ACP3uPLDVEsGBO0doIvGT/00ZHfQaQQ24YKMF2an89RGJMmolmAbNkkSo0dUAJIhx5SUGQkS1ysMFPyVNOebN5B7xYce9xyFjuN8Ch8MK8rBbsXuNYDUkIt9u5Ir0B/uYExamHSb7ok2Y+h533ZEkFthWrCI31v7zK5o+6CJe4xMDE7A21GcQiBqbISOzde+6WOiLg89FS7pIYcdP6o/e6entAxU9CVx1VcH3fwQ5fmFPiL030BoVJUuecS9tjTX7eMFGbLgRGq/rtDysaZ5Z79ZSsQuFO9rAMa5cC52bOTlojzjSroLdQxfBy/16ICqFJY9z+s7v+Y5TBNgtpaiI6s/5wBN+vDJFKI4itAZr5iaqfMWbMLF4gYmKavbke/MoelocXSw= + - secure: oCZjZ96pHmIni3RtXAJo9IIFEzxCdafIRK9EbTvfG52AHQuE3DB2lSrP6qQDQDxS9SoOgjy3zA+vmaiF76cHnW/IEmvdDox5tnPef8qmcj4KO8AsFx29xeuKH9PZaOXozHncZUCi3vRLpsbgW6SpCCM4/JZF7ty+TK1KfH9Y0JGTpIMdSV7XYOFPPO8/hXV8TB5n1597atP6TmVGiB3Uvu13xXoEUDxKho/uGioByH6gIBlhCa8IEveqYsswJ/VVI76vgFIDU+sQx+uhJUZEk/lXYHscme17uEGDVDtOaTqxz/N0mnbb7t9d6jAxPle//JmhJAMe0wMvqA09RzF36P11lDvd9I0EczPpEaSUX4c4Xd5Jd9tNOIus6VsanRI8bIrfzXARu+TJC5vo2W6ubGz/byyEuFB3ZUkR4ECp3BI9gdTdJl+I1bGyEXcgXJfU6lu0slfTJ7tD5dXcbNuDT9kHHx6oY+VWcxG2upqSidGesVcVjw+YojZZQq2bTWZG8dRJ6hkic8oD4cmCMXQUl1fT6HKG1LHrTokQ3pd1uQyKl+Gy/MwSW5r8LBdn2MA2ScGclgZ6pFx89zCmPgRYoxovbGsGYEmLivFsJjM7lVIlBOuEmqSv8rLPB5WlArbeiVTSXCXq/Gndx77Yi5pvOZQ5ddN9arnoI96CLR/UMmU= + - secure: HcAjAqIRMpBplTmfX67cTYpSpnm7nQluH7GR7C5p4QR/OG1MtozH76m9UBB/GfncZf9I0LLlY5LpjDB7RWZfgF+0ncYqbS2TqKuTlNEsn4+gUoewugs8dsCDSWulxm3+w2HlTROUEixkCa41r3a3MQAo768RY98dl1qxPyS4PGQurA3X030nnPKrqP5W8PEFGGtrgzwWjT9eL7FyGrG7uqMuAWWBvbAIvn9Xj5tFJYzGhF/eIO213f4lPfpOqdlAetKikSPNSFwjeurMUYxkV5UcZEM78Oj6Izw2OK1AIExeUB/iRkhfJnsKrOe7ELYHsT6tm1FJe5Q+QWW6j/3uzqNzX++1C+m3147ZfThGJe4d+vyw98NrC/zq58uf96oohR/s2VV5+C6n8tlD1gmf9pEtk8k+aX2C6IB0Gm2hF5USkga1fnHwRdD2ejqRHF6xTf4Qg73W5Uz+jVstoBmzAEUMIfQwWfTOhwI+iGGqpnsZyiOhDA2kYLtgs6kWOJ7AVtGy3+UCQEh03A5Ke2D9QSEBYUkA2kjZdgoy5+8z//xlYmmbg3Sb15W7SAG0vXkud8l6FXRMWIn5Ktp81pb3RvlJ9Ky1GJwN05YxNwLxmxrA7HBjwpRLVei1Zl39q2iv6gh7BkmhKHxY4A1TJzXVcwilBuyJ1zPPp3jiTYaYi1k= + - secure: MFcyC+Sz6t9FqehxCqm5GEQ3xau8EY9rRiXEw8NNcHbfZxLyc22lMmUvHB1SslVYI8q/sGWVw86QvnkKoNc/suEGoa4Jv7A7DswMgS/Ia6cQa2+1PVewuYr8yxZWWdfwkIKYSQFOkvYPCNB9JPqxVGMQtIgJ1dpKgpT1V+jD3KwvFfR0eXbrdeBlthAYhXhipz9YWfMPuT0sTy+5KMN1KmJPQSFB6Iqi1m5nHvMJd6iuusDGXiNW3HiseLTBn6YqYTzEClTbGi+9zUBOkW5+7lZtzu/G68i15iCxGOd//mRNfLRSkYEXKjlWYonmh+83ULZONkInnJ0jtJ2e20/q4zzAKhrDtc2hGJ07LWyM8AKpRSwp2A9AXLd0vWmMUuC0vuUcqLANpQ2q609id3iQxKEHq5TUPb9ocH7TCAUQE9vGKt3v6eOB3mFUlFgDywYOg0YLw9gXCAVw27d2gnX59yT1BJitAzUWGQH0yKyitg8cL5feeaySUw5d67tt4VJ8VN02QAfrCrxdtvd4o0vFQI+W7OsaBFIVzGaJBUNpfepeA0QAZw2AcWuugr5BOfgEKuftgDP4AObWfNAPDwfnR1nTDDpLkKDIrr4YjJlqlE11144sb0B3Bc9fqez47q0mgCTlX1TFIriZU/abtyClJF/jCwhIAYKocUfQ4mLenVA= + - secure: i4lishHxWGHMli9thPD3FM6JVMKOXqZqfOpIIpR+sWO1qkDz8gUcFjH+408drnvmIDjtXcCmfA1KCRBY0e776y8mn2efDNpSX1UlkQRooJZVQ0gAtqyQH3wC9POala1miZrnqSk09n07huBMCuTtL9bgTFodrFQw7kXz6lA3pvB1ZtYr8U0zD7akOdanYMjlLhr5FaYh0y5PUct+7F2UuiSjYBFCYjXCklcRwbu8yHXfjm1e7SATzvopHpd4NTih5m3pJOTT7uSbze9xYwjy6a42Lz+QxPa3F20DjZdq2pG0l54d46lhRjBXhT/tre5Cz6hj6RMvmNuRQ8+xMnenlYYyZEMlOgPcltUcde7UqHsqj3FarzStn8l4J+6SBPPd5Ic6QiACuTFxAOM/St75uPSr134GRhkVhvIjsym9qk1iOhcBYwlw1Bhn8kRy/L0OpasrW/Yo3p7jLtqZ7IxKmZk6tPDM6VIKk0JpW57V6Dl49RI0gSym1H9BhVLPHgmavoNTwFOFV10r17FqQOW9tqR8qqrI9I1y+wNx4Xxu0Vxpu+5ZgKv+wzA9BBUaQnsW/b6akM+Kfc4vWl+lzHNZiPrW9lesEp11esz6xmccd6Euen2czp2kt0Hvwld8ijcV7ufUmB+FUCkZg8DqdYWkc4MpcYpQ10AebOmaAh7xLSE= diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d379c9a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 (October 5, 2018) + +* Initial public release diff --git a/Gopkg.lock b/Gopkg.lock new file mode 100644 index 0000000..72bd442 --- /dev/null +++ b/Gopkg.lock @@ -0,0 +1,139 @@ +# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. + + +[[projects]] + name = "github.com/aws/aws-sdk-go" + packages = [ + "aws", + "aws/arn", + "aws/awserr", + "aws/awsutil", + "aws/client", + "aws/client/metadata", + "aws/corehandlers", + "aws/credentials", + "aws/credentials/ec2rolecreds", + "aws/credentials/endpointcreds", + "aws/credentials/stscreds", + "aws/csm", + "aws/defaults", + "aws/ec2metadata", + "aws/endpoints", + "aws/request", + "aws/session", + "aws/signer/v4", + "internal/sdkio", + "internal/sdkrand", + "internal/shareddefaults", + "private/protocol", + "private/protocol/query", + "private/protocol/query/queryutil", + "private/protocol/rest", + "private/protocol/xml/xmlutil", + "service/iam", + "service/sts" + ] + revision = "47309c012812d9e9c488a54313e5cdfa7479df93" + version = "v1.14.1" + +[[projects]] + name = "github.com/davecgh/go-spew" + packages = ["spew"] + revision = "346938d642f2ec3594ed81d874461961cd0faa76" + version = "v1.1.0" + +[[projects]] + name = "github.com/ghodss/yaml" + packages = ["."] + revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" + version = "v1.0.0" + +[[projects]] + name = "github.com/go-ini/ini" + packages = ["."] + revision = "06f5f3d67269ccec1fe5fe4134ba6e982984f7f5" + version = "v1.37.0" + +[[projects]] + name = "github.com/golang/mock" + packages = ["gomock"] + revision = "c34cdb4725f4c3844d095133c6e40e448b86589b" + version = "v1.1.1" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/errwrap" + packages = ["."] + revision = "7554cd9344cec97297fa6649b055a8c98c2a1e55" + +[[projects]] + branch = "master" + name = "github.com/hashicorp/go-multierror" + packages = ["."] + revision = "b7773ae218740a7be65057fc60b366a49b538a44" + +[[projects]] + name = "github.com/hgfischer/go-otp" + packages = ["."] + revision = "ae459b7572ee6c0fe3394a750678aacffd138060" + version = "v1.0.0" + +[[projects]] + name = "github.com/jmespath/go-jmespath" + packages = ["."] + revision = "0b12d6b5" + +[[projects]] + branch = "master" + name = "github.com/mitchellh/go-homedir" + packages = ["."] + revision = "3864e76763d94a6df2f9960b16a20a33da9f9a66" + +[[projects]] + name = "github.com/pmezard/go-difflib" + packages = ["difflib"] + revision = "792786c7400a136282c1664665ae0a8db921c6c2" + version = "v1.0.0" + +[[projects]] + name = "github.com/stretchr/testify" + packages = [ + "assert", + "require" + ] + revision = "12b6f73e6084dad08a7c6e575284b177ecafbc71" + version = "v1.2.1" + +[[projects]] + branch = "master" + name = "golang.org/x/crypto" + packages = ["ssh/terminal"] + revision = "b47b1587369238182299fe4dad77d05b8b461e06" + +[[projects]] + branch = "master" + name = "golang.org/x/net" + packages = ["context"] + revision = "1e491301e022f8f977054da4c2d852decd59571f" + +[[projects]] + branch = "master" + name = "golang.org/x/sys" + packages = [ + "unix", + "windows" + ] + revision = "9527bec2660bd847c050fda93a0f0c6dee0800bb" + +[[projects]] + name = "gopkg.in/yaml.v2" + packages = ["."] + revision = "5420a8b6744d3b0345ab293f6fcba19c978f1183" + version = "v2.2.1" + +[solve-meta] + analyzer-name = "dep" + analyzer-version = 1 + inputs-digest = "0ea233102ec441eaf9beb6cbef779a78957f2b3674316e985bb1c0bf2e24ee29" + solver-name = "gps-cdcl" + solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml new file mode 100644 index 0000000..72882e9 --- /dev/null +++ b/Gopkg.toml @@ -0,0 +1,39 @@ +[[constraint]] + name = "github.com/aws/aws-sdk-go" + version = "1.14.1" + +[[constraint]] + name = "github.com/ghodss/yaml" + version = "1.0.0" + +[[constraint]] + name = "github.com/go-ini/ini" + version = "1.37.0" + +[[constraint]] + name = "github.com/golang/mock" + version = "1.1.1" + +[[constraint]] + branch = "master" + name = "github.com/hashicorp/go-multierror" + +[[constraint]] + name = "github.com/hgfischer/go-otp" + version = "1.0.0" + +[[constraint]] + branch = "master" + name = "github.com/mitchellh/go-homedir" + +[[constraint]] + name = "github.com/stretchr/testify" + version = "1.2.1" + +[[constraint]] + branch = "master" + name = "golang.org/x/crypto" + +[prune] + go-tests = true + unused-packages = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LICENSE-short b/LICENSE-short new file mode 100644 index 0000000..c06e2dc --- /dev/null +++ b/LICENSE-short @@ -0,0 +1,13 @@ + Copyright (c) 2018 Uber Technologies, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e71ee93 --- /dev/null +++ b/Makefile @@ -0,0 +1,62 @@ +define PRE_COMMIT_HOOK +#!/bin/sh -xe +make lint test +endef + +export PRE_COMMIT_HOOK + +install_hook := $(shell [ "$$INSTALL_HOOK" == "" ] && { export INSTALL_HOOK=1; \ + make .git/hooks/pre-commit; \ +}) + +source_files := $(shell find . -name '*.go') + +.PHONY: all +all: assume-role + +.git/hooks/pre-commit: Makefile + echo "$$PRE_COMMIT_HOOK" >.git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + +.PHONY: clean +clean: + rm -f ./assume-role + +.PHONY: clean-mocks +clean-mocks: + rm mocks/*.go + +assume-role: vendor $(source_files) + go build -o assume-role ./cli/assume-role/main.go + +.PHONY: +lint: lint-license + +.PHONY: +lint-license: + @f="$$(find . -name '*.go' ! -path './vendor/*' | xargs grep -L 'Licensed under the Apache License')"; \ + if [ ! -z "$$f" ]; then \ + echo "ERROR: Files missing license header:"$$'\n'"$$f" >&2; \ + exit 1; \ + fi + +.PHONY: mocks +mocks: + go get -u github.com/golang/mock/gomock + go get -u github.com/golang/mock/mockgen + mockgen -package=mocks github.com/uber/assume-role-cli AWSProvider,AWSConfigProvider | sed -e 's/assume_role/assumerole/g' > mocks/aws_mocks.go + + # Add license to mocks + set -eu; licence="$$(echo "/*"; while read -r line; do [ -z "$$line" ] && echo " *" || echo " * $$line"; done "$$tmp"; \ + mv "$$tmp" "$$f"; \ + done + +.PHONY: test +test: vendor + go test -short -coverprofile=.coverage.out ./... + +vendor: + dep ensure diff --git a/README.md b/README.md new file mode 100644 index 0000000..7731ed7 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# CLI for AssumeRole + +CLI for AssumeRole is a CLI tool for running programs with temporary AWS credentials. It is intended to be used by operators for running scripts and other tools that don't have native AssumeRole support. + +**Example** + +Run `myscript.py` using the "admin" role in your AWS account: + +``` +assume-role --role admin ./myscript.py +``` + +## Features + +* Caches credentials with configurable expiry time (e.g. 15 mins before credentials are due to expire) +* Interoperability with awscli +* Supports MFA and attempts to autodetect when MFA is required +* Configurable via autoloading config file + +## Getting started + +**Set up base policy** + +assume-role requires the user performing the AssumeRole call has the `iam:GetUser` permission, to identify the username and use that as the session name (so the user's name shows up in the CloudTrail UI). + +If MFA needs to be provided, assume-role also requires that the current user can list their own MFA devices. + +Create the following policy (e.g. named "allow-assume-role-script") and attach this to users or groups who will be performing the AssumeRole: + +``` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "iam:GetUser", + "iam:ListMFADevices" + ], + "Resource": "arn:aws:iam:::user/${aws:username}" + } + ] +} +``` + +(Replace `` with your AWS account ID.) + +**Create a configuration file** + +The `--role` option takes the full ARN of the role you want to assume (e.g. `arn:aws:iam::1234567890:role/admin`). To save humans on typing, you can specify the role prefix in configuration, so that you only need to use the named part of the role (i.e. `--role admin`). + +Create a YAML-based configuration file called `assume-role.yaml` in root of your project directory: + +``` +role_prefix: arn:aws:iam::1234567890:role/ +``` + +This allows you to execute assume-role by using the short role name, e.g.: + +``` +assume-role --role myrolename +``` + +To see other useful configuration, see *Configuration options* below. + +**Install and run it** + +You can install assume-role using go get: + +``` +go get -u github.com/uber/assume-role-cli +``` + +Now, run a command: + +``` +assume-role --role admin ./myscript.py +``` + +If you run it without a command to execute, environment variables will be printed to the console instead: + +``` +assume-role --role admin +AWS_ACCESS_KEY_ID=ASIAQWERTYUIOPASDFGHJKL +AWS_SECRET_ACCESS_KEY=8qLCbGYKhOWXU38ZVj+RhY1f7+zvuZ3vHMIhNGTxnhs= +AWS_SESSION_TOKEN=Wt5owtYQ/zObHy+8KLAgejM/CKGlt3Fa67PpRt+dVaDv4+NqmuFBu6VCkV1jmtfr82eABf9R2sN76ezZ1NIaaKnnkx8fk1WIH7jb7e5KYD0gsaOaAFIKEsMBMixvrFcxTe4Xth8D7lCohZZLTU2I2kazJxOrE249Xwq61hh1ZTezKHNvqek9BbItQdaWoniEkJz9vtTgXYSxnBJoV+VIsSa7KyDcLrteHVKdLx7qkxvsZvXkvmPRnQtnrGBeT3pm7LIlc2xOiKgAxuDf8gW5RWORrz71DdzFfPVqi0lAw5Hx0Qx/9gipuTPr5DICUzah8l64w4t21R0L9T1r84NAjA== +``` + +That's it! + +## Configuration options + +Configuration is done by placing a file named `assume-role.yaml` in your project directory, or in `~/.aws`. + +assume-role will locate this file if you are running it from within a project subdirectory. + +The following configuration options are available: + +* `refresh_before_expiry: ` (default `15m`) + + When you run assume-role credentials are cached and subsequent invocations just read from the cache. When the credentials expire, a refresh is triggered (doing the AssumeRole again). + + This value controls how long before the credentials are due to expire we'll refresh them anyway. This is so that credentials don't expire in the middle of running a command. + +* `role_prefix: ` (default: empty) + + To avoid typing the full ARN at the command-line every time, you can a prefix so you no longer have to type: + + ``` + assume-role --role arn:aws:iam::123:role/admin + ``` + + Instead you can do: + + ``` + assume-role --role admin + ``` + + By configuring `arn:aws:iam::123:role/` as the prefix. + +* `profile_name_prefix: ` (defaults to empty, which uses your AWS account ID instead) + + When you do an assume-role, the credentials are saved to `~/.aws/credentials` under a name in the format `-`. This allows you to then use the profile with other tools using the `AWS_PROFILE` variable, or for example when executing awscli directly: `aws --profile=myaccount-admin s3 ls bucket://mybucket/`. + + This is a convenience helper but is generally not needed if you always just run all your commands through assume-role. diff --git a/app.go b/app.go new file mode 100644 index 0000000..f6cf44a --- /dev/null +++ b/app.go @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/hashicorp/go-multierror" + "golang.org/x/crypto/ssh/terminal" +) + +// App is the main AssumeRole app. +type App struct { + aws AWSProvider + awsConfig AWSConfigProvider + clock Clock + config Config + stderr io.Writer + stdin io.Reader + + stdinReader *bufio.Reader +} + +// AssumeRoleParameters are the parameters for the AssumeRole call +type AssumeRoleParameters struct { + // UserRole is the ARN of the role to be assumed + UserRole string + + // RoleSessionName is the session name for the AWS AssumeRole call; if it is + // the empty string, the current username will be used + RoleSessionName string +} + +// used here and in tests +var errAssumedRoleNeedsSessionName = errors.New("Validation error: missing role session name when current IAM principal is an assumed role") + +// NewApp creates a new App. +func NewApp(opts ...Option) (*App, error) { + app := &App{ + stdin: os.Stdin, + stderr: os.Stderr, + } + + if err := app.applyOptions(opts...); err != nil { + return nil, err + } + + if err := app.setDefaults(); err != nil { + return nil, err + } + + app.stdinReader = bufio.NewReader(app.stdin) + + return app, nil +} + +// AssumeRole takes a role name and calls AWS AssumeRole, returning a +// set of temporary credentials. If MFA is required, it will prompt for +// an MFA token interactively. +func (app *App) AssumeRole(options AssumeRoleParameters) (*TemporaryCredentials, error) { + profileName, err := app.profileName(options.UserRole) + if err != nil { + return nil, err + } + + profile, err := app.awsConfig.GetProfile(profileName) + if err != nil { + return nil, err + } + if profile == nil { + profile = &ProfileConfiguration{} + } + + currentPrincipalIsAssumedRole, err := app.CurrentPrincipalIsAssumedRole() + if err != nil { + return nil, fmt.Errorf("unable to check IAM principal type: %v", err) + } + + // If the credentials from a previous session are still valid, + // return those + if !app.credentialsExpired(profile.Expires) { + return app.awsConfig.GetCredentials(profileName) + } + + // Get the full role ARN by combining the role prefix with the + // user-provided role name + roleARN := fmt.Sprintf("%s%s", app.config.RolePrefix, options.UserRole) + profile.RoleARN = roleARN + + sessionName := profile.RoleSessionName + if sessionName == "" { + if options.RoleSessionName != "" { + sessionName = options.RoleSessionName + } else { + if currentPrincipalIsAssumedRole { + return nil, errAssumedRoleNeedsSessionName + } + sessionName, err = app.aws.Username() + if err != nil { + return nil, fmt.Errorf("unable to get username from AWS: %v", err) + } + } + profile.RoleSessionName = sessionName + } + + // We first try to assume role without MFA and if that doesn't work then we + // try to assume role with MFA. Along the way, we collect errors in a + // multierr, so that if there is a fatal problem then we can output all + // errors so the user can see what happened along the way. + var finalErr error + + // Try to assume role without MFA + creds, err := app.aws.AssumeRole(roleARN, sessionName) + if err != nil { + if IsAWSAccessDeniedError(err) { + finalErr = multierror.Append(finalErr, fmt.Errorf("error trying to AssumeRole without MFA: %v", err)) + } else { + // Fail immediately if the error was something other than "access denied" + return nil, err + } + } + if creds != nil { + profile.Expires = creds.Expires + + // Save credentials + if err := app.save(profileName, profile, creds); err != nil { + return nil, err + } + + return creds, nil + } + + if currentPrincipalIsAssumedRole { + // assumed roles don't have an user name or MFA device associated with them + return nil, finalErr + } + + // Get user's MFA device + mfaDeviceARN, err := app.mfaDevice() + if err != nil { + finalErr = multierror.Append(finalErr, fmt.Errorf("error trying to AssumeRole with MFA: %v", err)) + return nil, finalErr + } + profile.MFASerial = mfaDeviceARN + + // Get token + mfaToken, err := app.mfaToken() + if err != nil { + finalErr = multierror.Append(finalErr, fmt.Errorf("error trying to AssumeRole with MFA: %v", err)) + return nil, finalErr + } + + // Assume role + creds, err = app.aws.AssumeRoleWithMFA(roleARN, sessionName, mfaDeviceARN, mfaToken) + if err != nil { + finalErr = multierror.Append(finalErr, fmt.Errorf("error trying to AssumeRole with MFA: %v; giving up", err)) + return nil, finalErr + } + profile.Expires = creds.Expires + + // Save credentials + if err := app.save(profileName, profile, creds); err != nil { + return nil, err + } + + return creds, nil +} + +// CurrentPrincipalIsAssumedRole returns true is the current principal is an assumed role. +func (app *App) CurrentPrincipalIsAssumedRole() (bool, error) { + arn, err := app.aws.CurrentPrincipalARN() + if err != nil { + return false, err + } + return regexp.MatchString(`^arn:aws:sts::[0-9]+:assumed-role/`, arn) +} + +// credentialsExpired returns a boolean indicating whether the credentials +// are still valid. This is based on the credentials expiry and the refresh +// horizon configuration. +func (app *App) credentialsExpired(expiryTime time.Time) bool { + return app.clock.Now().After(expiryTime.Add(-app.config.RefreshBeforeExpiry)) +} + +func (app *App) mfaDevice() (string, error) { + devices, err := app.aws.MFADevices() + if err != nil { + return "", err + } + if len(devices) < 1 { + return "", errors.New("no MFA devices found") + } + if len(devices) == 1 { + return devices[0], nil + } + +Prompt: + for i, device := range devices { + fmt.Fprintf(app.stderr, "[%d]: %s\n", i+1, device) + } + + app.stderr.Write([]byte("Select MFA device: ")) + + userInput, err := readInput(app.stdinReader) + if err != nil { + return "", fmt.Errorf("unable to read MFA device option from stdin: %v", err) + } + + userInputInt, err := strconv.Atoi(userInput) + if err != nil { + app.stderr.Write([]byte("Invalid input (not a number)\n")) + goto Prompt + } + + if userInputInt < 1 || userInputInt > len(devices) { + app.stderr.Write([]byte("Invalid input (not in range)\n")) + goto Prompt + } + + return devices[userInputInt-1], nil +} + +func (app *App) mfaToken() (string, error) { + var token string + var err error + + app.stderr.Write([]byte("Enter MFA token: ")) + + stdinFile, ok := app.stdin.(*os.File) + + if ok && terminal.IsTerminal(int(stdinFile.Fd())) { + token, err = readSecretInputFromTerminal(stdinFile) + // Echo the user's "enter" keypress so they get feedback that they did + // in fact hit enter. + app.stderr.Write([]byte("\n")) + } else { + token, err = readInput(app.stdinReader) + } + + if err != nil { + return "", fmt.Errorf("unable to read MFA token from stdin: %v", err) + } + + return strings.TrimSpace(token), nil +} + +// profileName returns a string that will be used as the profile name +// in the AWS config for these credentials. +func (app *App) profileName(userRole string) (string, error) { + var profileNamePrefix string + + roleARN, err := app.roleARN(userRole) + if err != nil { + return "", err + } + + parsedARN, err := arn.Parse(roleARN) + if err != nil { + return "", err + } + + if app.config.ProfileNamePrefix != "" { + profileNamePrefix = app.config.ProfileNamePrefix + } else { + profileNamePrefix = parsedARN.AccountID + } + + return fmt.Sprintf("%s-%s", profileNamePrefix, filepath.Base(parsedARN.Resource)), nil +} + +// roleARN returns the full role ARN, based on configuration and what +// is provided. +func (app *App) roleARN(userRole string) (string, error) { + if isValidARN(userRole) { + return userRole, nil + } + + // Combine the user provided role name with the prefix from the + // config. + combined := fmt.Sprintf("%s%s", app.config.RolePrefix, userRole) + + if isValidARN(combined) { + return combined, nil + } + + return "", fmt.Errorf("invalid role ARN: %v", combined) +} + +// save the credentials and profile. +func (app *App) save(profileName string, profile *ProfileConfiguration, creds *TemporaryCredentials) error { + if err := app.awsConfig.SetProfile(profileName, profile); err != nil { + return err + } + if err := app.awsConfig.SetCredentials(profileName, creds); err != nil { + return err + } + + return nil +} + +func (app *App) setDefaults() error { + if app.aws == nil { + defaultAWS, err := NewAWS() + if err != nil { + return err + } + app.aws = defaultAWS + } + + if app.awsConfig == nil { + defaultCfg, err := NewAWSConfig(AWSConfigOpts{}) + if err != nil { + return err + } + app.awsConfig = defaultCfg + } + + if app.clock == nil { + app.clock = &defaultClock{} + } + + app.config.setDefaults() + + return nil +} diff --git a/app_test.go b/app_test.go new file mode 100644 index 0000000..4cc5051 --- /dev/null +++ b/app_test.go @@ -0,0 +1,349 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole_test + +import ( + "bytes" + "errors" + "fmt" + "testing" + "time" + + assumerole "github.com/uber/assume-role-cli" + "github.com/uber/assume-role-cli/mocks" + + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var awsAccessDeniedError = awserr.New("AccessDenied", "Not authorized to perform sts:AssumeRole", errors.New("test")) + +var fooCredentials = &assumerole.TemporaryCredentials{ + AccessKeyID: "ABC123", + SecretAccessKey: "supersecret", + SessionToken: "123tok", + Expires: time.Now(), +} + +var fooProfileWithMFA = &assumerole.ProfileConfiguration{ + Expires: fooCredentials.Expires, + MFASerial: "arn:aws:iam::000000000000:mfa/bob", + RoleARN: "arn:aws:iam::000000000000:role/testRole", + RoleSessionName: "bob", +} + +var fooProfileWithoutMFA = &assumerole.ProfileConfiguration{ + Expires: fooCredentials.Expires, + RoleARN: "arn:aws:iam::000000000000:role/testRole-fromassumedrole", + RoleSessionName: "bob-session", +} + +type test struct { + AssumeRoleMain *assumerole.App + MockAWS *mocks.MockAWSProvider + MockAWSConfig *mocks.MockAWSConfigProvider + MockClock *testClock + MockStdin *bytes.Buffer + MockStderr *bytes.Buffer +} + +type testClock struct { + time time.Time +} + +func (c *testClock) Now() time.Time { + return c.time +} + +func (c *testClock) SetTime(t time.Time) { + c.time = t +} + +func newTestAssumeRole(t *testing.T, customOptions ...assumerole.Option) *test { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + mockAWS := mocks.NewMockAWSProvider(mockCtrl) + mockAWSConfig := mocks.NewMockAWSConfigProvider(mockCtrl) + + mockClock := &testClock{} + + mockStdin := &bytes.Buffer{} + mockStderr := &bytes.Buffer{} + + // Combine default test options with anything overridden for a particular + // test + testAssumeRoleOptions := append([]assumerole.Option{ + assumerole.WithAWS(mockAWS), + assumerole.WithAWSConfig(mockAWSConfig), + assumerole.WithClock(mockClock), + assumerole.WithStdin(mockStdin), + assumerole.WithStderr(mockStderr), + }, customOptions...) + + main, err := assumerole.NewApp(testAssumeRoleOptions...) + require.NoError(t, err) + + return &test{ + AssumeRoleMain: main, + MockAWS: mockAWS, + MockAWSConfig: mockAWSConfig, + MockClock: mockClock, + MockStdin: mockStdin, + MockStderr: mockStderr, + } +} + +func TestAssumeRoleWithMFAFirstTime(t *testing.T) { + test := newTestAssumeRole(t) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:iam::000000000000:user/bob", nil) + test.MockAWS.EXPECT().Username().Return("bob", nil) + test.MockAWS.EXPECT().MFADevices().Return([]string{fooProfileWithMFA.MFASerial}, nil) + test.MockAWS.EXPECT().AssumeRole(fooProfileWithMFA.RoleARN, "bob").Return(nil, awsAccessDeniedError) + test.MockAWS.EXPECT().AssumeRoleWithMFA(fooProfileWithMFA.RoleARN, "bob", fooProfileWithMFA.MFASerial, "123456").Return(fooCredentials, nil) + + test.MockAWSConfig.EXPECT().GetProfile("000000000000-testRole").Return(nil, nil) + test.MockAWSConfig.EXPECT().SetProfile("000000000000-testRole", fooProfileWithMFA).Return(nil) + test.MockAWSConfig.EXPECT().SetCredentials("000000000000-testRole", fooCredentials) + + test.MockStdin.WriteString("123456" + "\n") + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: fooProfileWithMFA.RoleARN, + }) + assert.NoError(t, err) + assert.Equal(t, fooCredentials, creds) +} + +func TestErrorNoMFADevices(t *testing.T) { + test := newTestAssumeRole(t) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:iam::000000000000:user/bob", nil) + test.MockAWS.EXPECT().Username().Return("bob", nil) + test.MockAWS.EXPECT().MFADevices().Return([]string{}, nil) + test.MockAWS.EXPECT().AssumeRole(fooProfileWithMFA.RoleARN, "bob").Return(nil, awsAccessDeniedError) + test.MockAWS.EXPECT().AssumeRoleWithMFA(fooProfileWithMFA.RoleARN, "bob", fooProfileWithMFA.MFASerial, "123456").Return(fooCredentials, nil) + + test.MockAWSConfig.EXPECT().GetProfile("000000000000-testRole").Return(nil, nil) + test.MockAWSConfig.EXPECT().SetProfile("000000000000-testRole", fooProfileWithMFA).Return(nil) + test.MockAWSConfig.EXPECT().SetCredentials("000000000000-testRole", fooCredentials) + + test.MockStdin.WriteString("123456" + "\n") + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: fooProfileWithMFA.RoleARN, + }) + require.Error(t, err) + + assert.Contains(t, err.Error(), "error trying to AssumeRole without MFA") + assert.Contains(t, err.Error(), "error trying to AssumeRole with MFA") + assert.Nil(t, creds) +} + +func TestMFAPromptInvalid(t *testing.T) { + test := newTestAssumeRole(t) + + expectedCredentials := &assumerole.TemporaryCredentials{} + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:iam::000000000000:user/bob", nil) + test.MockAWS.EXPECT().Username().Return("bob", nil) + test.MockAWS.EXPECT().MFADevices().Return([]string{ + "foo", + "bar", + }, nil) + test.MockAWS.EXPECT().AssumeRole("arn:aws:iam::000000000000:role/testRole", "bob").Return(nil, nil) + test.MockAWS.EXPECT().AssumeRoleWithMFA("arn:aws:iam::000000000000:role/testRole", "bob", "foo", "123456").Return(expectedCredentials, nil) + test.MockAWSConfig.EXPECT().GetProfile("000000000000-testRole").Return(nil, nil) + test.MockAWSConfig.EXPECT().SetProfile("000000000000-testRole", gomock.Any()).Return(nil) + test.MockAWSConfig.EXPECT().SetCredentials("000000000000-testRole", gomock.Any()).Return(nil) + + // Write responses for the prompts + test.MockStdin.WriteString("asd\n") // invalid + test.MockStdin.WriteString("3\n") // invalid + test.MockStdin.WriteString("1\n") + test.MockStdin.WriteString("123456\n") + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: "arn:aws:iam::000000000000:role/testRole", + }) + require.NoError(t, err) + require.Exactly(t, expectedCredentials, creds) + + assert.Equal(t, `[1]: foo +[2]: bar +Select MFA device: Invalid input (not a number) +[1]: foo +[2]: bar +Select MFA device: Invalid input (not in range) +[1]: foo +[2]: bar +Select MFA device: Enter MFA token: `, test.MockStderr.String()) +} + +func TestAssumeRoleWithAssumedRoleSuccess(t *testing.T) { + test := newTestAssumeRole(t) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:sts::000000000000:assumed-role/testRole/bob", nil) + test.MockAWS.EXPECT().AssumeRole(fooProfileWithoutMFA.RoleARN, "bob-session").Return(fooCredentials, nil) + + test.MockAWSConfig.EXPECT().GetProfile("000000000000-testRole-fromassumedrole").Return(nil, nil) + test.MockAWSConfig.EXPECT().SetProfile("000000000000-testRole-fromassumedrole", fooProfileWithoutMFA).Return(nil) + test.MockAWSConfig.EXPECT().SetCredentials("000000000000-testRole-fromassumedrole", fooCredentials) + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: fooProfileWithoutMFA.RoleARN, + RoleSessionName: "bob-session", + }) + assert.NoError(t, err) + assert.Equal(t, fooCredentials, creds) +} + +func TestAssumeRoleWithAssumedRoleDoesNotTryMFA(t *testing.T) { + test := newTestAssumeRole(t) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:sts::000000000000:assumed-role/testRole/bob", nil) + test.MockAWS.EXPECT().AssumeRole(fooProfileWithoutMFA.RoleARN, "bob-session").Return(nil, awsAccessDeniedError) + + test.MockAWSConfig.EXPECT().GetProfile("000000000000-testRole-fromassumedrole").Return(nil, nil) + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: fooProfileWithoutMFA.RoleARN, + RoleSessionName: "bob-session", + }) + assert.Error(t, err) + assert.Nil(t, creds) +} + +func TestConfigRolePrefix(t *testing.T) { + config, err := assumerole.LoadConfig("fixtures/test-config-roleprefix/assume-role.yaml") + require.NoError(t, err) + + test := newTestAssumeRole(t, assumerole.WithConfig(config)) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:iam::000000000000:user/bob", nil) + test.MockAWS.EXPECT().Username().Return("bob", nil) + test.MockAWS.EXPECT().MFADevices().Return([]string{fooProfileWithMFA.MFASerial}, nil) + test.MockAWS.EXPECT().AssumeRole(fooProfileWithMFA.RoleARN, "bob").Return(nil, nil) + test.MockAWS.EXPECT().AssumeRoleWithMFA(fooProfileWithMFA.RoleARN, "bob", fooProfileWithMFA.MFASerial, "123456").Return(fooCredentials, nil) + + test.MockAWSConfig.EXPECT().GetProfile("foobar-testRole").Return(nil, nil) + test.MockAWSConfig.EXPECT().SetProfile("foobar-testRole", fooProfileWithMFA).Return(nil) + test.MockAWSConfig.EXPECT().SetCredentials("foobar-testRole", fooCredentials) + + test.MockStdin.WriteString("123456" + "\n") + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: "testRole", + }) + assert.NoError(t, err) + assert.Equal(t, fooCredentials, creds) +} + +func TestCredentialsExpiry(t *testing.T) { + mockNow := time.Date(2018, 04, 23, 23, 45, 43, 0, time.UTC) + mockCreds := &assumerole.TemporaryCredentials{} + + config := &assumerole.Config{ + RefreshBeforeExpiry: 5 * time.Minute, + } + + tests := []struct { + credentialExpiry time.Time + expectRefresh bool + }{ + { + // credentials are expiring exactly now + credentialExpiry: mockNow, + expectRefresh: true, + }, + { + // expired 1s ago + credentialExpiry: mockNow.Add(-time.Second), + expectRefresh: true, + }, + { + // expiring in 3m + credentialExpiry: mockNow.Add(3 * time.Minute), + // should trigger a refresh, because it is within the refresh + // horizon even though it's not expired yet. + expectRefresh: true, + }, + { + // expiring in 10m (still valid) + credentialExpiry: mockNow.Add(10 * time.Minute), + expectRefresh: false, + }, + { + // expired 20m ago + credentialExpiry: mockNow.Add(-20 * time.Minute), + expectRefresh: true, + }, + } + + for i, tt := range tests { + test := newTestAssumeRole(t, assumerole.WithConfig(config)) + + // Base expectations + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:iam::000000000000:user/bob", nil) + test.MockAWS.EXPECT().Username().Return("bob", nil) + test.MockAWSConfig.EXPECT().GetProfile("123-testRole").Return(&assumerole.ProfileConfiguration{ + Expires: tt.credentialExpiry, + }, nil) + test.MockAWSConfig.EXPECT().SetProfile("123-testRole", gomock.Any()).Return(nil) + test.MockAWSConfig.EXPECT().SetCredentials("123-testRole", gomock.Any()).Return(nil) + + // Set mock time.Now() + test.MockClock.SetTime(mockNow) + + if tt.expectRefresh { + // If we're expecting a refresh, the app should call out to AWS's + // AssumeRole, and get the credentials back. + test.MockAWS.EXPECT().AssumeRole(gomock.Any(), gomock.Any()).Return(mockCreds, nil) + } else { + // If there's no refresh, there should be no AssumeRole call. + test.MockAWS.EXPECT().AssumeRole(gomock.Any(), gomock.Any()).Do(func(roleARN string, sessionName string) { + assert.Fail(t, fmt.Sprintf("unexpected credentials refresh; table test index: %d", i)) + }) + // Credentials should be fetched from cache. + test.MockAWSConfig.EXPECT().GetCredentials(gomock.Any()).Return(mockCreds, nil) + } + + creds, err := test.AssumeRoleMain.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: "arn:aws:iam::123:role/testRole", + }) + assert.NoError(t, err) + assert.Equal(t, mockCreds, creds) + } +} + +func TestCurrentRoleIsAssumedRole(t *testing.T) { + test := newTestAssumeRole(t) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:iam::000000000000:user/bob", nil) + + isAssumedRole, err := test.AssumeRoleMain.CurrentPrincipalIsAssumedRole() + assert.NoError(t, err) + assert.Equal(t, false, isAssumedRole) + + test.MockAWS.EXPECT().CurrentPrincipalARN().Return("arn:aws:sts::000000000000:assumed-role/testRole/bob", nil) + + isAssumedRole, err = test.AssumeRoleMain.CurrentPrincipalIsAssumedRole() + assert.NoError(t, err) + assert.Equal(t, true, isAssumedRole) +} diff --git a/aws.go b/aws.go new file mode 100644 index 0000000..be8c7c2 --- /dev/null +++ b/aws.go @@ -0,0 +1,395 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/awserr" + "github.com/aws/aws-sdk-go/aws/defaults" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/iam" + "github.com/aws/aws-sdk-go/service/sts" + "github.com/go-ini/ini" +) + +// IsAWSAccessDeniedError indicates whether an error is an AWS "access denied" +// error. +func IsAWSAccessDeniedError(err error) bool { + awsErr, ok := err.(awserr.Error) + return (ok && awsErr.Code() == "AccessDenied") +} + +// AWSProvider is an interface to AWS. +type AWSProvider interface { + AssumeRole(roleARN string, sessionName string) (*TemporaryCredentials, error) + AssumeRoleWithMFA(roleARN string, sessionName string, mfaDeviceARN string, mfaToken string) (*TemporaryCredentials, error) + MFADevices() ([]string, error) + Username() (string, error) + CurrentPrincipalARN() (string, error) +} + +// AWSConfigProvider is an interface to the AWS configuration (usually +// kept in files in ~/.aws). +type AWSConfigProvider interface { + GetCredentials(profileName string) (*TemporaryCredentials, error) + SetCredentials(profileName string, creds *TemporaryCredentials) error + GetProfile(profileName string) (*ProfileConfiguration, error) + SetProfile(profileName string, profile *ProfileConfiguration) error +} + +// ProfileConfiguration holds the configuration from a single profile +// usually in ~/.aws/config. +type ProfileConfiguration struct { + Expires time.Time + MFASerial string + SourceProfile string + RoleARN string + RoleSessionName string +} + +// TemporaryCredentials is a set of Amazon security credentials, along +// with an expiry. +type TemporaryCredentials struct { + AccessKeyID string + SecretAccessKey string + SessionToken string + Expires time.Time +} + +// AWS is the default implementation of AWSProvider that talks to the +// real AWS. +type AWS struct { + iam *iam.IAM + sts *sts.STS +} + +// NewAWS creates a new connection to AWS. +func NewAWS() (AWSProvider, error) { + session, err := session.NewSessionWithOptions(session.Options{ + SharedConfigState: session.SharedConfigEnable, + }) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %v", err) + } + return &AWS{ + iam: iam.New(session), + sts: sts.New(session), + }, nil +} + +// AssumeRole calls sts:AssumeRole and returns temporary credentials. +func (a *AWS) AssumeRole(roleARN string, sessionName string) (*TemporaryCredentials, error) { + return a.AssumeRoleWithMFA(roleARN, sessionName, "", "") +} + +// AssumeRoleWithMFA calls sts:AssumeRole (with MFA information) and +// returns temporary credentials. +func (a *AWS) AssumeRoleWithMFA(roleARN string, sessionName string, mfaDeviceARN string, mfaToken string) (*TemporaryCredentials, error) { + req := &sts.AssumeRoleInput{ + DurationSeconds: aws.Int64(int64(time.Hour.Seconds())), + RoleArn: aws.String(roleARN), + RoleSessionName: aws.String(sessionName), + } + + if mfaDeviceARN != "" { + req.SerialNumber = aws.String(mfaDeviceARN) + } + + if mfaToken != "" { + req.TokenCode = aws.String(mfaToken) + } + + res, err := a.sts.AssumeRole(req) + if err != nil { + return nil, err + } + + return &TemporaryCredentials{ + AccessKeyID: *res.Credentials.AccessKeyId, + Expires: *res.Credentials.Expiration, + SecretAccessKey: *res.Credentials.SecretAccessKey, + SessionToken: *res.Credentials.SessionToken, + }, nil +} + +// MFADevices lists the MFA devices on the current user's account. +func (a *AWS) MFADevices() ([]string, error) { + username, err := a.Username() + if err != nil { + return nil, err + } + + res, err := a.iam.ListMFADevices(&iam.ListMFADevicesInput{ + UserName: aws.String(username), + }) + if err != nil { + return nil, err + } + + devices := make([]string, len(res.MFADevices)) + for i := range res.MFADevices { + devices[i] = *res.MFADevices[i].SerialNumber + } + return devices, nil +} + +// Username returns the username of the current AWS user. +func (a *AWS) Username() (string, error) { + res, err := a.iam.GetUser(&iam.GetUserInput{}) + if err != nil { + return "", err + } + + return *res.User.UserName, nil +} + +// CurrentPrincipalARN returns the ARN of the current IAM principal. +func (a *AWS) CurrentPrincipalARN() (string, error) { + res, err := a.sts.GetCallerIdentity(&sts.GetCallerIdentityInput{}) + if err != nil { + return "", err + } + + return *res.Arn, nil +} + +// AWSConfig represents the default AWS config files that exist on a system at +// ~/.aws/{config,credentials}. These two files are inherently linked for us, +// because while the credentials are stored in the credentials file, the +// metadata about these credentials are stored in the profile config file. +type AWSConfig struct { + config *AWSConfigOpts + + awsConfigIni *ini.File + awsCredentialsIni *ini.File +} + +// AWSConfigOpts are the options for the AWSConfig. +type AWSConfigOpts struct { + // ConfigFilePath is the path to the shared AWS config file, usually at + // ~/.aws/config. If you leave this blank, the default location will be + // used. + ConfigFilePath string + // CredentialsFilePath is the path to the shared AWS config file, usually + // at ~/.aws/credentials. If you leave this blank, the default location + // will be used. + CredentialsFilePath string +} + +// NewAWSConfig returns a new AWSConfig, that will lazily read credentials and +// configuration from the default AWS config at ~/.aws. +func NewAWSConfig(config AWSConfigOpts) (*AWSConfig, error) { + if config.ConfigFilePath == "" { + if os.Getenv("AWS_CONFIG_FILE") != "" { + config.ConfigFilePath = os.Getenv("AWS_CONFIG_FILE") + } else { + config.ConfigFilePath = defaults.SharedConfigFilename() + } + } + + if config.CredentialsFilePath == "" { + if os.Getenv("AWS_SHARED_CREDENTIALS_FILE") != "" { + config.CredentialsFilePath = os.Getenv("AWS_SHARED_CREDENTIALS_FILE") + } else { + config.CredentialsFilePath = defaults.SharedCredentialsFilename() + } + } + + awsConfigIni, err := ini.LooseLoad(config.ConfigFilePath) + if err != nil { + return nil, err + } + + awsCredentialsIni, err := ini.LooseLoad(config.CredentialsFilePath) + if err != nil { + return nil, err + } + + return &AWSConfig{ + config: &config, + awsConfigIni: awsConfigIni, + awsCredentialsIni: awsCredentialsIni, + }, nil +} + +// credentialsIniSection returns the named INI section from the credentials +// file or creates it if it doesn't exist. +func (c *AWSConfig) credentialsIniSection(profileName string) (*ini.Section, error) { + if section := c.awsCredentialsIni.Section(profileName); section != nil { + return section, nil + } + + return c.awsConfigIni.NewSection(profileName) +} + +// credentialsIniSection returns the named INI section from the shared config +// file or creates it if it doesn't exist. +func (c *AWSConfig) profileIniSection(profileName string) (*ini.Section, error) { + if section := c.awsConfigIni.Section(fmt.Sprintf("profile %s", profileName)); section != nil { + return section, nil + } + + return c.awsConfigIni.NewSection(profileName) +} + +// GetProfile returns the AWS profile metadata information from the shared +// config file. +func (c *AWSConfig) GetProfile(profileName string) (*ProfileConfiguration, error) { + section, err := c.profileIniSection(profileName) + if err != nil { + return nil, err + } + + profileConfig := &ProfileConfiguration{} + + if key := section.Key("expiration"); key != nil { + if value, err := key.TimeFormat(time.RFC3339); err == nil { + profileConfig.Expires = value + } + } + + if key := section.Key("mfa_serial"); key != nil { + profileConfig.MFASerial = key.String() + } + + if key := section.Key("source_profile"); key != nil { + profileConfig.SourceProfile = key.String() + } + + if key := section.Key("role_arn"); key != nil { + profileConfig.RoleARN = key.String() + } + + if key := section.Key("role_session_name"); key != nil { + profileConfig.RoleSessionName = key.String() + } + + return profileConfig, nil +} + +// SetProfile writes the specified profile information to the shared AWS config +// config file. +func (c *AWSConfig) SetProfile(profileName string, profile *ProfileConfiguration) error { + section, err := c.profileIniSection(profileName) + if err != nil { + return err + } + + if err := setIniKeyValue(section, "expiration", profile.Expires.Format(time.RFC3339)); err != nil { + return err + } + + if err := setIniKeyValue(section, "mfa_serial", profile.MFASerial); err != nil { + return err + } + + if err := setIniKeyValue(section, "source_profile", profile.SourceProfile); err != nil { + return err + } + + if err := setIniKeyValue(section, "role_arn", profile.RoleARN); err != nil { + return err + } + + if err := setIniKeyValue(section, "role_session_name", profile.RoleSessionName); err != nil { + return err + } + + // Ensure dir exists + if err := os.MkdirAll(filepath.Dir(c.config.ConfigFilePath), 0755); err != nil { + return err + } + + return c.awsConfigIni.SaveTo(c.config.ConfigFilePath) +} + +// GetCredentials retrieves the named credentials from the AWS credential file. +func (c *AWSConfig) GetCredentials(profileName string) (*TemporaryCredentials, error) { + section, err := c.credentialsIniSection(profileName) + if err != nil { + return nil, err + } + + creds := &TemporaryCredentials{} + + if key := section.Key("aws_access_key_id"); key != nil { + creds.AccessKeyID = key.String() + } + + if key := section.Key("aws_secret_access_key"); key != nil { + creds.SecretAccessKey = key.String() + } + + if key := section.Key("aws_session_token"); key != nil { + creds.SessionToken = key.String() + } + + // Get the expiry time from the profile + profile, err := c.GetProfile(profileName) + if err != nil { + return nil, err + } + creds.Expires = profile.Expires + + return creds, nil +} + +// SetCredentials saves the credentials to the AWS credential file. +func (c *AWSConfig) SetCredentials(profileName string, creds *TemporaryCredentials) error { + section, err := c.credentialsIniSection(profileName) + if err != nil { + return err + } + + if err := setIniKeyValue(section, "aws_access_key_id", creds.AccessKeyID); err != nil { + return err + } + + if err := setIniKeyValue(section, "aws_secret_access_key", creds.SecretAccessKey); err != nil { + return err + } + + if err := setIniKeyValue(section, "aws_session_token", creds.SessionToken); err != nil { + return err + } + + profile, err := c.profileIniSection(profileName) + if err != nil { + return err + } + + // Set the expiry time in the profile + if err := setIniKeyValue(profile, "expiration", creds.Expires.Format(time.RFC3339)); err != nil { + return err + } + + // Save profile + if err := c.awsConfigIni.SaveTo(c.config.ConfigFilePath); err != nil { + return err + } + + // Ensure dir exists + if err := os.MkdirAll(filepath.Dir(c.config.CredentialsFilePath), 0755); err != nil { + return err + } + + return c.awsCredentialsIni.SaveTo(c.config.CredentialsFilePath) +} diff --git a/aws_test.go b/aws_test.go new file mode 100644 index 0000000..ef68096 --- /dev/null +++ b/aws_test.go @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole_test + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" + "time" + + "github.com/uber/assume-role-cli" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReadConfig(t *testing.T) { + awsConfig, err := assumerole.NewAWSConfig(assumerole.AWSConfigOpts{ + ConfigFilePath: "fixtures/test-awsconfig/config", + CredentialsFilePath: "fixtures/test-awsconfig/credentials", + }) + require.NoError(t, err) + + fooTestProfile, err := awsConfig.GetProfile("foo-test") + require.NoError(t, err) + + assert.Equal(t, &assumerole.ProfileConfiguration{ + Expires: time.Date(2018, 4, 23, 13, 45, 43, 0, time.UTC), + MFASerial: "arn:aws:iam::123:mfa/bob", + SourceProfile: "default", + RoleARN: "arn:aws:iam::123:role/admin", + RoleSessionName: "", + }, fooTestProfile) +} + +func TestWriteConfig(t *testing.T) { + tempDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + + defer os.RemoveAll(tempDir) + + awsConfig, err := assumerole.NewAWSConfig(assumerole.AWSConfigOpts{ + ConfigFilePath: filepath.Join(tempDir, "aws", "config"), + CredentialsFilePath: filepath.Join(tempDir, "aws", "credentials"), + }) + require.NoError(t, err) + + fooTestProfile, err := awsConfig.GetProfile("test") + require.NoError(t, err) + require.Nil(t, err) + + fooTestProfile = &assumerole.ProfileConfiguration{ + Expires: time.Date(2018, 4, 23, 13, 45, 43, 0, time.UTC), + MFASerial: "arn:aws:iam::123:mfa/bob", + SourceProfile: "default", + RoleARN: "arn:aws:iam::123:role/admin", + RoleSessionName: "", + } + + err = awsConfig.SetProfile("test", fooTestProfile) + require.NoError(t, err) + + fooTestProfileReRead, err := awsConfig.GetProfile("test") + require.NoError(t, err) + + assert.Equal(t, fooTestProfile, fooTestProfileReRead) +} + +func TestGetCredentialsFromAWSConfigFile(t *testing.T) { + awsConfig, err := assumerole.NewAWSConfig(assumerole.AWSConfigOpts{ + ConfigFilePath: "fixtures/test-getcredentials/config", + CredentialsFilePath: "fixtures/test-getcredentials/credentials", + }) + require.NoError(t, err) + + creds, err := awsConfig.GetCredentials("foo-test") + require.NoError(t, err) + + assert.Equal(t, &assumerole.TemporaryCredentials{ + AccessKeyID: "DEF", + SecretAccessKey: "yyy", + SessionToken: "sss", + Expires: time.Date(2018, 4, 23, 13, 45, 43, 0, time.UTC), + }, creds) +} + +func TestWriteCredentialsToAWSConfigFile(t *testing.T) { + tempDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + + defer os.RemoveAll(tempDir) + + awsConfig, err := assumerole.NewAWSConfig(assumerole.AWSConfigOpts{ + ConfigFilePath: filepath.Join(tempDir, "config"), + CredentialsFilePath: filepath.Join(tempDir, "credentials"), + }) + require.NoError(t, err) + + fooCreds := &assumerole.TemporaryCredentials{ + AccessKeyID: "DEF", + SecretAccessKey: "yyy", + SessionToken: "sss", + Expires: time.Date(2018, 4, 23, 13, 45, 43, 0, time.UTC), + } + + err = awsConfig.SetCredentials("foo-test", fooCreds) + require.NoError(t, err) + + fooCredsReRead, err := awsConfig.GetCredentials("foo-test") + require.NoError(t, err) + + assert.Equal(t, fooCreds, fooCredsReRead) +} diff --git a/cli/assume-role/main.go b/cli/assume-role/main.go new file mode 100644 index 0000000..d251439 --- /dev/null +++ b/cli/assume-role/main.go @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package main + +import ( + "os" + + "github.com/uber/assume-role-cli/cli" +) + +func main() { + os.Exit(cli.Main(os.Stdin, os.Stdout, os.Stderr, os.Args[1:])) +} diff --git a/cli/cli.go b/cli/cli.go new file mode 100644 index 0000000..d442caa --- /dev/null +++ b/cli/cli.go @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cli + +import ( + "fmt" + "io" + "os" + "os/exec" + "syscall" + + "github.com/uber/assume-role-cli" +) + +// credentialsToEnv takes credentials and outputs them as a list of environment +// variables. +func credentialsToEnv(creds *assumerole.TemporaryCredentials) (envVars []string) { + envVars = append(envVars, + fmt.Sprintf("%s=%s", "AWS_ACCESS_KEY_ID", creds.AccessKeyID), + fmt.Sprintf("%s=%s", "AWS_SECRET_ACCESS_KEY", creds.SecretAccessKey), + fmt.Sprintf("%s=%s", "AWS_SESSION_TOKEN", creds.SessionToken), + ) + + return envVars +} + +// execute will act like "exec [args ...]" in a shell, first searching +// for cmd in the path and then ecxecuting it, replacing the current running +// process. +func execute(cmd string, args []string, env []string) error { + binary, err := exec.LookPath(cmd) + if err != nil { + return err + } + + // execve will replace the current running process on success + return syscall.Exec(binary, args, env) +} + +func loadApp(stdin io.Reader, stdout io.Writer, stderr io.Writer) (*assumerole.App, error) { + appOpts := []assumerole.Option{ + assumerole.WithStdin(stdin), + assumerole.WithStderr(stderr), + } + + configFile, err := findConfigFile() + if err != nil { + return nil, err + } + + if configFile != "" { + config, err := assumerole.LoadConfig(configFile) + if err != nil { + return nil, err + } + + appOpts = append(appOpts, assumerole.WithConfig(config)) + } + + return assumerole.NewApp(appOpts...) +} + +func printHelp(out io.Writer) { + fmt.Fprint(out, `Assume an AWS role and run the specified command. + +Usage: + assume-role [options] [args ...] + +Options: + --help Help for assume-role + --role string Name of the role to assume + --role-session-name string Name of the session for the assumed role +`) +} + +func printVars(vars []string, out io.Writer) { + for _, x := range vars { + fmt.Fprintf(out, "%s\n", x) + } +} + +// Main is the main entry point into the CLI program. +func Main(stdin io.Reader, stdout io.Writer, stderr io.Writer, args []string) (exitCode int) { + if len(args) == 1 && (args[0] == "-h" || args[0] == "--help") { + printHelp(stdout) + return 0 + } + + app, err := loadApp(stdin, stdout, stderr) + if err != nil { + fmt.Fprintf(stderr, "ERROR: %v\n", err) + return 1 + } + + userOpts, err := parseOptions(args) + if err != nil { + fmt.Fprintf(stderr, "ERROR: %v\n", err) + return 1 + } + + credentials, err := app.AssumeRole(assumerole.AssumeRoleParameters{ + UserRole: userOpts.role, + RoleSessionName: userOpts.roleSessionName, + }) + if err != nil { + fmt.Fprintf(stderr, "ERROR: %v\n", err) + return 1 + } + + vars := credentialsToEnv(credentials) + + if len(userOpts.args) == 0 { + // Print vars to stdout + printVars(vars, stdout) + } else { + // Add AWS credentials to the environment + env := append(os.Environ(), vars...) + + // execve will replace the current running process on success + if err := execute(userOpts.args[0], userOpts.args, env); err != nil { + fmt.Fprintf(stderr, "ERROR: Could not execute command: %v\n", err) + return 127 + } + } + + return 0 +} diff --git a/cli/cli_test.go b/cli/cli_test.go new file mode 100644 index 0000000..adec168 --- /dev/null +++ b/cli/cli_test.go @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cli_test + +import ( + "bytes" + "fmt" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/uber/assume-role-cli/cli" + + "github.com/hashicorp/go-multierror" + "github.com/hgfischer/go-otp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Env vars containing secret keys need to be set for some integration tests +// to work properly. For security reasons, they are not committed to the repo. +var secretCredentialsEnvVarPrefix = "ASSUME_ROLE_INTEGRATION_TEST_" + +// List of vars that make up the secret credentials. +var secretCredentialsVars = []string{ + "AWS_ACCESS_KEY", + "AWS_SECRET_ACCESS_KEY", +} + +var secretOTPEnvVar = secretCredentialsEnvVarPrefix + "AWS_OTP_SECRET" + +type execTestOpts struct { + // Args to send to the program for the test. + args []string + // Additional env vars to set before executing the test. + env map[string]string + // Directory that contains the fixture data (e.g. aws config files). + // Test will be executed in this dir. + fixture string + // String value for stdin for the test. + input string +} + +type execResult struct { + ExitCode int + Stdout *bytes.Buffer + Stderr *bytes.Buffer +} + +func copyFilesToTempDir(src string) (string, error) { + tmpdir, err := ioutil.TempDir("", "") + if err != nil { + return "", err + } + + // Copy the dir to a temporary location + if err := exec.Command("cp", "-r", src, tmpdir).Run(); err != nil { + return "", err + } + + return filepath.Join(tmpdir, filepath.Base(src)), nil +} + +func execTest(t *testing.T, opts execTestOpts) *execResult { + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + // Restore existing env vars after changing them for the test + oldAWSConfigFileEnv := os.Getenv("AWS_CONFIG_FILE") + defer os.Setenv("AWS_CONFIG_FILE", oldAWSConfigFileEnv) + oldAWSSharedCredsEnv := os.Getenv("AWS_SHARED_CREDENTIALS_FILE") + defer os.Setenv("AWS_SHARED_CREDENTIALS_FILE", oldAWSSharedCredsEnv) + + // Set additional env vars + for key, val := range opts.env { + oldValue := os.Getenv(key) + defer os.Setenv(key, oldValue) + + os.Setenv(key, val) + } + + os.Setenv("AWS_CONFIG_FILE", filepath.Join(opts.fixture, "aws/config")) + os.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(opts.fixture, "aws/credentials")) + + stdin := bytes.NewBufferString(opts.input) + + // Restore previous working dir after changing + cwd, err := os.Getwd() + require.NoError(t, err) + + err = os.Chdir(opts.fixture) + require.NoError(t, err) + + defer os.Chdir(cwd) + + exitCode := cli.Main(stdin, stdout, stderr, opts.args) + + return &execResult{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + } +} + +// readSecretCredentials reads secret AWS credentials from environment +// variables. The AWS credentials are expected to be in an env var named like: +// "secretCredentialsEnvVarPrefix + credentialsKey + _AWS_XXXX". +func readSecretCredentialsFromEnv(credentialsKey string) (vars map[string]string, errs error) { + vars = make(map[string]string) + + for _, targetEnvVarName := range secretCredentialsVars { + sourceEnvVarName := fmt.Sprintf("%s%s_%s", secretCredentialsEnvVarPrefix, credentialsKey, targetEnvVarName) + value := os.Getenv(sourceEnvVarName) + + if value == "" { + errs = multierror.Append(errs, fmt.Errorf("missing required env var: %v", sourceEnvVarName)) + } else { + vars[targetEnvVarName] = value + } + } + + if errs != nil { + return nil, errs + } + + return vars, nil +} + +func TestAssumeRoleWithoutMFA(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test due to -short flag") + } + + awsCreds, err := readSecretCredentialsFromEnv("WITHOUT_MFA") + require.NoError(t, err) + + fixtureDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(fixtureDir) + + result := execTest(t, execTestOpts{ + args: []string{"--role", "arn:aws:iam::675470192105:role/test_assume-role"}, + env: awsCreds, + fixture: fixtureDir, + }) + assert.Regexp(t, "^AWS_ACCESS_KEY_ID=.*\nAWS_SECRET_ACCESS_KEY=.*\nAWS_SESSION_TOKEN=.*\n$", result.Stdout.String()) + assert.Empty(t, result.Stderr.String()) + assert.Zero(t, result.ExitCode) +} + +func TestErrorNoMFADevices(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test due to -short flag") + } + + awsCreds, err := readSecretCredentialsFromEnv("WITHOUT_MFA") + require.NoError(t, err) + + fixtureDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(fixtureDir) + + result := execTest(t, execTestOpts{ + args: []string{"--role", "arn:aws:iam::675470192105:role/no-access-role"}, + env: awsCreds, + fixture: fixtureDir, + }) + assert.Contains(t, result.Stderr.String(), "error trying to AssumeRole without MFA") + assert.Contains(t, result.Stderr.String(), "error trying to AssumeRole with MFA") + assert.NotZero(t, result.ExitCode) +} + +func TestAssumeRoleWithMFA(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test due to -short flag") + } + + awsCreds, err := readSecretCredentialsFromEnv("WITH_MFA") + require.NoError(t, err) + + otpSecret := os.Getenv(secretOTPEnvVar) + if otpSecret == "" { + t.Errorf("missing OTP secret from env var: %v", secretOTPEnvVar) + t.FailNow() + } + + fixtureDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(fixtureDir) + + mfa := otp.TOTP{ + Secret: otpSecret, + IsBase32Secret: true, + } + + result := execTest(t, execTestOpts{ + args: []string{"--role", "arn:aws:iam::675470192105:role/test_assume-role"}, + env: awsCreds, + fixture: fixtureDir, + input: mfa.Get() + "\n", + }) + assert.Regexp(t, "^AWS_ACCESS_KEY_ID=.*\nAWS_SECRET_ACCESS_KEY=.*\nAWS_SESSION_TOKEN=.*\n$", result.Stdout.String()) + assert.Equal(t, "Enter MFA token: ", result.Stderr.String()) + assert.Zero(t, result.ExitCode) +} + +func TestCredentialsCached(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test due to -short flag") + } + + awsCreds, err := readSecretCredentialsFromEnv("WITHOUT_MFA") + require.NoError(t, err) + + fixtureDir, err := ioutil.TempDir("", "") + require.NoError(t, err) + defer os.RemoveAll(fixtureDir) + + // Do the first AssumeRole + a := execTest(t, execTestOpts{ + args: []string{"--role", "arn:aws:iam::675470192105:role/test_assume-role"}, + env: awsCreds, + fixture: fixtureDir, + }) + require.Empty(t, a.Stderr.String()) + require.Zero(t, a.ExitCode) + + // Do the second AssumeRole + b := execTest(t, execTestOpts{ + args: []string{"--role", "arn:aws:iam::675470192105:role/test_assume-role"}, + env: awsCreds, + fixture: fixtureDir, + }) + require.Empty(t, b.Stderr.String()) + require.Zero(t, b.ExitCode) + + // Credentials should match because they were cached the first time + assert.Equal(t, a.Stdout.String(), b.Stdout.String()) + + writtenCredentialFile, err := os.Stat(filepath.Join(fixtureDir, "aws/credentials")) + require.NoError(t, err) + + writtenConfigFile, err := os.Stat(filepath.Join(fixtureDir, "aws/config")) + require.NoError(t, err) + + // Config/credential files should have been written to + assert.NotZero(t, writtenConfigFile.Size()) + assert.NotZero(t, writtenCredentialFile.Size()) +} + +func TestConfig(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test due to -short flag") + } + + awsCreds, err := readSecretCredentialsFromEnv("WITHOUT_MFA") + require.NoError(t, err) + + fixtureDir, err := copyFilesToTempDir("fixtures/test-config") + require.NoError(t, err) + defer os.RemoveAll(fixtureDir) + + result := execTest(t, execTestOpts{ + args: []string{"--role", "test_assume-role"}, + fixture: fixtureDir, + env: awsCreds, + }) + assert.Empty(t, result.Stderr.String()) + assert.Zero(t, result.ExitCode) +} diff --git a/cli/config.go b/cli/config.go new file mode 100644 index 0000000..3257edf --- /dev/null +++ b/cli/config.go @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cli + +import ( + "os" + "path/filepath" + + homedir "github.com/mitchellh/go-homedir" +) + +func fileExists(path string) bool { + _, err := os.Stat(path) + return !os.IsNotExist(err) +} + +func findConfigFile() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + + for _, path := range searchPaths(wd) { + configFile := filepath.Join(path, "assume-role.yaml") + if fileExists(configFile) { + return configFile, nil + } + } + + home, err := homedir.Dir() + if err != nil { + return "", err + } + + configFile := filepath.Join(home, ".aws", "assume-role.yaml") + if fileExists(configFile) { + return configFile, nil + } + + return "", nil +} + +// searchPaths returns a list of paths from basePath upwards to the root ("/). +func searchPaths(basePath string) (paths []string) { + root := basePath + + for root != "/" { + paths = append(paths, root) + root = filepath.Dir(root) + } + + paths = append(paths, "/") + + return paths +} diff --git a/cli/fixtures/test-config/assume-role.yaml b/cli/fixtures/test-config/assume-role.yaml new file mode 100644 index 0000000..50ac6be --- /dev/null +++ b/cli/fixtures/test-config/assume-role.yaml @@ -0,0 +1 @@ +role_prefix: arn:aws:iam::675470192105:role/ diff --git a/cli/options.go b/cli/options.go new file mode 100644 index 0000000..957f10b --- /dev/null +++ b/cli/options.go @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cli + +import "errors" + +// cliOpts are the available options for the assume-role CLI. +type cliOpts struct { + // args is for collecting the remaidner arguments (that are not part of + // assume-role's options). We stop parsing on first unknown option and then + // collect the remaining args because they will be executed. + args []string + + // role is the role name or ARN that the user wants to assume + role string + + // roleSessionName overrides the default session name + roleSessionName string +} + +// argumentList is a special slice of strings that includes helpers for +// processing. +type argumentList []string + +// used both here and in tests +var errNoRole = errors.New("Missing required argument: --role") + +// Next returns the arg from the beginning of the argument list and +// removes it from the list. +func (a *argumentList) Next() string { + s := *a + + if len(s) == 0 { + return "" + } + + // shift / mutate slice + next, newList := s[0], s[1:] + *a = newList + + return next +} + +func parseOptions(args argumentList) (*cliOpts, error) { + opts := &cliOpts{} + +ArgsLoop: + for len(args) > 0 { + switch arg := args.Next(); arg { + + case "--role": + opts.role = args.Next() + + case "--role-session-name": + opts.roleSessionName = args.Next() + + case "--": + // Stop parsing and add remaining args to opts.args + opts.args = append(opts.args, args...) + break ArgsLoop + + default: + // Stop parsing and add this arg + remaining args to opts.args + opts.args = append(opts.args, arg) + opts.args = append(opts.args, args...) + break ArgsLoop + } + } + + if opts.role == "" { + return opts, errNoRole + } + + return opts, nil +} diff --git a/cli/options_test.go b/cli/options_test.go new file mode 100644 index 0000000..249ce30 --- /dev/null +++ b/cli/options_test.go @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package cli + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +const testRole = "arn:aws:iam::675470192105:role/test_assume-role" + +func TestParseOptionsCommonCase(t *testing.T) { + cliOpts, err := parseOptions([]string{"--role", testRole, "ls", "-l"}) + assert.NoError(t, err) + assert.Equal(t, testRole, cliOpts.role) + assert.Equal(t, "", cliOpts.roleSessionName) + assert.Equal(t, []string{"ls", "-l"}, cliOpts.args) +} + +func TestParseOptionsSessionName(t *testing.T) { + cliOpts, err := parseOptions([]string{"--role", testRole, "--role-session-name", "test-session-name", "ls", "-l"}) + assert.NoError(t, err) + assert.Equal(t, testRole, cliOpts.role) + assert.Equal(t, "test-session-name", cliOpts.roleSessionName) + assert.Equal(t, []string{"ls", "-l"}, cliOpts.args) +} + +func TestParseOptionsDoubleDash(t *testing.T) { + cliOpts, err := parseOptions([]string{"--role", testRole, "--", "ls", "-l"}) + assert.NoError(t, err) + assert.Equal(t, testRole, cliOpts.role) + assert.Equal(t, "", cliOpts.roleSessionName) + assert.Equal(t, []string{"ls", "-l"}, cliOpts.args) +} + +func TestParseOptionsNoRole(t *testing.T) { + _, err := parseOptions([]string{"ls", "-l"}) + assert.Error(t, err) + assert.Equal(t, errNoRole, err) +} diff --git a/clock.go b/clock.go new file mode 100644 index 0000000..e81afb1 --- /dev/null +++ b/clock.go @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole + +import ( + "time" +) + +// Clock is the clock interface we expect. +type Clock interface { + Now() time.Time +} + +type defaultClock struct{} + +func (c *defaultClock) Now() time.Time { + return time.Now() +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..d3a731d --- /dev/null +++ b/config.go @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole + +import ( + "io/ioutil" + "time" + + "github.com/ghodss/yaml" +) + +// Config is the config for the AssumeRole app. +type Config struct { + // RefreshBeforeExpiry is a duration prior to the credentials expiring + // where we'll refresh them anyway. This is to prevent a command running + // just before credentials are about to expire. Defaults to 15m. + RefreshBeforeExpiry time.Duration `json:"refresh_before_expiry"` + + // RolePrefix allows the user to specify a prefix for the role ARN that + // will be combined with what is specified as the role when executing the + // app. For example, if the prefix is "arn:aws:iam::123:role/" and the user + // executes the app with role "foobar", the final ARN will become: + // "arn:aws:iam::123:role/foobar". + RolePrefix string `json:"role_prefix"` + + // ProfileNamePrefix is a prefix that will prepended to the role name to + // create the profile name under which the AWS configuration will be saved. + ProfileNamePrefix string `json:"profile_name_prefix"` +} + +// SetDefaults sets any default values for unset variables. +func (c *Config) setDefaults() { + if c.RefreshBeforeExpiry == 0 { + c.RefreshBeforeExpiry = time.Minute * 15 + } +} + +// LoadConfig reads config values from a file and returns the config. +func LoadConfig(configFilePath string) (*Config, error) { + var config Config + + b, err := ioutil.ReadFile(configFilePath) + if err != nil { + return nil, err + } + + err = yaml.Unmarshal(b, &config) + if err != nil { + return nil, err + } + + return &config, nil +} diff --git a/fixtures/test-awsconfig/config b/fixtures/test-awsconfig/config new file mode 100644 index 0000000..f230ce2 --- /dev/null +++ b/fixtures/test-awsconfig/config @@ -0,0 +1,5 @@ +[profile foo-test] +expiration = 2018-04-23T13:45:43Z +mfa_serial = arn:aws:iam::123:mfa/bob +role_arn = arn:aws:iam::123:role/admin +source_profile = default diff --git a/fixtures/test-awsconfig/credentials b/fixtures/test-awsconfig/credentials new file mode 100644 index 0000000..ec06d42 --- /dev/null +++ b/fixtures/test-awsconfig/credentials @@ -0,0 +1,3 @@ +[default] +aws_access_key_id = ABC +aws_secret_access_key = xxx diff --git a/fixtures/test-config-roleprefix/assume-role.yaml b/fixtures/test-config-roleprefix/assume-role.yaml new file mode 100644 index 0000000..501cbb6 --- /dev/null +++ b/fixtures/test-config-roleprefix/assume-role.yaml @@ -0,0 +1,2 @@ +profile_name_prefix: foobar +role_prefix: arn:aws:iam::000000000000:role/ diff --git a/fixtures/test-config-roleprefix/credentials b/fixtures/test-config-roleprefix/credentials new file mode 100644 index 0000000..8afe4e0 --- /dev/null +++ b/fixtures/test-config-roleprefix/credentials @@ -0,0 +1,8 @@ +[default] +aws_access_key_id = ABC +aws_secret_access_key = xxx + +[foo-test] +aws_access_key_id = DEF +aws_secret_access_key = yyy +aws_session_token = sss diff --git a/fixtures/test-getcredentials/config b/fixtures/test-getcredentials/config new file mode 100644 index 0000000..f230ce2 --- /dev/null +++ b/fixtures/test-getcredentials/config @@ -0,0 +1,5 @@ +[profile foo-test] +expiration = 2018-04-23T13:45:43Z +mfa_serial = arn:aws:iam::123:mfa/bob +role_arn = arn:aws:iam::123:role/admin +source_profile = default diff --git a/fixtures/test-getcredentials/credentials b/fixtures/test-getcredentials/credentials new file mode 100644 index 0000000..8afe4e0 --- /dev/null +++ b/fixtures/test-getcredentials/credentials @@ -0,0 +1,8 @@ +[default] +aws_access_key_id = ABC +aws_secret_access_key = xxx + +[foo-test] +aws_access_key_id = DEF +aws_secret_access_key = yyy +aws_session_token = sss diff --git a/mocks/aws_mocks.go b/mocks/aws_mocks.go new file mode 100644 index 0000000..bafd5cf --- /dev/null +++ b/mocks/aws_mocks.go @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/uber/assume-role-cli (interfaces: AWSProvider,AWSConfigProvider) + +// Package mocks is a generated GoMock package. +package mocks + +import ( + gomock "github.com/golang/mock/gomock" + assumerole_cli "github.com/uber/assume-role-cli" + reflect "reflect" +) + +// MockAWSProvider is a mock of AWSProvider interface +type MockAWSProvider struct { + ctrl *gomock.Controller + recorder *MockAWSProviderMockRecorder +} + +// MockAWSProviderMockRecorder is the mock recorder for MockAWSProvider +type MockAWSProviderMockRecorder struct { + mock *MockAWSProvider +} + +// NewMockAWSProvider creates a new mock instance +func NewMockAWSProvider(ctrl *gomock.Controller) *MockAWSProvider { + mock := &MockAWSProvider{ctrl: ctrl} + mock.recorder = &MockAWSProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockAWSProvider) EXPECT() *MockAWSProviderMockRecorder { + return m.recorder +} + +// AssumeRole mocks base method +func (m *MockAWSProvider) AssumeRole(arg0, arg1 string) (*assumerole_cli.TemporaryCredentials, error) { + ret := m.ctrl.Call(m, "AssumeRole", arg0, arg1) + ret0, _ := ret[0].(*assumerole_cli.TemporaryCredentials) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AssumeRole indicates an expected call of AssumeRole +func (mr *MockAWSProviderMockRecorder) AssumeRole(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssumeRole", reflect.TypeOf((*MockAWSProvider)(nil).AssumeRole), arg0, arg1) +} + +// AssumeRoleWithMFA mocks base method +func (m *MockAWSProvider) AssumeRoleWithMFA(arg0, arg1, arg2, arg3 string) (*assumerole_cli.TemporaryCredentials, error) { + ret := m.ctrl.Call(m, "AssumeRoleWithMFA", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(*assumerole_cli.TemporaryCredentials) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// AssumeRoleWithMFA indicates an expected call of AssumeRoleWithMFA +func (mr *MockAWSProviderMockRecorder) AssumeRoleWithMFA(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AssumeRoleWithMFA", reflect.TypeOf((*MockAWSProvider)(nil).AssumeRoleWithMFA), arg0, arg1, arg2, arg3) +} + +// CurrentPrincipalARN mocks base method +func (m *MockAWSProvider) CurrentPrincipalARN() (string, error) { + ret := m.ctrl.Call(m, "CurrentPrincipalARN") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// CurrentPrincipalARN indicates an expected call of CurrentPrincipalARN +func (mr *MockAWSProviderMockRecorder) CurrentPrincipalARN() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurrentPrincipalARN", reflect.TypeOf((*MockAWSProvider)(nil).CurrentPrincipalARN)) +} + +// MFADevices mocks base method +func (m *MockAWSProvider) MFADevices() ([]string, error) { + ret := m.ctrl.Call(m, "MFADevices") + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// MFADevices indicates an expected call of MFADevices +func (mr *MockAWSProviderMockRecorder) MFADevices() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MFADevices", reflect.TypeOf((*MockAWSProvider)(nil).MFADevices)) +} + +// Username mocks base method +func (m *MockAWSProvider) Username() (string, error) { + ret := m.ctrl.Call(m, "Username") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Username indicates an expected call of Username +func (mr *MockAWSProviderMockRecorder) Username() *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Username", reflect.TypeOf((*MockAWSProvider)(nil).Username)) +} + +// MockAWSConfigProvider is a mock of AWSConfigProvider interface +type MockAWSConfigProvider struct { + ctrl *gomock.Controller + recorder *MockAWSConfigProviderMockRecorder +} + +// MockAWSConfigProviderMockRecorder is the mock recorder for MockAWSConfigProvider +type MockAWSConfigProviderMockRecorder struct { + mock *MockAWSConfigProvider +} + +// NewMockAWSConfigProvider creates a new mock instance +func NewMockAWSConfigProvider(ctrl *gomock.Controller) *MockAWSConfigProvider { + mock := &MockAWSConfigProvider{ctrl: ctrl} + mock.recorder = &MockAWSConfigProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use +func (m *MockAWSConfigProvider) EXPECT() *MockAWSConfigProviderMockRecorder { + return m.recorder +} + +// GetCredentials mocks base method +func (m *MockAWSConfigProvider) GetCredentials(arg0 string) (*assumerole_cli.TemporaryCredentials, error) { + ret := m.ctrl.Call(m, "GetCredentials", arg0) + ret0, _ := ret[0].(*assumerole_cli.TemporaryCredentials) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCredentials indicates an expected call of GetCredentials +func (mr *MockAWSConfigProviderMockRecorder) GetCredentials(arg0 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCredentials", reflect.TypeOf((*MockAWSConfigProvider)(nil).GetCredentials), arg0) +} + +// GetProfile mocks base method +func (m *MockAWSConfigProvider) GetProfile(arg0 string) (*assumerole_cli.ProfileConfiguration, error) { + ret := m.ctrl.Call(m, "GetProfile", arg0) + ret0, _ := ret[0].(*assumerole_cli.ProfileConfiguration) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetProfile indicates an expected call of GetProfile +func (mr *MockAWSConfigProviderMockRecorder) GetProfile(arg0 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProfile", reflect.TypeOf((*MockAWSConfigProvider)(nil).GetProfile), arg0) +} + +// SetCredentials mocks base method +func (m *MockAWSConfigProvider) SetCredentials(arg0 string, arg1 *assumerole_cli.TemporaryCredentials) error { + ret := m.ctrl.Call(m, "SetCredentials", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetCredentials indicates an expected call of SetCredentials +func (mr *MockAWSConfigProviderMockRecorder) SetCredentials(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetCredentials", reflect.TypeOf((*MockAWSConfigProvider)(nil).SetCredentials), arg0, arg1) +} + +// SetProfile mocks base method +func (m *MockAWSConfigProvider) SetProfile(arg0 string, arg1 *assumerole_cli.ProfileConfiguration) error { + ret := m.ctrl.Call(m, "SetProfile", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// SetProfile indicates an expected call of SetProfile +func (mr *MockAWSConfigProviderMockRecorder) SetProfile(arg0, arg1 interface{}) *gomock.Call { + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetProfile", reflect.TypeOf((*MockAWSConfigProvider)(nil).SetProfile), arg0, arg1) +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..44e405b --- /dev/null +++ b/options.go @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole + +import ( + "io" + + multierror "github.com/hashicorp/go-multierror" +) + +// Option is an option for the App that allows for changing of options or +// dependency injection for testing. +type Option func(*App) error + +func (app *App) applyOptions(opts ...Option) (errs error) { + for _, opt := range opts { + if err := opt(app); err != nil { + errs = multierror.Append(errs, err) + } + } + return errs +} + +// WithAWS allows you to pass a custom AWSProvider for talking to AWS. +func WithAWS(aws AWSProvider) Option { + return func(app *App) error { + app.aws = aws + return nil + } +} + +// WithAWSConfig allows you to pass a custom AWSConfigProvider, which stores +// config and credentials for talking to AWS. +func WithAWSConfig(awsConfig AWSConfigProvider) Option { + return func(app *App) error { + app.awsConfig = awsConfig + return nil + } +} + +// WithClock allows you to specify a custom clock implementation (for tests). +func WithClock(clock Clock) Option { + return func(app *App) error { + app.clock = clock + return nil + } +} + +// WithConfig allows you to customise the configuration for the AssumeRole app +// itself. +func WithConfig(config *Config) Option { + return func(app *App) error { + app.config = *config + return nil + } +} + +// WithStderr allows you to pass a custom stderr. +func WithStderr(stderr io.Writer) Option { + return func(app *App) error { + app.stderr = stderr + return nil + } +} + +// WithStdin allows you to pass a custom stdin. +func WithStdin(stdin io.Reader) Option { + return func(app *App) error { + app.stdin = stdin + return nil + } +} diff --git a/utils.go b/utils.go new file mode 100644 index 0000000..75c3fa6 --- /dev/null +++ b/utils.go @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018 Uber Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package assumerole + +import ( + "bufio" + "os" + "strings" + + "github.com/aws/aws-sdk-go/aws/arn" + "github.com/go-ini/ini" + "golang.org/x/crypto/ssh/terminal" +) + +func isValidARN(str string) bool { + _, err := arn.Parse(str) + return err == nil +} + +func readInput(in *bufio.Reader) (string, error) { + val, err := in.ReadString('\n') + return strings.TrimSpace(val), err +} + +func readSecretInputFromTerminal(in *os.File) (string, error) { + b, err := terminal.ReadPassword(int(in.Fd())) + return string(b), err +} + +func setIniKeyValue(section *ini.Section, key string, value string) error { + section.DeleteKey(key) + _, err := section.NewKey(key, value) + return err +}