cerberus_client 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,200 @@
1
+ module Cerberus
2
+
3
+ require_relative('../cerberus_client/log')
4
+ require_relative('../cerberus_client/http')
5
+ require_relative('../cerberus_client')
6
+ require_relative('exception/no_value_error')
7
+ require_relative('exception/http_error')
8
+ require_relative('cerberus_client_token')
9
+ require_relative('aws_role_info')
10
+ require('aws-sdk')
11
+ require('net/http')
12
+ require('json')
13
+ require('base64')
14
+
15
+ ##
16
+ # The AWS IAM role credentials provider
17
+ ##
18
+ class AwsRoleCredentialsProvider
19
+
20
+ # AWS metadata instance URL
21
+ INSTANCE_METADATA_SVC_BASE_URL = "http://169.254.169.254/latest/meta-data"
22
+ # relative URI to look up AZ in AWS metadata svc
23
+ REGION_REL_URI = "/placement/availability-zone"
24
+ # relative URI to look up IAM role in AWS metadata svc
25
+ IAM_ROLE_INFO_REL_URI = "/iam/info"
26
+ # reference into the metadata data json we get to look up IAM role
27
+ IAM_ROLE_ARN_KEY = 'InstanceProfileArn'
28
+ # magic number is the index into a split role ARN to grab the acccount ID
29
+ ROLE_ARN_ARRAY_INDEX_OF_ACCOUNTNUM = 4
30
+ # magic number is the index into a split role ARN to grab the role name
31
+ ROLE_ARN_ARRAY_INDEX_OF_ROLENAME = 1
32
+ # relative URI to get encrypted auth data from Cerberus
33
+ ROLE_AUTH_REL_URI = "/v1/auth/iam-role"
34
+ # reference into the decrypted auth data json we get from Cerberus
35
+ CERBERUS_AUTH_DATA_CLIENT_TOKEN_KEY = "client_token"
36
+ CERBERUS_AUTH_DATA_LEASE_DURATION_KEY = "lease_duration"
37
+ CERBERUS_AUTH_DATA_POLICIES_KEY = "policies"
38
+
39
+ LOGGER = CerberusClient::Log.instance
40
+
41
+ ##
42
+ # Init AWS role provider - needs vault base url. Instance metadata service url is optional to make unit tests
43
+ # easier and so we can provide a hook to set this via config as needed
44
+ ##
45
+ def initialize(vaultBaseUrl, instanceMdSvcBaseUrl = nil, roleName = nil, roleRegion = nil, roleAccountId = nil)
46
+ @vaultBaseUrl = vaultBaseUrl
47
+ @clientToken = nil
48
+ @role = get_role_info(instanceMdSvcBaseUrl, roleName, roleRegion, roleAccountId)
49
+
50
+ LOGGER.debug("AwsRoleCredentialsProvider initialized with vault base url #{@vaultBaseUrl}")
51
+ end
52
+
53
+ ##
54
+ # Get credentials using AWS IAM role
55
+ ##
56
+ def getClientToken
57
+
58
+ if (@clientToken == nil)
59
+ @clientToken = getCredentialsFromCerberus
60
+ end
61
+
62
+ # using two if statements here just to make the logging easier..
63
+ # the above we expect on startup, expiration is an interesting event worth a debug log all its own
64
+ if (@clientToken.expired?)
65
+ LOGGER.debug("Existing ClientToken has expired - refreshing from Cerberus...")
66
+ @clientToken = getCredentialsFromCerberus
67
+ end
68
+
69
+ return @clientToken.authToken
70
+
71
+ end
72
+
73
+ private
74
+
75
+ def get_role_info(instanceMdSvcBaseUrl, roleName, roleRegion, roleAccountId)
76
+ if (should_assume_role(roleAccountId, roleName, roleRegion))
77
+ return get_role_from_provided_info(roleName, roleRegion, roleAccountId)
78
+ else
79
+ @instanceMdSvcBaseUrl = instanceMdSvcBaseUrl.nil? ? INSTANCE_METADATA_SVC_BASE_URL : instanceMdSvcBaseUrl
80
+
81
+ # collect instance MD we need to auth with Cerberus
82
+ return get_role_from_instance_metadata
83
+ end
84
+ end
85
+
86
+ def get_role_from_provided_info(roleName, roleRegion, roleAccountId)
87
+ role_creds = Aws::AssumeRoleCredentials.new(client: Aws::STS::Client.new(region: roleRegion), role_arn: "arn:aws:iam::#{roleAccountId}:role/#{roleName}", role_session_name: "hiera-cpe-build")
88
+
89
+ return AwsRoleInfo.new(roleName, roleRegion, roleAccountId, credentials: role_creds)
90
+ end
91
+
92
+ def get_role_from_instance_metadata
93
+ role_arn = getIAMRoleARN
94
+ region = getRegionFromAZ(getAvailabilityZone)
95
+ account_id = getAccountIdFromRoleARN(role_arn)
96
+ role_name = getRoleNameFromRoleARN(role_arn)
97
+
98
+ LOGGER.debug("roleARN #{role_arn}")
99
+ LOGGER.debug("region #{region}")
100
+ LOGGER.debug("accountId #{account_id}")
101
+ LOGGER.debug("roleName #{role_name}")
102
+
103
+ return AwsRoleInfo.new(role_name, region, account_id, nil)
104
+ end
105
+
106
+ def should_assume_role(roleAccountId, roleName, roleRegion)
107
+ !(roleName.nil? || roleAccountId.nil? || roleRegion.nil?)
108
+ end
109
+
110
+ ##
111
+ # Reach out to the Cerberus management service and get an auth token
112
+ ##
113
+ def getCredentialsFromCerberus
114
+ begin
115
+ authData = doAuthWithCerberus(@role.account_id, @role.name, @role.region)
116
+
117
+ LOGGER.debug("Got auth data from Cerberus. Attempting to decrypt...")
118
+
119
+ # decrypt the data we got from cerberus to get the vault token
120
+ kms = Aws::KMS::Client.new(region: @role.region, credentials: @role.credentials[:credentials])
121
+
122
+ decryptedAuthDataJson = JSON.parse(kms.decrypt(ciphertext_blob: Base64.decode64(authData)).plaintext)
123
+
124
+ LOGGER.debug("Decrypt successful. Passing back Cerberus auth token.")
125
+ # pass back a credentials object that will allow us to reuse it until it expires
126
+ CerberusClientToken.new(decryptedAuthDataJson[CERBERUS_AUTH_DATA_CLIENT_TOKEN_KEY],
127
+ decryptedAuthDataJson[CERBERUS_AUTH_DATA_LEASE_DURATION_KEY],
128
+ decryptedAuthDataJson[CERBERUS_AUTH_DATA_POLICIES_KEY])
129
+
130
+ rescue Cerberus::Exception::HttpError
131
+ # catch http errors here and assert no value
132
+ # this may not actually be the case, there are legitimate reasons HTTP can fail when it "should" work
133
+ # but this is handled by logging - a warning is set in the log in during the HTTP call
134
+ raise Cerberus::Exception::NoValueError
135
+ end
136
+ end
137
+
138
+ ##
139
+ # Get the AWS account ID from the role ARN
140
+ # Expects formatting [some value]:[some value]:[some value]::[account id]
141
+ ##
142
+ def getAccountIdFromRoleARN(roleARN)
143
+ roleARN.split(':')[ROLE_ARN_ARRAY_INDEX_OF_ACCOUNTNUM]
144
+ end
145
+
146
+ ##
147
+ # Get the role name from the role ARN
148
+ # Expects formatting [some value]:[some value]:[some value]::[account id]
149
+ ##
150
+ def getRoleNameFromRoleARN(roleARN)
151
+ roleARN.split('/')[ROLE_ARN_ARRAY_INDEX_OF_ROLENAME]
152
+ end
153
+
154
+ ##
155
+ # Read the IAM role ARN from the instance metadata
156
+ # Will throw an HTTP exception if there is no IAM role associated with the instance
157
+ ##
158
+ def getIAMRoleARN
159
+ response = doHttpToMDService(IAM_ROLE_INFO_REL_URI)
160
+ jsonResponseBody = JSON.parse(response.body)
161
+ jsonResponseBody[IAM_ROLE_ARN_KEY]
162
+ end
163
+
164
+ ##
165
+ # Get the region from AWS instance metadata
166
+ ##
167
+ def getAvailabilityZone
168
+ doHttpToMDService(REGION_REL_URI).body
169
+ end
170
+
171
+ ##
172
+ # Get region from AZ
173
+ ##
174
+ def getRegionFromAZ(az)
175
+ az[0, az.length-1]
176
+ end
177
+
178
+ ##
179
+ # Call the instance metadata service with a relative URI and return the response if the call succeeds
180
+ # else throw an IOError for non-2xx responses and RuntimeError for any exceptions down the stack
181
+ ##
182
+ def doHttpToMDService(relUri)
183
+ url = URI(@instanceMdSvcBaseUrl + relUri)
184
+ CerberusClient::Http.new.doHttp(url, 'GET', false)
185
+ end
186
+
187
+ ##
188
+ #
189
+ ##
190
+ def doAuthWithCerberus(accountId, roleName, region)
191
+ postJsonData = JSON.generate({:account_id => accountId, :role_name => roleName, :region => region})
192
+ authUrl = URI(@vaultBaseUrl + ROLE_AUTH_REL_URI)
193
+ authResponse = CerberusClient::Http.new.doHttp(authUrl, 'POST', true, postJsonData)
194
+ # if we got this far, we should have a valid response with encrypted data
195
+ # send back the encrypted data
196
+ JSON.parse(authResponse.body)['auth_data']
197
+ end
198
+
199
+ end
200
+ end
@@ -0,0 +1,15 @@
1
+ module Cerberus
2
+ class AwsRoleInfo
3
+ attr_reader :name
4
+ attr_reader :region
5
+ attr_reader :credentials
6
+ attr_reader :account_id
7
+
8
+ def initialize(name, region, account_id, credentials = nil)
9
+ @name = name
10
+ @region = region
11
+ @account_id = account_id
12
+ @credentials = credentials
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+
2
+
3
+ module Cerberus
4
+
5
+ require_relative('../cerberus_client/log')
6
+
7
+ ##
8
+ # Object to hold the Cerberus client token credentials and check for expiration and refresh
9
+ ##
10
+ class CerberusClientToken
11
+
12
+ attr_reader :authToken
13
+ attr_reader :cacheLifetimeSec
14
+
15
+ ##
16
+ # Init with an authToken. Expired will be true approximately cacheLifetimeSec seconds from when new is called.
17
+ # Optionally, set the cache lifetime. For now this is primarily used for testing.
18
+ ##
19
+ def initialize(authToken, cacheLifetimeSec, policiesArray)
20
+ @createTime = Time.now
21
+ @cacheLifetimeSec = cacheLifetimeSec
22
+ @policies = policiesArray
23
+ CerberusClient::Log.instance.debug("AwsCredentials cache lifetime set to #{@cacheLifetimeSec} seconds")
24
+ CerberusClient::Log.instance.debug("AwsCredentials policies: #{@policies.join(", ")}")
25
+ @authToken = authToken
26
+ end
27
+
28
+ ##
29
+ # Return true if cache lifetime has expired
30
+ # This object doesn't enforce expiration - someone else can worry about making sure the credentials are valid
31
+ ##
32
+ def expired?
33
+ ((@createTime + @cacheLifetimeSec) <=> Time.now) == -1
34
+ end
35
+
36
+ end
37
+ end
@@ -0,0 +1,44 @@
1
+ require_relative('../cerberus_client')
2
+ require_relative('../cerberus_client/log')
3
+ require_relative('exception/no_value_error')
4
+ require_relative('exception/no_valid_providers')
5
+ require_relative('aws_role_credentials_provider')
6
+ require_relative('env_credentials_provider')
7
+
8
+ module Cerberus
9
+
10
+ ##
11
+ # Default credentials provider chain
12
+ ##
13
+ class DefaultCredentialsProviderChain
14
+
15
+ def initialize(urlResolver, instanceMdSvcBaseUrl = nil)
16
+ vaultBaseUrl = CerberusClient.getUrlFromResolver(urlResolver)
17
+
18
+ # return default array of providers
19
+ @providers = [Cerberus::EnvCredentialsProvider.new,
20
+ Cerberus::AwsRoleCredentialsProvider.new(vaultBaseUrl, instanceMdSvcBaseUrl)]
21
+ end
22
+
23
+ ##
24
+ # Return the first provider in the default hierarchy that has a valid token
25
+ ##
26
+ def getCredentialsProvider
27
+ @providers.each { |p|
28
+ begin
29
+ # if token is assigned, that's the provider we want.
30
+ # providers must throw NoValueError so that we can fall to the next provider if necessary
31
+ CerberusClient.getCredentialsFromProvider(p)
32
+ return p
33
+
34
+ rescue Cerberus::Exception::NoValueError
35
+ next
36
+ end
37
+ }
38
+
39
+ # we should have found and returned a valid provider above, else there's a problem
40
+ CerberusClient::Log.instance.error(" could not find a valid provider")
41
+ raise Cerberus::Exception::NoValidProviders.new
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,26 @@
1
+
2
+ module Cerberus
3
+
4
+ require_relative('exception/no_value_error')
5
+
6
+ ##
7
+ # The default Vault URL resolver - looks for #{CERBERUS_VAULT_URL_ENV_KEY} in env vars
8
+ ##
9
+ class DefaultUrlResolver
10
+
11
+ CERBERUS_VAULT_URL_ENV_KEY = "CERBERUS_ADDR"
12
+
13
+ ##
14
+ # Look for the vault url in the env var
15
+ ##
16
+ def getUrl
17
+ urlOption = ENV[CERBERUS_VAULT_URL_ENV_KEY]
18
+
19
+ if(urlOption != nil)
20
+ return urlOption
21
+ else
22
+ raise Cerberus::Exception::NoValueError
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,29 @@
1
+
2
+
3
+ module Cerberus
4
+
5
+ require_relative('exception/no_value_error')
6
+
7
+ ##
8
+ # The Environment variable credentials provider - looks for #{CERBERUS_VAULT_TOKEN_ENV_KEY} in env vars
9
+ ##
10
+ class EnvCredentialsProvider
11
+
12
+ CERBERUS_VAULT_TOKEN_ENV_KEY = "CERBERUS_TOKEN"
13
+
14
+ ##
15
+ # Look for the vault token in the env var
16
+ ##
17
+ def getClientToken
18
+
19
+ tokenOption = ENV[CERBERUS_VAULT_TOKEN_ENV_KEY]
20
+
21
+ if(tokenOption != nil)
22
+ return tokenOption
23
+ else
24
+ raise Cerberus::Exception::NoValueError
25
+ end
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,22 @@
1
+
2
+ module Cerberus
3
+ module Exception
4
+
5
+ ##
6
+ # Custom exception raised when Vault sends us a bad request with a "permissions error"
7
+ #
8
+ # Since Vault wants to pass back 400 bad request for both paths we don't have access to
9
+ # and paths that don't actually exist at all, I'm sending back a specific error so that implementing clients
10
+ # can at least understand the situation they find themselves in
11
+ ##
12
+ class AmbiguousVaultBadRequest < RuntimeError
13
+
14
+ ##
15
+ # Init with exception message
16
+ ##
17
+ def initialize()
18
+ super("Vault sent back 400, Bad Request 'permissions error'. This means that 1) the root path may not exist 2) the account used may not have access to the path or 3) you actually can't be authenticated.")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,24 @@
1
+
2
+ module Cerberus
3
+ module Exception
4
+
5
+ ##
6
+ # Custom exception raised when an HTTP exception is raised but we want to handle it differently
7
+ # than simply presenting it to the end-user
8
+ ##
9
+ class HttpError < StandardError
10
+
11
+ attr_reader :response
12
+
13
+ ##
14
+ # Init with exception message and response object if one is available
15
+ ##
16
+ def initialize(httpMsg, responseObj = nil)
17
+
18
+ @response = responseObj
19
+ super("An error occurred executing the HTTP request: #{httpMsg}")
20
+
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,17 @@
1
+ module Cerberus
2
+ module Exception
3
+
4
+ ##
5
+ # Custom exception raised when no credentials providers can be found
6
+ ##
7
+ class NoValidProviders < RuntimeError
8
+
9
+ ##
10
+ # Init with exception message
11
+ ##
12
+ def initialize
13
+ super("No valid credentials providers could be found")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+
2
+ module Cerberus
3
+ module Exception
4
+
5
+ ##
6
+ # Custom exception raised when a provider can't successfully provide what is needed
7
+ # and this is likely an expected condition
8
+ ##
9
+ class NoValueError < RuntimeError
10
+
11
+ ##
12
+ # Init with exception message
13
+ ##
14
+ def initialize
15
+ super("No value specified")
16
+ end
17
+ end
18
+ end
19
+ end