Skip to content

Commit

Permalink
Ec2RoleCredentials!
Browse files Browse the repository at this point in the history
  • Loading branch information
jacquev6 committed Apr 24, 2015
1 parent ad65e67 commit ba4d8a0
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 14 deletions.
1 change: 0 additions & 1 deletion LowVoltage/__init__.py
Expand Up @@ -14,7 +14,6 @@

# @todo __str__ and __repr__
# @todo docs
# @todo credential provider for AWS's AIM roles
# @todo create builder for attribute paths
# @todo improve builder for expressions
# @todo metrics
Expand Down
2 changes: 1 addition & 1 deletion LowVoltage/connection/__init__.py
Expand Up @@ -4,4 +4,4 @@

from .connection import Connection
from .retry_policies import ExponentialBackoffRetryPolicy
from .credentials import StaticCredentials, EnvironmentCredentials
from .credentials import StaticCredentials, EnvironmentCredentials, Ec2RoleCredentials
18 changes: 16 additions & 2 deletions LowVoltage/connection/connection.py
Expand Up @@ -72,9 +72,11 @@ def __call__(self, action):
raise

def __request_once(self, action):
key, secret = self.__credentials.get()
key, secret, token = self.__credentials.get()
payload = json.dumps(action.build())
headers = self.__signer(key, secret, self.__now(), action.name, payload)
if token is not None:
headers["X-Amz-Security-Token"] = token
try:
r = self.__session.post(self.__endpoint, data=payload, headers=headers)
except requests.exceptions.RequestException as e:
Expand Down Expand Up @@ -104,7 +106,7 @@ def setUp(self):
self.action = self.mocks.create("action")

def __expect_post(self):
self.credentials.expect.get().andReturn(("a", "b"))
self.credentials.expect.get().andReturn(("a", "b", None))
self.action.expect.build().andReturn({"d": "e"})
self.now.expect().andReturn("f")
self.action.expect.name.andReturn("c")
Expand Down Expand Up @@ -202,6 +204,18 @@ def test_failure_on_unkown_exception_raised_by_requests(self):
self.connection(self.action.object)
self.assertEqual(catcher.exception.args, (exception,))

def test_success_after_network_error_during_credentials(self):
exception = _exn.NetworkError()
self.credentials.expect.get().andRaise(exception)
self.retry_policy.expect.retry(self.action.object, [exception]).andReturn(0)

self.__expect_post().andReturn("k")
self.action.expect.Result.andReturn("l")
exception2 = _exn.ProvisionedThroughputExceededException()
self.responder.expect("l", "k").andReturn("m")

self.assertEqual(self.connection(self.action.object), "m")


