cerberus_client 1.2.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +15 -0
- data/.gitignore +13 -0
- data/.rspec +2 -0
- data/CHANGELOG.md +8 -0
- data/CONTRIBUTING.md +39 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +202 -0
- data/README.md +71 -0
- data/cerberus_client.gemspec +33 -0
- data/lib/cerberus/assumed_role_credentials_provider_chain.rb +45 -0
- data/lib/cerberus/aws_role_credentials_provider.rb +200 -0
- data/lib/cerberus/aws_role_info.rb +15 -0
- data/lib/cerberus/cerberus_client_token.rb +37 -0
- data/lib/cerberus/default_credentials_provider_chain.rb +44 -0
- data/lib/cerberus/default_url_resolver.rb +26 -0
- data/lib/cerberus/env_credentials_provider.rb +29 -0
- data/lib/cerberus/exception/ambiguous_vault_bad_request.rb +22 -0
- data/lib/cerberus/exception/http_error.rb +24 -0
- data/lib/cerberus/exception/no_valid_providers.rb +17 -0
- data/lib/cerberus/exception/no_value_error.rb +19 -0
- data/lib/cerberus/vault_client.rb +207 -0
- data/lib/cerberus_client.rb +62 -0
- data/lib/cerberus_client/default_logger.rb +64 -0
- data/lib/cerberus_client/http.rb +67 -0
- data/lib/cerberus_client/log.rb +59 -0
- data/lib/cerberus_client/version.rb +3 -0
- metadata +102 -0
@@ -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
|