]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
EC2token middleware implement multi-cloud auth
authorSteve Baker <sbaker@redhat.com>
Wed, 21 Aug 2013 02:05:17 +0000 (14:05 +1200)
committerSteve Baker <sbaker@redhat.com>
Wed, 21 Aug 2013 23:10:22 +0000 (11:10 +1200)
Making ec2token middleware multi-cloud aware presents a
challenge as the API assumes there is only one cloud and
Heat must remain compatible. This means that unlike multi-cloud
heat-api, the request cannot explicitly say which endpoint it
wants to authenticate against.

However it must be possible to make authenticated cfn requests
against a multi-cloud heat for the sake of cfntools requests.

The approach taken in this commit is to attempt authentication
against each configured allowed_auth_uris until one succeeds.

This is safe for the following reasons:
1. AWSAccessKeyId is a randomly generated sequence
2. No secret is transferred to validate a request

If all auth attempts fail, the last failed reason is raised
as the error to the user.

Change-Id: I3a5a7adc97b110fcb8c6e8b156749fdec8924b88

etc/heat/heat.conf.sample
heat/api/aws/ec2token.py
heat/tests/test_api_ec2token.py

index b4e6872f17b6d3c2f8fce7d95d3cf68a8d4df611..14f313fa5d60ffa3a830c198267ec60b9e80abf3 100644 (file)
 # Authentication Endpoint URI (string value)
 #auth_uri=<None>
 
+# Allow orchestration of multiple clouds (boolean value)
+#multi_cloud=false
+
+# Allowed keystone endpoints for auth_uri when multi_cloud is
+# enabled. At least one endpoint needs to be specified. (list
+# value)
+#allowed_auth_uris=
+
 
 [matchmaker_redis]
 
index d223bd07e92df0678c82fee017f78632772a45dd..428aa3be9f30464aecc5d5642a6253ba0ad8ebbd 100644 (file)
@@ -17,6 +17,7 @@ import hashlib
 import requests
 
 from heat.openstack.common import gettextutils
+from heat.api.aws.exception import HeatAPIException
 
 gettextutils.install('heat')
 
@@ -35,7 +36,15 @@ logger = logging.getLogger(__name__)
 opts = [
     cfg.StrOpt('auth_uri',
                default=None,
-               help=_("Authentication Endpoint URI"))
+               help=_("Authentication Endpoint URI")),
+    cfg.BoolOpt('multi_cloud',
+                default=False,
+                help=_('Allow orchestration of multiple clouds')),
+    cfg.ListOpt('allowed_auth_uris',
+                default=[],
+                help=_('Allowed keystone endpoints for auth_uri when '
+                       'multi_cloud is enabled. At least one endpoint needs '
+                       'to be specified.'))
 ]
 cfg.CONF.register_opts(opts, group='ec2authtoken')
 
@@ -54,8 +63,8 @@ class EC2Token(wsgi.Middleware):
         else:
             return cfg.CONF.ec2authtoken[name]
 
-    def _conf_get_keystone_ec2_uri(self):
-        auth_uri = self._conf_get('auth_uri')
+    @staticmethod
+    def _conf_get_keystone_ec2_uri(auth_uri):
         if auth_uri.endswith('/'):
             return '%sec2tokens' % auth_uri
         return '%s/ec2tokens' % auth_uri
@@ -99,6 +108,25 @@ class EC2Token(wsgi.Middleware):
 
     @webob.dec.wsgify(RequestClass=wsgi.Request)
     def __call__(self, req):
+        if not self._conf_get('multi_cloud'):
+            return self._authorize(req, self._conf_get('auth_uri'))
+        else:
+            # attempt to authorize for each configured allowed_auth_uris
+            # until one is successful.
+            # This is safe for the following reasons:
+            # 1. AWSAccessKeyId is a randomly generated sequence
+            # 2. No secret is transferred to validate a request
+            last_failure = None
+            for auth_uri in self._conf_get('allowed_auth_uris'):
+                try:
+                    logger.debug("Attempt authorize on %s" % auth_uri)
+                    return self._authorize(req, auth_uri)
+                except HeatAPIException as e:
+                    logger.debug("Authorize failed: %s" % e.__class__)
+                    last_failure = e
+            raise last_failure or exception.HeatAccessDeniedError()
+
+    def _authorize(self, req, auth_uri):
         # Read request signature and access id.
         # If we find X-Auth-User in the headers we ignore a key error
         # here so that we can use both authentication methods.