class ConnectionLocalIntegTests(_tst.LocalIntegTests):
class TestAction:
Expand Down
172 changes: 164 additions & 8 deletions LowVoltage/connection/credentials.py
Expand Up @@ -15,34 +15,190 @@
.. py:method:: get()
Return the key/secret to be used to sign the next request.
Return the key/secret/token to be used to sign the next request. If the credentials are permanent, return ``None`` as token.
:type: tuple of two strings
:type: tuple of (string, string, ``None`` or string)
"""

import datetime
import os

import requests

import LowVoltage.testing as _tst
import LowVoltage.exceptions as _exn


class StaticCredentials(object):
"""
The simplest credential provider: a constant key/secret pair.
The simplest credential provider: a constant key/secret pair (and optionally a session token if you know what you're doing).
"""

def __init__(self, key, secret):
self.__credentials = (key, secret)
def __init__(self, key, secret, token=None):
self.__key = key
self.__secret = secret
self.__token = token

def get(self):
return self.__credentials
return self.__key, self.__secret, self.__token


class EnvironmentCredentials(object):
"""
Credential provider reading the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables.
Credential provider reading the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and optionally AWS_SECURITY_TOKEN environment variables.
"""

def __init__(self):
os.environ["AWS_ACCESS_KEY_ID"]
os.environ["AWS_SECRET_ACCESS_KEY"]

def get(self):
return (os.environ["AWS_ACCESS_KEY_ID"], os.environ["AWS_SECRET_ACCESS_KEY"])
return (os.environ["AWS_ACCESS_KEY_ID"], os.environ["AWS_SECRET_ACCESS_KEY"], os.environ.get("AWS_SECURITY_TOKEN"))


class Ec2RoleCredentials(object):
"""
Credentials provider using EC2 instance metadata to retrieve temporary, automatically rotated, credentials
from the `IAM role of the instance <http://docs.aws.amazon.com/IAM/latest/UserGuide/roles-usingrole-ec2instance.html>`__.
Usable *only* on an EC2 instance with an IAM role assigned.
:param requests_session: a ``Session`` object from the `python-requests <http://python-requests.org>`__ library. Typically not used. Leave it to ``None`` and one will be created for you.
"""

def __init__(self, requests_session=None):
if requests_session is None:
requests_session = requests.Session()

self.__session = requests_session
try:
role = self.__session.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/").text
except requests.exceptions.RequestException as e:
raise _exn.NetworkError(e)
except Exception as e:
raise _exn.UnknownError(e)
self.__creds_uri = "http://169.254.169.254/latest/meta-data/iam/security-credentials/{}".format(role)

self.__key = None
self.__secret = None
self.__token = None

# Dependency injection through monkey-patching
self.__now = datetime.datetime.utcnow

def get(self):
now = self.__now()
if self.__needs_refresh(now):
self.__refresh(now)

return self.__key, self.__secret, self.__token

def __needs_refresh(self, now):
if self.__key is None:
return True
elif now >= self.__expiration:
return True
elif now >= self.__next_refresh:
return True
else:
return False

def __refresh(self, now):
try:
creds = self.__session.get(self.__creds_uri).json()
except requests.exceptions.RequestException as e:
raise _exn.NetworkError(e)
except Exception as e:
raise _exn.UnknownError(e)
# {
# u'Code': u'Success',
# u'LastUpdated': u'2015-04-24T13:36:55Z',
# u'AccessKeyId': u'ASIAISBLBZIEQJ6USRAQ',
# u'SecretAccessKey': u'xxx',
# u'Token': u'yyy',
# u'Expiration': u'2015-04-24T19:36:54Z',
# u'Type': u'AWS-HMAC'
# }
self.__key = creds["AccessKeyId"]
self.__secret = creds["SecretAccessKey"]
self.__token = creds["Token"]
# Refresh every hour and 15 minutes before expiration: http://docs.aws.amazon.com/IAM/latest/UserGuide/roles-usingrole-ec2instance.html
self.__expiration = datetime.datetime.strptime(creds["Expiration"], "%Y-%m-%dT%H:%M:%SZ") - datetime.timedelta(minutes=15)
self.__next_refresh = now + datetime.timedelta(hours=1)


class Ec2RoleCredentialsUnitTests(_tst.UnitTestsWithMocks):
def setUp(self):
super(Ec2RoleCredentialsUnitTests, self).setUp()
self.session = self.mocks.create("session")
self.response = self.mocks.create("response")

def test_refresh_scenarios(self):
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/").andReturn(self.response.object)
self.response.expect.text.andReturn("RoleName")

credentials = Ec2RoleCredentials(self.session.object)

self.now = self.mocks.replace("credentials._Ec2RoleCredentials__now")

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 12, 30, 0))
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName").andReturn(self.response.object)
self.response.expect.json().andReturn({"AccessKeyId": "key1", "SecretAccessKey": "secret1", "Token": "token1", "Expiration": "2015-04-24T15:00:30Z"})

self.assertEqual(credentials.get(), ("key1", "secret1", "token1"))

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 12, 31, 0))
self.assertEqual(credentials.get(), ("key1", "secret1", "token1"))

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 13, 29, 0))
self.assertEqual(credentials.get(), ("key1", "secret1", "token1"))

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 13, 30, 0))
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName").andReturn(self.response.object)
self.response.expect.json().andReturn({"AccessKeyId": "key2", "SecretAccessKey": "secret2", "Token": "token3", "Expiration": "2015-04-24T14:00:30Z"})
self.assertEqual(credentials.get(), ("key2", "secret2", "token3"))

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 13, 45, 0))
self.assertEqual(credentials.get(), ("key2", "secret2", "token3"))

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 13, 46, 0))
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName").andReturn(self.response.object)
self.response.expect.json().andReturn({"AccessKeyId": "key3", "SecretAccessKey": "secret3", "Token": "token3", "Expiration": "2015-04-24T18:00:30Z"})
self.assertEqual(credentials.get(), ("key3", "secret3", "token3"))

def test_network_error_during_construction(self):
with self.assertRaises(_exn.NetworkError):
Ec2RoleCredentials()

def test_unknown_error_during_construction(self):
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/").andRaise(Exception)

with self.assertRaises(_exn.UnknownError):
Ec2RoleCredentials(self.session.object)

def test_network_error_during_refresh(self):
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/").andReturn(self.response.object)
self.response.expect.text.andReturn("RoleName")

credentials = Ec2RoleCredentials(self.session.object)

self.now = self.mocks.replace("credentials._Ec2RoleCredentials__now")

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 12, 30, 0))
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName").andRaise(requests.exceptions.RequestException)

with self.assertRaises(_exn.NetworkError):
credentials.get()

def test_unknown_error_during_refresh(self):
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/").andReturn(self.response.object)
self.response.expect.text.andReturn("RoleName")

credentials = Ec2RoleCredentials(self.session.object)

self.now = self.mocks.replace("credentials._Ec2RoleCredentials__now")

self.now.expect().andReturn(datetime.datetime(2015, 04, 24, 12, 30, 0))
self.session.expect.get("http://169.254.169.254/latest/meta-data/iam/security-credentials/RoleName").andRaise(Exception)

with self.assertRaises(_exn.UnknownError):
credentials.get()
1 change: 1 addition & 0 deletions LowVoltage/connection/tests/unit.py
Expand Up @@ -3,4 +3,5 @@
# Copyright 2014-2015 Vincent Jacques <vincent@vincent-jacques.net>

from ..connection import ConnectionUnitTests, SignerUnitTests, ResponderUnitTests
from ..credentials import Ec2RoleCredentialsUnitTests
from ..retry_policies import ExponentialBackoffRetryPolicyUnitTests
16 changes: 14 additions & 2 deletions LowVoltage/exceptions.py
Expand Up @@ -205,14 +205,24 @@ class ValidationException(ClientError):

class AccessDeniedException(ClientError):
"""
@todo Document
Exception `not documented <http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/CommonErrors.html>`__.
Seems to be raised when credentials are valid, but the operation is not allowed by IAM policies.
"""
pass


class InvalidSignatureException(ClientError):
"""
@todo Document
Exception `not documented <http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/CommonErrors.html>`__.
Seems to be raised when credentials are not valid.
"""
pass


class UnrecognizedClientException(ClientError):
"""
Exception `not documented <http://docs.aws.amazon.com/amazondynamodb/latest/APIReference/CommonErrors.html>`__.
Seems to be raised when using (valid) temporary credentials but an invalid token.
"""
pass

Expand Down Expand Up @@ -241,8 +251,10 @@ class InvalidSignatureException(ClientError):
("Throttling", Throttling),
("ValidationError", ValidationError),
("ValidationException", ValidationException),

("AccessDeniedException", AccessDeniedException),
("InvalidSignatureException", InvalidSignatureException),
("UnrecognizedClientException", UnrecognizedClientException),
],
key=lambda (prefix, cls): -len(prefix)
)

0 comments on commit ba4d8a0

Please sign in to comment.