diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..338a5b5 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.6.6 diff --git a/.travis.yml b/.travis.yml index 15ddbea..23817ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,8 @@ sudo: false language: ruby rvm: -- 2.2.4 -before_install: gem install bundler -v 1.13.3 +- 2.6.6 +before_install: gem install bundler -v 2.1.4 after_success: bundle exec codeclimate-test-reporter env: matrix: diff --git a/aws-rotate-keys.gemspec b/aws-rotate-keys.gemspec index 39449be..dc7ca5f 100644 --- a/aws-rotate-keys.gemspec +++ b/aws-rotate-keys.gemspec @@ -23,7 +23,7 @@ Gem::Specification.new do |spec| spec.add_dependency "aws-sdk", "~> 2" - spec.add_development_dependency "bundler", "~> 1.13" + spec.add_development_dependency "bundler", "~> 2.0" spec.add_development_dependency "rake", "~> 10.0" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "simplecov" diff --git a/exe/aws-rotate-keys b/exe/aws-rotate-keys index 645fc88..589e13b 100755 --- a/exe/aws-rotate-keys +++ b/exe/aws-rotate-keys @@ -1,5 +1,32 @@ #!/usr/bin/env ruby require "aws_rotate_keys" +require "optparse" -AwsRotateKeys::CLI.call +# Use some basic parsing to allow command-line overrides of config +class Parser + def self.parse(options) + output_options = { + delete_inactive: false + } + + opt_parser = OptionParser.new do |opts| + opts.banner = "Usage: #{File.basename(__FILE__)} [options]" + + opts.on("--delete-inactive", "If the key quota is full but one is inactive, delete that key to make room") do |p| + output_options[:delete_inactive] = p + end + + opts.on("-h", "--help", "Prints this help") do + puts opts + exit + end + end + + opt_parser.parse!(options) + output_options + end +end + +cli_options = (Parser.parse ARGV) +AwsRotateKeys::CLI.call(options: cli_options) diff --git a/lib/aws_rotate_keys/cli.rb b/lib/aws_rotate_keys/cli.rb index 72b4953..aaaeb16 100644 --- a/lib/aws_rotate_keys/cli.rb +++ b/lib/aws_rotate_keys/cli.rb @@ -3,6 +3,8 @@ module AwsRotateKeys class CLI + AWS_ENVIRONMENT_VARIABLES = ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].freeze + def self.call(*args) new(*args).call end @@ -12,35 +14,54 @@ def self.call(*args) def initialize(iam: Aws::IAM::Client.new, credentials_path: "#{Dir.home}/.aws/credentials", stdout: $stdout, - env: ENV) + env: ENV, + options: {}) @iam = iam @credentials_path = credentials_path @stdout = stdout @env = env + @options = options end def call + log "Reading key quota..." + quota = access_key_quota + + log "Reading existing keys..." + access_keys = aws_access_keys + + if quota <= access_keys.size + log "Key set is already at quota limit of #{quota}:" + log_keylist(access_keys) + + inactive_keys = access_keys.select { |k| k["status"] == "Inactive" } + if @options[:delete_inactive] && !inactive_keys.empty? + log "Deleting oldest inactive access key as requested..." + log_keylist(inactive_keys) + delete_oldest_access_key(inactive_keys) + else + raise "You must manually delete a key or use one of the command-line overrides" + end + end + log "Creating access key..." new_key = create_access_key + access_keys = aws_access_keys # refresh key list - create_credentials_directory_if_needed - - if credentials_file_exists? + if File.exist?(credentials_path) log "Backing up #{credentials_path} to #{credentials_backup_path}..." - backup_aws_credentials_file + FileUtils.cp(credentials_path, credentials_backup_path) end log "Writing new access key to #{credentials_path}" write_aws_credentials_file(new_key) log "Deleting your oldest access key..." - delete_oldest_access_key + delete_oldest_access_key(access_keys) - log "You're all set!" + log aws_environment_variables_warning_message if aws_environment_variables? - if aws_environment_variables? - log aws_environment_variables_warning_message - end + log "You're all set!" end private @@ -50,24 +71,14 @@ def create_access_key create_access_key_response.access_key end - def create_credentials_directory_if_needed - FileUtils.mkdir_p(credentials_dir) - end - - def credentials_file_exists? - File.exist?(credentials_path) - end - # ex. ~/aws/credentials.bkp-2017-01-06-16-38-07--0800 def credentials_backup_path credentials_path + ".bkp-#{Time.now.to_s.gsub(/[^\d]/, '-')}" end - def backup_aws_credentials_file - FileUtils.cp(credentials_path, credentials_backup_path) - end - def write_aws_credentials_file(access_key) + FileUtils.mkdir_p(File.dirname(credentials_path)) # ensure credentials directory exists + File.open(credentials_path, "w") do |f| f.puts "[default]" f.puts "aws_access_key_id = #{access_key.access_key_id}" @@ -75,16 +86,24 @@ def write_aws_credentials_file(access_key) end end - def delete_oldest_access_key + def access_key_quota + ret = @iam.get_account_summary.summary_map["AccessKeysPerUserQuota"] + end + + def aws_access_keys list_access_keys_response = iam.list_access_keys - access_keys = list_access_keys_response.access_key_metadata + list_access_keys_response.access_key_metadata + end - oldest_access_key = access_keys.sort_by(&:create_date).first + def delete_oldest_access_key(access_key_list) + oldest_access_key = access_key_list.min_by(&:create_date) iam.delete_access_key(access_key_id: oldest_access_key.access_key_id) end - def credentials_dir - File.dirname(credentials_path) + def log_keylist(access_keys) + access_keys.each do |k| + log " #{k['create_date']} #{k['access_key_id']} #{k['status']}" + end end def log(msg) @@ -92,11 +111,11 @@ def log(msg) end def aws_environment_variables? - env['AWS_ACCESS_KEY_ID'] || env['AWS_SECRET_ACCESS_KEY'] + AWS_ENVIRONMENT_VARIABLES.any? { |v| env.key?(v) } end def aws_environment_variables_warning_message - "We've noticed that the environment variables AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are set.\n" + + "We've noticed that the environment variables #{AWS_ENVIRONMENT_VARIABLES} are set.\n" + "Please remove them so that aws cli and libraries use #{credentials_path} instead." end end diff --git a/spec/aws_rotate_keys_spec.rb b/spec/aws_rotate_keys_spec.rb index 637088f..bd567e5 100644 --- a/spec/aws_rotate_keys_spec.rb +++ b/spec/aws_rotate_keys_spec.rb @@ -1,34 +1,50 @@ require "spec_helper" +require "myio" describe AwsRotateKeys do + OLD_KEY_ID = "OLDKEY".freeze + NEW_KEY_ID = "KEY123".freeze + NEW_SECRET = "SECRET123".freeze + class IAMDouble + def initialize + @keys = [ + Aws::IAM::Types::AccessKeyMetadata.new( + access_key_id: OLD_KEY_ID, + create_date: Time.new(2017, 1, 1) + ) + ] + end + def create_access_key + @keys << Aws::IAM::Types::AccessKeyMetadata.new( + access_key_id: NEW_KEY_ID, + create_date: Time.new(2017, 2, 1) + ) + Aws::IAM::Types::CreateAccessKeyResponse.new( access_key: Aws::IAM::Types::AccessKey.new( - access_key_id: "KEY123", - secret_access_key: "SECRET123" + access_key_id: NEW_KEY_ID, + secret_access_key: NEW_SECRET ) ) end def list_access_keys Aws::IAM::Types::ListAccessKeysResponse.new( - access_key_metadata: [ - Aws::IAM::Types::AccessKeyMetadata.new( - access_key_id: "KEY123", - create_date: Time.new(2017, 2, 1) - ), - Aws::IAM::Types::AccessKeyMetadata.new( - access_key_id: "OLDKEY", - create_date: Time.new(2017, 1, 1) - ) - ] + access_key_metadata: @keys ) end - def delete_access_key(access_key_id:) - raise "Expected to delete access key 'OLDKEY' but was #{access_key_id}" unless access_key_id == "OLDKEY" + def get_account_summary + Aws::IAM::Types::GetAccountSummaryResponse.new( + summary_map: { + "AccessKeysPerUserQuota" => 2 + } + ) end + + def delete_access_key(access_key_id:); end end let(:iam_double) { IAMDouble.new } @@ -44,7 +60,7 @@ def rotate_keys(args = {}) end before do - expect(iam_double).to receive(:delete_access_key).with(access_key_id: "OLDKEY") + expect(iam_double).to receive(:delete_access_key).with(access_key_id: OLD_KEY_ID) end context "when no credentials" do @@ -57,7 +73,7 @@ def rotate_keys(args = {}) credentials_content = File.read(credentials_path) - expect(credentials_content).to eq "[default]\naws_access_key_id = KEY123\naws_secret_access_key = SECRET123\n" + expect(credentials_content).to eq "[default]\naws_access_key_id = #{NEW_KEY_ID}\naws_secret_access_key = #{NEW_SECRET}\n" end end @@ -96,17 +112,3 @@ def rotate_keys(args = {}) end end end - -class MyIO - def initialize - @content = "" - end - - def puts(msg) - @content << msg + "\n" - end - - def to_s - @content - end -end diff --git a/spec/aws_rotate_keys_unsuccessfully_spec.rb b/spec/aws_rotate_keys_unsuccessfully_spec.rb new file mode 100644 index 0000000..1c0258b --- /dev/null +++ b/spec/aws_rotate_keys_unsuccessfully_spec.rb @@ -0,0 +1,98 @@ +require "spec_helper" +require "myio" + +describe AwsRotateKeys do + ACTIVE_KEY_ID = "ACTKEEEY".freeze + INACTIVE_KEY_ID = "INACTKEY".freeze + ANOTHER_KEY_ID = "NEWKEEEY".freeze + ANOTHER_SECRET = "SECRET123".freeze + + class IAMAnotherDouble + def initialize + @keys = [ + Aws::IAM::Types::AccessKeyMetadata.new( + access_key_id: INACTIVE_KEY_ID, + status: "Inactive", + create_date: Time.new(2017, 1, 1) + ), + Aws::IAM::Types::AccessKeyMetadata.new( + access_key_id: ACTIVE_KEY_ID, + status: "Active", + create_date: Time.new(2017, 2, 1) + ) + ] + end + + def create_access_key + @keys << Aws::IAM::Types::AccessKeyMetadata.new( + access_key_id: ANOTHER_KEY_ID, + status: "Active", + create_date: Time.new(2017, 3, 1) + ) + + Aws::IAM::Types::CreateAccessKeyResponse.new( + access_key: Aws::IAM::Types::AccessKey.new( + access_key_id: ANOTHER_KEY_ID, + secret_access_key: ANOTHER_SECRET + ) + ) + end + + def list_access_keys + Aws::IAM::Types::ListAccessKeysResponse.new( + access_key_metadata: @keys + ) + end + + def get_account_summary + Aws::IAM::Types::GetAccountSummaryResponse.new( + summary_map: { + "AccessKeysPerUserQuota" => 2 + } + ) + end + + def delete_access_key(access_key_id:) + @keys.reject! { |k| k.access_key_id == access_key_id } + end + end + + let(:iam_double) { IAMAnotherDouble.new } + let(:credentials_path) { "./spec/tmp/aws/credentials" } + + def rotate_keys(args = {}) + AwsRotateKeys::CLI.call( + { + iam: iam_double, + credentials_path: credentials_path + }.merge(args) + ) + end + + context "when at quota and no override" do + it "raises an error" do + stdout = MyIO.new + expected_err = "You must manually delete a key or use one of the command-line overrides" + expect { rotate_keys(stdout: stdout) }.to raise_error(RuntimeError, expected_err) + expect(stdout.to_s).to include "Key set is already at quota limit" + end + end + + context "when at quota with override" do + before do + expect(iam_double).to receive(:delete_access_key).with(access_key_id: INACTIVE_KEY_ID).and_call_original + expect(iam_double).to receive(:delete_access_key).with(access_key_id: ACTIVE_KEY_ID).and_call_original + FileUtils.touch(credentials_path) + end + + it "deletes both the inactive key and the active key" do + credentials_dir = File.dirname(credentials_path) + credentials = Dir["#{credentials_dir}/*"] + stdout = MyIO.new + rotate_keys(stdout: stdout, options: { delete_inactive: true }) + expect(stdout.to_s).to include "Key set is already at quota limit" + expect(stdout.to_s).to include "Deleting oldest inactive access key as requested" + end + end + +end diff --git a/spec/myio.rb b/spec/myio.rb new file mode 100644 index 0000000..b30b3e2 --- /dev/null +++ b/spec/myio.rb @@ -0,0 +1,13 @@ +class MyIO + def initialize + @content = "" + end + + def puts(msg) + @content << msg + "\n" + end + + def to_s + @content + end +end