From: Steve Baker Date: Wed, 21 Aug 2013 02:05:17 +0000 (+1200) Subject: EC2token middleware implement multi-cloud auth X-Git-Tag: 2014.1~90^2 X-Git-Url: https://review.fuel-infra.org/gitweb?a=commitdiff_plain;h=fc007a1ec8a7c14335b992638c96893d7bfe6762;p=openstack-build%2Fheat-build.git EC2token middleware implement multi-cloud auth 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 --- diff --git a/etc/heat/heat.conf.sample b/etc/heat/heat.conf.sample index b4e6872f..14f313fa 100644 --- a/etc/heat/heat.conf.sample +++ b/etc/heat/heat.conf.sample @@ -523,6 +523,14 @@ # Authentication Endpoint URI (string value) #auth_uri= +# 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] diff --git a/heat/api/aws/ec2token.py b/heat/api/aws/ec2token.py index d223bd07..428aa3be 100644 --- a/heat/api/aws/ec2token.py +++ b/heat/api/aws/ec2token.py @@ -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) diff --git a/heat/tests/test_api_ec2token.py b/heat/tests/test_api_ec2token.py index d5a25565..13693af9 100644 --- a/heat/tests/test_api_ec2token.py +++ b/heat/tests/test_api_ec2token.py @@ -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()