wdi_runas 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,61 @@
1
+ # Copyright 2015 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'inifile'
16
+
17
+ module AwsRunAs
18
+ # Manages the configuartion file, including loading and retrieving values.
19
+ class Config
20
+ attr_reader :profile
21
+ # Finds the configuration file (used if no file is specified).
22
+ # paths searched: ./aws_config, and ~/.aws/config.
23
+ def self.find_config_file
24
+ local_config = File.expand_path('aws_config')
25
+ user_config = File.expand_path('~/.aws/config')
26
+ return local_config if File.exist?(local_config)
27
+ user_config if File.exist?(user_config)
28
+ end
29
+
30
+ def initialize(path:, profile:)
31
+ @path = path
32
+ @path = self.class.find_config_file unless @path
33
+ fail(Errno::ENOENT, "#{@path}") unless File.exist?(@path.to_s)
34
+ @profile = profile
35
+ end
36
+
37
+ # Loads the config section for a specific profile.
38
+ def load_config_value(key:)
39
+ section = @profile
40
+ section = "profile #{@profile}" unless @profile == 'default'
41
+ aws_config = IniFile.load(@path)
42
+ unless aws_config.has_section?(section)
43
+ fail(NameError, "Profile #{@profile} not found in #{@path}")
44
+ end
45
+ aws_config[section][key]
46
+ end
47
+
48
+ # Checks to see if MFA is required for a specific profile.
49
+ def mfa_required?
50
+ return true if load_config_value(key: 'mfa_serial') && !ENV.include?('AWS_SESSION_TOKEN')
51
+ false
52
+ end
53
+
54
+ # loads the soruce credentials profile based on the supplied profile.
55
+ def load_source_profile
56
+ source_profile = load_config_value(key: 'source_profile')
57
+ return source_profile if source_profile
58
+ @profile
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,104 @@
1
+ # Copyright 2015 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'aws_runas/config'
16
+ require 'aws_runas/utils'
17
+
18
+ # AWS_SDK_CONFIG_OPT_OUT must be set here so that we use the pre-2.4 SDK
19
+ # behaviour, which ensures that ~/.aws/config is not re-read when assuming
20
+ # roles.
21
+ ENV.store('AWS_SDK_CONFIG_OPT_OUT', '1')
22
+ require 'aws-sdk'
23
+
24
+ module AwsRunAs
25
+ # Main program logic for aws-runas - sets up sts asession and assumed role,
26
+ # and hands off environment to called process.
27
+ class Main
28
+ # Instantiate the object and set up the path, profile, and populate MFA
29
+ def initialize(path: nil, profile: default, mfa_code: nil, no_role: nil)
30
+ cfg_path = if path
31
+ path
32
+ else
33
+ AwsRunAs::Config.find_config_file
34
+ end
35
+ @cfg = AwsRunAs::Config.new(path: cfg_path, profile: profile)
36
+ @mfa_code = mfa_code
37
+ @no_role = no_role
38
+ end
39
+
40
+ def sts_client
41
+ region = @cfg.load_config_value(key: 'region')
42
+ region = 'us-east-1' unless region
43
+ Aws::STS::Client.new(
44
+ profile: @cfg.load_source_profile,
45
+ region: region
46
+ )
47
+ end
48
+
49
+ def assume_role
50
+ session_id = "aws-runas-session_#{Time.now.to_i}"
51
+ role_arn = @cfg.load_config_value(key: 'role_arn')
52
+ mfa_serial = @cfg.load_config_value(key: 'mfa_serial') unless ENV.include?('AWS_SESSION_TOKEN')
53
+ if @no_role
54
+ raise 'No mfa_serial in selected profile, session will be useless' if mfa_serial.nil?
55
+ @session = sts_client.get_session_token(
56
+ duration_seconds: 86400,
57
+ serial_number: mfa_serial,
58
+ token_code: @mfa_code
59
+ )
60
+ else
61
+ @session = Aws::AssumeRoleCredentials.new(
62
+ client: sts_client,
63
+ role_arn: role_arn,
64
+ serial_number: mfa_serial,
65
+ token_code: @mfa_code,
66
+ role_session_name: session_id
67
+ )
68
+ end
69
+ end
70
+
71
+ def session_credentials
72
+ @session.credentials
73
+ end
74
+
75
+ def credentials_env
76
+ env = {}
77
+ env['AWS_ACCESS_KEY_ID'] = session_credentials.access_key_id
78
+ env['AWS_SECRET_ACCESS_KEY'] = session_credentials.secret_access_key
79
+ env['AWS_SESSION_TOKEN'] = session_credentials.session_token
80
+ env['AWS_RUNAS_PROFILE'] = @cfg.profile
81
+ unless @cfg.load_config_value(key: 'region').nil?
82
+ env['AWS_REGION'] = @cfg.load_config_value(key: 'region')
83
+ env['AWS_DEFAULT_REGION'] = @cfg.load_config_value(key: 'region')
84
+ end
85
+ if @no_role
86
+ env['AWS_SESSION_EXPIRATION'] = session_credentials.expiration.to_s
87
+ env['AWS_SESSION_EXPIRATION_UNIX'] = DateTime.parse(session_credentials.expiration.to_s).strftime('%s')
88
+ else
89
+ env['AWS_SESSION_EXPIRATION'] = @session.expiration.to_s
90
+ env['AWS_SESSION_EXPIRATION_UNIX'] = DateTime.parse(@session.expiration.to_s).strftime('%s')
91
+ env['AWS_RUNAS_ASSUMED_ROLE_ARN'] = @cfg.load_config_value(key: 'role_arn')
92
+ end
93
+ env
94
+ end
95
+
96
+ def handoff(command: nil, argv: nil, skip_prompt:)
97
+ env = credentials_env
98
+ unless command
99
+ AwsRunAs::Utils.handoff_to_shell(env: env, profile: @no_role ? nil : @cfg.profile, skip_prompt: skip_prompt)
100
+ end
101
+ exec(env, command, *argv)
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,106 @@
1
+ # Copyright 2016 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'rbconfig'
16
+ require 'tempfile'
17
+ require 'tmpdir'
18
+ require 'fileutils'
19
+ require 'English'
20
+
21
+ module AwsRunAs
22
+ # Utility functions that aren't specifically tied to a class.
23
+ module Utils
24
+ module_function
25
+
26
+ # Return the path to the shell_profiles directory vendored with the gem.
27
+ def shell_profiles_dir
28
+ File.expand_path('../../../shell_profiles', __FILE__)
29
+ end
30
+
31
+ # Run an interactive bash session with a special streamed RC file. The RC
32
+ # merges a local .bashrc if it exists, with a prompt that includes the
33
+ # computed message from handoff_to_shell.
34
+ def handoff_bash(env:, path:, message:, skip_prompt:)
35
+ rc_data = IO.read("#{ENV['HOME']}/.bashrc") if File.exist?("#{ENV['HOME']}/.bashrc")
36
+ rc_file = Tempfile.new('aws_runas_bashrc')
37
+ rc_file.write("#{rc_data}\n") unless rc_data.nil?
38
+ rc_file.write(IO.read("#{shell_profiles_dir}/sh.profile"))
39
+ unless skip_prompt
40
+ rc_file.write("PS1=\"\\[\\e[\\$(aws_session_status_color)m\\](#{message})\\[\\e[0m\\] $PS1\"\n")
41
+ end
42
+ rc_file.close
43
+ system(env, path, '--rcfile', rc_file.path)
44
+ ensure
45
+ rc_file.unlink
46
+ end
47
+
48
+ # Run an interactive zsh session with a special streamed RC file. The RC
49
+ # merges a local .zshrc if it exists, with a prompt that includes the
50
+ # computed message from handoff_to_shell.
51
+ def handoff_zsh(env:, path:, message:, skip_prompt:)
52
+ rc_data = IO.read("#{ENV['HOME']}/.zshrc") if File.exist?("#{ENV['HOME']}/.zshrc")
53
+ rc_dir = Dir.mktmpdir('aws_runas_zsh')
54
+ rc_file = File.new("#{rc_dir}/.zshrc", 'w')
55
+ rc_file.write("#{rc_data}\n") unless rc_data.nil?
56
+ rc_file.write(IO.read("#{shell_profiles_dir}/sh.profile"))
57
+ unless skip_prompt
58
+ rc_file.write("setopt PROMPT_SUBST\n")
59
+ rc_file.write("export OLDPROMPT=\"${PROMPT}\"\n")
60
+ rc_file.write("PROMPT=$'%{\\e[\\%}$(aws_session_status_color)m(#{message})%{\\e[0m%} $OLDPROMPT'\n")
61
+ end
62
+ rc_file.close
63
+ env.store('ZDOTDIR', rc_dir)
64
+ system(env, path)
65
+ ensure
66
+ FileUtils.rmtree(rc_dir)
67
+ end
68
+
69
+ # load the shell for a specific operating system.
70
+ # if $SHELL exists, load that.
71
+ def shell
72
+ if RbConfig::CONFIG['host_os'] =~ /mswin|windows|mingw32/i
73
+ 'cmd.exe'
74
+ elsif ENV.include?('SHELL')
75
+ ENV['SHELL']
76
+ else
77
+ '/bin/sh'
78
+ end
79
+ end
80
+
81
+ # Compute the message given to the prompt based off supplied profile.
82
+ def compute_message(profile:)
83
+ if profile.nil?
84
+ 'AWS'
85
+ else
86
+ "AWS:#{profile}"
87
+ end
88
+ end
89
+
90
+ # "Handoff" to a supported interactive shell. More technically, this runs
91
+ # an interactive shell with the shell prompt customized to the current
92
+ # running AWS profile. If the shell is not something we can handle
93
+ # specifically, just run the shell.
94
+ def handoff_to_shell(env:, profile: nil, skip_prompt:)
95
+ path = shell
96
+ if path.end_with?('/bash')
97
+ handoff_bash(env: env, path: path, message: compute_message(profile: profile), skip_prompt: skip_prompt)
98
+ elsif path.end_with?('/zsh')
99
+ handoff_zsh(env: env, path: path, message: compute_message(profile: profile), skip_prompt: skip_prompt)
100
+ else
101
+ system(env, path)
102
+ end
103
+ exit $CHILD_STATUS.exitstatus
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,17 @@
1
+ # Copyright 2015 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ module AwsRunAs
16
+ VERSION = '0.5.1'
17
+ end
data/lib/aws_runas.rb ADDED
@@ -0,0 +1,15 @@
1
+ # Copyright 2015 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'aws_runas/main'
@@ -0,0 +1,23 @@
1
+ # vim:filetype=sh
2
+ #
3
+ # aws_session_expired checks to see if the current session has expired, based
4
+ # off of the value stored in AWS_SESSION_EXPIRATION_UNIX. This functionality
5
+ # relies on date being in $PATH.
6
+ aws_session_expired() {
7
+ if [[ "${AWS_SESSION_EXPIRATION_UNIX}" -lt "$(date +%s)" ]]; then
8
+ return 0
9
+ fi
10
+ return 1
11
+ }
12
+
13
+ # aws_session_status_color returns an ANSI color number for the specific status
14
+ # of the session. Note that if session_expired is not correctly functioning,
15
+ # this will always be yellow. Red is shown when it's verified that the session
16
+ # has expired.
17
+ aws_session_status_color() {
18
+ if aws_session_expired; then
19
+ echo "31"
20
+ else
21
+ echo "33"
22
+ fi
23
+ }
@@ -0,0 +1,59 @@
1
+ # Copyright 2015 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'spec_helper'
16
+ require 'aws_runas/cli'
17
+ require 'aws_runas/main'
18
+
19
+ describe AwsRunAs::Cli do
20
+ describe '::load_opts' do
21
+ it 'loads the path option' do
22
+ opts = AwsRunAs::Cli.load_opts(args: ['--path', 'test-opts/aws_config'])
23
+ expect(opts[:path]).to eq('test-opts/aws_config')
24
+ end
25
+
26
+ it 'loads the profile option' do
27
+ opts = AwsRunAs::Cli.load_opts(args: ['--profile', 'test-profile'])
28
+ expect(opts[:profile]).to eq('test-profile')
29
+ end
30
+ end
31
+
32
+ describe '::start' do
33
+ before(:example) do
34
+ allow(AwsRunAs::Cli).to receive(:load_opts).and_return({})
35
+ allow(AwsRunAs::Cli).to receive(:read_mfa_if_needed)
36
+ allow(AwsRunAs::Main).to receive(:new).and_return double(
37
+ 'AwsRunAs::Main',
38
+ assume_role: true,
39
+ handoff: true
40
+ )
41
+ end
42
+
43
+ it 'creates an AwsConfig::Main instance' do
44
+ expect(AwsRunAs::Main).to receive(:new)
45
+ AwsRunAs::Cli.start
46
+ end
47
+ end
48
+
49
+ describe '::read_mfa_if_needed' do
50
+ it 'reads the MFA code' do
51
+ allow(STDIN).to receive(:gets).and_return('123456')
52
+ mfa_code = AwsRunAs::Cli.read_mfa_if_needed(
53
+ path: MOCK_AWS_CONFIGPATH,
54
+ profile: 'test-profile'
55
+ )
56
+ expect(mfa_code).to eq('123456')
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,114 @@
1
+ # Copyright 2015 Chris Marchesi
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ require 'spec_helper'
16
+ require 'aws_runas/config'
17
+
18
+ describe AwsRunAs::Config do
19
+ describe '::find_config_file' do
20
+ before(:example) do
21
+ allow(File).to receive(:expand_path).with('aws_config').and_return('./aws_config')
22
+ allow(File).to receive(:expand_path).with('~/.aws/config').and_return('./.aws/config')
23
+ allow(File).to receive(:exist?).with('./.aws/config').and_return true
24
+ end
25
+
26
+ it 'finds a file at ./aws_config' do
27
+ allow(File).to receive(:exist?).with('./aws_config').and_return true
28
+ expect(AwsRunAs::Config.find_config_file).to eq('./aws_config')
29
+ end
30
+ it 'finds a file at ~/.aws/config' do
31
+ allow(File).to receive(:exist?).with('./aws_config').and_return false
32
+ expect(AwsRunAs::Config.find_config_file).to eq('./.aws/config')
33
+ end
34
+ end
35
+
36
+ context 'with profile set to default' do
37
+ before(:context) do
38
+ @cfg = AwsRunAs::Config.new(path: MOCK_AWS_CONFIGPATH, profile: 'default')
39
+ end
40
+
41
+ describe '#load_config_value' do
42
+ it 'loads a value from the default profile' do
43
+ expect(@cfg.load_config_value(key: 'region')).to eq('us-east-1')
44
+ end
45
+ end
46
+
47
+ describe '#load_source_profile' do
48
+ it 'returns the default profile when no source profile is present' do
49
+ expect(@cfg.load_source_profile).to eq('default')
50
+ end
51
+ end
52
+ end
53
+
54
+ context 'with profile set to test-profile' do
55
+ before(:context) do
56
+ @cfg = AwsRunAs::Config.new(path: MOCK_AWS_CONFIGPATH, profile: 'test-profile')
57
+ end
58
+
59
+ describe '#initialize' do
60
+ it 'sets the profile correctly' do
61
+ expect(@cfg.instance_variable_get('@profile')).to eq('test-profile')
62
+ end
63
+ end
64
+
65
+ describe '#load_config_value' do
66
+ it 'loads a value from the non-default profile' do
67
+ expect(@cfg.load_config_value(key: 'mfa_serial')).to eq('arn:aws:iam::123456789012:mfa/test')
68
+ end
69
+ end
70
+
71
+ describe '#mfa_required' do
72
+ it 'confirms MFA is required the non-default profile' do
73
+ expect(@cfg.instance_variable_get('@profile')).to eq('test-profile')
74
+ expect(@cfg.mfa_required?).to be true
75
+ end
76
+
77
+ it 'confirms MFA is not required if AWS_SESSION_TOKEN is set' do
78
+ expect(@cfg.instance_variable_get('@profile')).to eq('test-profile')
79
+ expect(@cfg.mfa_required?).to be true
80
+ ENV.store('AWS_SESSION_TOKEN', 'foo')
81
+ end
82
+ end
83
+
84
+ describe '#load_source_profile' do
85
+ it 'loads the source credentials profile for the the non-default profile' do
86
+ expect(@cfg.load_source_profile).to eq('test-credentials')
87
+ end
88
+ end
89
+ end
90
+
91
+ context 'with profile set to an invalid profile' do
92
+ before(:context) do
93
+ @cfg = AwsRunAs::Config.new(path: MOCK_AWS_CONFIGPATH, profile: 'bad-profile')
94
+ end
95
+
96
+ describe '#load_config_value' do
97
+ it 'raises a NameError when a value load is attempted' do
98
+ expect { @cfg.load_config_value(key: 'region') }.to raise_error(NameError)
99
+ end
100
+ end
101
+ end
102
+
103
+ context 'with an invalid config file supplied' do
104
+ describe '#load_config_value' do
105
+ it 'raises a Errno::ENOENT error' do
106
+ expect do
107
+ AwsRunAs::Config.new(
108
+ path: '/bad/path/here', profile: 'default'
109
+ )
110
+ end.to raise_error(Errno::ENOENT)
111
+ end
112
+ end
113
+ end
114
+ end