@@ -143,7 +171,7 @@ class EC2Token(wsgi.Middleware):
         creds_json = json.dumps(creds)
         headers = {'Content-Type': 'application/json'}
 
-        keystone_ec2_uri = self._conf_get_keystone_ec2_uri()
+        keystone_ec2_uri = self._conf_get_keystone_ec2_uri(auth_uri)
         logger.info('Authenticating with %s' % keystone_ec2_uri)
         response = requests.post(keystone_ec2_uri, data=creds_json,
                                  headers=headers)
index d5a255659b0b4a3d564309cc6c3a7bda2a0234b8..13693af9e34b9b5a81f5d0aa07c0adb8a099a0a9 100644 (file)
@@ -28,6 +28,11 @@ class Ec2TokenTest(HeatTestCase):
     '''
     Tests the Ec2Token middleware
     '''
+
+    def setUp(self):
+        super(Ec2TokenTest, self).setUp()
+        self.m.StubOutWithMock(requests, 'post')
+
     def _dummy_GET_request(self, params={}, environ={}):
         # Mangle the params dict into a query string
         qs = "&".join(["=".join([k, str(params[k])]) for k in params])
@@ -39,16 +44,18 @@ class Ec2TokenTest(HeatTestCase):
         dummy_conf = {'auth_uri': 'http://192.0.2.9/v2.0'}
         ec2 = ec2token.EC2Token(app=None, conf=dummy_conf)
         self.assertEqual(ec2._conf_get('auth_uri'), 'http://192.0.2.9/v2.0')
-        self.assertEqual(ec2._conf_get_keystone_ec2_uri(),
-                         'http://192.0.2.9/v2.0/ec2tokens')
+        self.assertEqual(
+            ec2._conf_get_keystone_ec2_uri('http://192.0.2.9/v2.0'),
+            'http://192.0.2.9/v2.0/ec2tokens')
 
     def test_conf_get_opts(self):
         cfg.CONF.set_default('auth_uri', 'http://192.0.2.9/v2.0/',
                              group='ec2authtoken')
         ec2 = ec2token.EC2Token(app=None, conf={})
         self.assertEqual(ec2._conf_get('auth_uri'), 'http://192.0.2.9/v2.0/')
-        self.assertEqual(ec2._conf_get_keystone_ec2_uri(),
-                         'http://192.0.2.9/v2.0/ec2tokens')
+        self.assertEqual(
+            ec2._conf_get_keystone_ec2_uri('http://192.0.2.9/v2.0/'),
+            'http://192.0.2.9/v2.0/ec2tokens')
 
     def test_get_signature_param_old(self):
         params = {'Signature': 'foo'}
@@ -172,7 +179,8 @@ class Ec2TokenTest(HeatTestCase):
         ec2 = ec2token.EC2Token(app='xyz', conf={})
         self.assertEqual(ec2.__call__(dummy_req), 'xyz')
 
-    def _stub_http_connection(self, headers={}, params={}, response=None):
+    def _stub_http_connection(self, headers={}, params={}, response=None,
+                              req_url='http://123:5000/v2.0/ec2tokens'):
 
         class DummyHTTPResponse(object):
             text = response
@@ -180,7 +188,6 @@ class Ec2TokenTest(HeatTestCase):
             def json(self):
                 return json.loads(self.text)
 
-        self.m.StubOutWithMock(requests, 'post')
         body_hash = ('e3b0c44298fc1c149afbf4c8996fb9'
                      '2427ae41e4649b934ca495991b7852b855')
         req_creds = json.dumps({"ec2Credentials":
@@ -193,7 +200,6 @@ class Ec2TokenTest(HeatTestCase):
                                  "path": "/v1",
                                  "body_hash": body_hash}})
         req_headers = {'Content-Type': 'application/json'}
