wdi_runas 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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