-        req_url = 'http://123:5000/v2.0/ec2tokens'
         requests.post(req_url, data=req_creds,
                       headers=req_headers).AndReturn(DummyHTTPResponse())
 
@@ -335,3 +341,94 @@ class Ec2TokenTest(HeatTestCase):
         self.assertEqual(ec2.__call__(dummy_req), 'woot')
 
         self.m.VerifyAll()
+
+    def test_call_ok_multicloud(self):
+        dummy_conf = {
+            'allowed_auth_uris': [
+                'http://123:5000/v2.0', 'http://456:5000/v2.0'],
+            'multi_cloud': True
+        }
+        ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf)
+        params = {'AWSAccessKeyId': 'foo', 'Signature': 'xyz'}
+        req_env = {'SERVER_NAME': 'heat',
+                   'SERVER_PORT': '8000',
+                   'PATH_INFO': '/v1'}
+        dummy_req = self._dummy_GET_request(params, req_env)
+
+        ok_resp = json.dumps({'access': {'metadata': {}, 'token': {
+            'id': 123,
+            'tenant': {'name': 'tenant', 'id': 'abcd1234'}}}})
+        err_msg = "EC2 access key not found."
+        err_resp = json.dumps({'error': {'message': err_msg}})
+
+        # first request fails
+        self._stub_http_connection(
+            req_url='http://123:5000/v2.0/ec2tokens',
+            response=err_resp,
+            params={'AWSAccessKeyId': 'foo'})
+
+        # second request passes
+        self._stub_http_connection(
+            req_url='http://456:5000/v2.0/ec2tokens',
+            response=ok_resp,
+            params={'AWSAccessKeyId': 'foo'})
+
+        self.m.ReplayAll()
+        self.assertEqual(ec2.__call__(dummy_req), 'woot')
+
+        self.m.VerifyAll()
+
+    def test_call_err_multicloud(self):
+        dummy_conf = {
+            'allowed_auth_uris': [
+                'http://123:5000/v2.0', 'http://456:5000/v2.0'],
+            'multi_cloud': True
+        }
+        ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf)
+        params = {'AWSAccessKeyId': 'foo', 'Signature': 'xyz'}
+        req_env = {'SERVER_NAME': 'heat',
+                   'SERVER_PORT': '8000',
+                   'PATH_INFO': '/v1'}
+        dummy_req = self._dummy_GET_request(params, req_env)
+
+        err_resp1 = json.dumps({})
+
+        err_msg2 = "EC2 access key not found."
+        err_resp2 = json.dumps({'error': {'message': err_msg2}})
+
+        # first request fails with HeatAccessDeniedError
+        self._stub_http_connection(
+            req_url='http://123:5000/v2.0/ec2tokens',
+            response=err_resp1,
+            params={'AWSAccessKeyId': 'foo'})
+
+        # second request fails with HeatInvalidClientTokenIdError
+        self._stub_http_connection(
+            req_url='http://456:5000/v2.0/ec2tokens',
+            response=err_resp2,
+            params={'AWSAccessKeyId': 'foo'})
+
+        self.m.ReplayAll()
+        # raised error matches last failure
+        self.assertRaises(exception.HeatInvalidClientTokenIdError,
+                          ec2.__call__, dummy_req)
+
+        self.m.VerifyAll()
+
+    def test_call_err_multicloud_none_allowed(self):
+        dummy_conf = {
+            'allowed_auth_uris': [],
+            'multi_cloud': True
+        }
+        ec2 = ec2token.EC2Token(app='woot', conf=dummy_conf)
+        params = {'AWSAccessKeyId': 'foo', 'Signature': 'xyz'}
+        req_env = {'SERVER_NAME': 'heat',
+                   'SERVER_PORT': '8000',
+                   'PATH_INFO': '/v1'}
+        dummy_req = self._dummy_GET_request(params, req_env)
+
+        self.m.ReplayAll()
+        self.assertRaises(exception.HeatAccessDeniedError,
+                          ec2.__call__, dummy_req)
+
+        self.m.VerifyAll()