diff --git a/keystoneclient/auth/base.py b/keystoneclient/auth/base.py index 68b151fdc..4456b0f3b 100644 --- a/keystoneclient/auth/base.py +++ b/keystoneclient/auth/base.py @@ -34,3 +34,22 @@ class BaseAuthPlugin(object): :param session: A session object so the plugin can make HTTP calls. :return string: A token to use. """ + + def get_endpoint(self, session, **kwargs): + """Return an endpoint for the client. + + There are no required keyword arguments to ``get_endpoint`` as a plugin + implementation should use best effort with the information available to + determine the endpoint. However there are certain standard options that + will be generated by the clients and should be used by plugins: + + - ``service_type``: what sort of service is required. + - ``interface``: what visibility the endpoint should have. + - ``region_name``: the region the endpoint exists in. + + :param Session session: The session object that the auth_plugin + belongs to. + + :returns string: The base URL that will be used to talk to the + required service or None if not available. + """ diff --git a/keystoneclient/auth/identity/base.py b/keystoneclient/auth/identity/base.py index 4020a2e9a..a63f30328 100644 --- a/keystoneclient/auth/identity/base.py +++ b/keystoneclient/auth/identity/base.py @@ -89,3 +89,37 @@ class BaseIdentityPlugin(base.BaseAuthPlugin): self.auth_ref = self.get_auth_ref(session) return self.auth_ref + + def get_endpoint(self, session, service_type=None, interface=None, + region_name=None, **kwargs): + """Return a valid endpoint for a service. + + If a valid token is not present then a new one will be fetched using + the session and kwargs. + + :param string service_type: The type of service to lookup the endpoint + for. This plugin will return None (failure) + if service_type is not provided. + :param string interface: The exposure of the endpoint. Should be + `public`, `internal` or `admin`. + Defaults to `public`. + :param string region_name: The region the endpoint should exist in. + (optional) + + :raises HTTPError: An error from an invalid HTTP response. + + :return string or None: A valid endpoint URL or None if not available. + """ + if not service_type: + LOG.warn('Plugin cannot return an endpoint without knowing the ' + 'service type that is required. Add service_type to ' + 'endpoint filtering data.') + return None + + if not interface: + interface = 'public' + + service_catalog = self.get_access(session).service_catalog + return service_catalog.url_for(service_type=service_type, + endpoint_type=interface, + region_name=region_name) diff --git a/keystoneclient/httpclient.py b/keystoneclient/httpclient.py index db69e09a2..09392ab2a 100644 --- a/keystoneclient/httpclient.py +++ b/keystoneclient/httpclient.py @@ -252,6 +252,12 @@ class HTTPClient(baseclient.Client, base.BaseAuthPlugin): if self.auth_token_from_user: return self.auth_token_from_user + def get_endpoint(self, session, interface=None, **kwargs): + if interface == 'public': + return self.auth_url + else: + return self.management_url + @auth_token.setter def auth_token(self, value): """Override the auth_token. @@ -558,25 +564,27 @@ class HTTPClient(baseclient.Client, base.BaseAuthPlugin): resp = super(HTTPClient, self).request(url, method, **kwargs) return resp, self._decode_body(resp) - def _cs_request(self, url, method, **kwargs): + def _cs_request(self, url, method, management=True, **kwargs): """Makes an authenticated request to keystone endpoint by concatenating self.management_url and url and passing in method and any associated kwargs. """ + interface = 'admin' if management else 'public' + endpoint_filter = kwargs.setdefault('endpoint_filter', {}) + endpoint_filter.setdefault('service_type', 'identity') + endpoint_filter.setdefault('interface', interface) - is_management = kwargs.pop('management', True) - - if is_management and self.management_url is None: - raise exceptions.AuthorizationFailure( - 'Current authorization does not have a known management url') - - url_to_use = self.auth_url - if is_management: - url_to_use = self.management_url + if self.region_name: + endpoint_filter.setdefault('region_name', self.region_name) kwargs.setdefault('authenticated', None) - return self.request(url_to_use + url, method, - **kwargs) + try: + return self.request(url, method, **kwargs) + except exceptions.MissingAuthPlugin: + _logger.info('Cannot get authenticated endpoint without an ' + 'auth plugin') + raise exceptions.AuthorizationFailure( + 'Current authorization does not have a known management url') def get(self, url, **kwargs): return self._cs_request(url, 'GET', **kwargs) diff --git a/keystoneclient/session.py b/keystoneclient/session.py index 89654563b..6aa7df847 100644 --- a/keystoneclient/session.py +++ b/keystoneclient/session.py @@ -14,6 +14,7 @@ import logging import requests import six +from six.moves import urllib from keystoneclient import exceptions from keystoneclient.openstack.common import jsonutils @@ -113,7 +114,7 @@ class Session(object): @utils.positional(enforcement=utils.positional.WARN) def request(self, url, method, json=None, original_ip=None, user_agent=None, redirect=None, authenticated=None, - **kwargs): + endpoint_filter=None, **kwargs): """Send an HTTP request with the specified characteristics. Wrapper around `requests.Session.request` to handle tasks such as @@ -122,7 +123,11 @@ class Session(object): Arguments that are not handled are passed through to the requests library. - :param string url: Fully qualified URL of HTTP request + :param string url: Path or fully qualified URL of HTTP request. If only + a path is provided then endpoint_filter must also be + provided such that the base URL can be determined. + If a fully qualified URL is provided then + endpoint_filter will be ignored. :param string method: The http method to use. (eg. 'GET', 'POST') :param string original_ip: Mark this request as forwarded for this ip. (optional) @@ -139,6 +144,11 @@ class Session(object): request, False if not or None for attach if an auth_plugin is available. (optional, defaults to None) + :param dict endpoint_filter: Data to be provided to an auth plugin with + which it should be able to determine an + endpoint to use for this request. If not + provided then URL is expected to be a + fully qualified URL. (optional) :param kwargs: any other parameter that can be passed to requests.Session.request (such as `headers`). Except: 'data' will be overwritten by the data in 'json' param. @@ -164,6 +174,19 @@ class Session(object): headers['X-Auth-Token'] = token + # if we are passed a fully qualified URL and a endpoint_filter we + # should ignore the filter. This will make it easier for clients who + # want to overrule the default endpoint_filter data added to all client + # requests. We check fully qualified here by the presence of a host. + url_data = urllib.parse.urlparse(url) + if endpoint_filter and not url_data.netloc: + base_url = self.get_endpoint(**endpoint_filter) + + if not base_url: + raise exceptions.EndpointNotFound() + + url = '%s/%s' % (base_url.rstrip('/'), url.lstrip('/')) + if self.cert: kwargs.setdefault('cert', self.cert) @@ -347,3 +370,11 @@ class Session(object): except exceptions.HTTPError as exc: raise exceptions.AuthorizationFailure("Authentication failure: " "%s" % exc) + + def get_endpoint(self, **kwargs): + """Get an endpoint as provided by the auth plugin.""" + if not self.auth: + raise exceptions.MissingAuthPlugin('An auth plugin is required to ' + 'determine the endpoint URL.') + + return self.auth.get_endpoint(self, **kwargs) diff --git a/keystoneclient/tests/auth/__init__.py b/keystoneclient/tests/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keystoneclient/tests/auth/test_identity_v2.py b/keystoneclient/tests/auth/test_identity_v2.py index 0f31e38d3..16278e2c9 100644 --- a/keystoneclient/tests/auth/test_identity_v2.py +++ b/keystoneclient/tests/auth/test_identity_v2.py @@ -13,6 +13,7 @@ # under the License. import httpretty +from six.moves import urllib from keystoneclient.auth.identity import v2 from keystoneclient import exceptions @@ -29,7 +30,52 @@ class V2IdentityPlugin(utils.TestCase): TEST_PASS = 'password' - TEST_SERVICE_CATALOG = [] + TEST_SERVICE_CATALOG = [{ + "endpoints": [{ + "adminURL": "http://cdn.admin-nets.local:8774/v1.0", + "region": "RegionOne", + "internalURL": "http://127.0.0.1:8774/v1.0", + "publicURL": "http://cdn.admin-nets.local:8774/v1.0/" + }], + "type": "nova_compat", + "name": "nova_compat" + }, { + "endpoints": [{ + "adminURL": "http://nova/novapi/admin", + "region": "RegionOne", + "internalURL": "http://nova/novapi/internal", + "publicURL": "http://nova/novapi/public" + }], + "type": "compute", + "name": "nova" + }, { + "endpoints": [{ + "adminURL": "http://glance/glanceapi/admin", + "region": "RegionOne", + "internalURL": "http://glance/glanceapi/internal", + "publicURL": "http://glance/glanceapi/public" + }], + "type": "image", + "name": "glance" + }, { + "endpoints": [{ + "adminURL": TEST_ADMIN_URL, + "region": "RegionOne", + "internalURL": "http://127.0.0.1:5000/v2.0", + "publicURL": "http://127.0.0.1:5000/v2.0" + }], + "type": "identity", + "name": "keystone" + }, { + "endpoints": [{ + "adminURL": "http://swift/swiftapi/admin", + "region": "RegionOne", + "internalURL": "http://swift/swiftapi/internal", + "publicURL": "http://swift/swiftapi/public" + }], + "type": "object-store", + "name": "swift" + }] def setUp(self): super(V2IdentityPlugin, self).setUp() @@ -109,3 +155,55 @@ class V2IdentityPlugin(utils.TestCase): self.assertRequestBodyIs(json=req) self.assertEqual(s.auth.auth_ref.auth_token, self.TEST_TOKEN) + + @httpretty.activate + def _do_service_url_test(self, base_url, endpoint_filter): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + self.stub_url(httpretty.GET, ['path'], + base_url=base_url, + body='SUCCESS', status=200) + + a = v2.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + resp = s.get('/path', endpoint_filter=endpoint_filter) + + self.assertEqual(resp.status_code, 200) + path = "%s/%s" % (urllib.parse.urlparse(base_url).path, 'path') + self.assertEqual(httpretty.last_request().path, path) + + def test_service_url(self): + endpoint_filter = {'service_type': 'compute', 'interface': 'admin'} + self._do_service_url_test('http://nova/novapi/admin', endpoint_filter) + + def test_service_url_defaults_to_public(self): + endpoint_filter = {'service_type': 'compute'} + self._do_service_url_test('http://nova/novapi/public', endpoint_filter) + + @httpretty.activate + def test_endpoint_filter_without_service_type_fails(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + + a = v2.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + self.assertRaises(exceptions.EndpointNotFound, s.get, '/path', + endpoint_filter={'interface': 'admin'}) + + @httpretty.activate + def test_full_url_overrides_endpoint_filter(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + self.stub_url(httpretty.GET, [], + base_url='http://testurl/', + body='SUCCESS', status=200) + + a = v2.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + resp = s.get('http://testurl/', + endpoint_filter={'service_type': 'compute'}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.text, 'SUCCESS') diff --git a/keystoneclient/tests/auth/test_identity_v3.py b/keystoneclient/tests/auth/test_identity_v3.py index 1b2aaf198..efb2d39d0 100644 --- a/keystoneclient/tests/auth/test_identity_v3.py +++ b/keystoneclient/tests/auth/test_identity_v3.py @@ -15,6 +15,7 @@ import copy import httpretty +from six.moves import urllib from keystoneclient import access from keystoneclient.auth.identity import v3 @@ -32,7 +33,83 @@ class V3IdentityPlugin(utils.TestCase): TEST_PASS = 'password' - TEST_SERVICE_CATALOG = [] + TEST_SERVICE_CATALOG = [{ + "endpoints": [{ + "url": "http://cdn.admin-nets.local:8774/v1.0/", + "region": "RegionOne", + "interface": "public" + }, { + "url": "http://127.0.0.1:8774/v1.0", + "region": "RegionOne", + "interface": "internal" + }, { + "url": "http://cdn.admin-nets.local:8774/v1.0", + "region": "RegionOne", + "interface": "admin" + }], + "type": "nova_compat" + }, { + "endpoints": [{ + "url": "http://nova/novapi/public", + "region": "RegionOne", + "interface": "public" + }, { + "url": "http://nova/novapi/internal", + "region": "RegionOne", + "interface": "internal" + }, { + "url": "http://nova/novapi/admin", + "region": "RegionOne", + "interface": "admin" + }], + "type": "compute" + }, { + "endpoints": [{ + "url": "http://glance/glanceapi/public", + "region": "RegionOne", + "interface": "public" + }, { + "url": "http://glance/glanceapi/internal", + "region": "RegionOne", + "interface": "internal" + }, { + "url": "http://glance/glanceapi/admin", + "region": "RegionOne", + "interface": "admin" + }], + "type": "image", + "name": "glance" + }, { + "endpoints": [{ + "url": "http://127.0.0.1:5000/v3", + "region": "RegionOne", + "interface": "public" + }, { + "url": "http://127.0.0.1:5000/v3", + "region": "RegionOne", + "interface": "internal" + }, { + "url": TEST_ADMIN_URL, + "region": "RegionOne", + "interface": "admin" + }], + "type": "identity" + }, { + "endpoints": [{ + "url": "http://swift/swiftapi/public", + "region": "RegionOne", + "interface": "public" + }, { + "url": "http://swift/swiftapi/internal", + "region": "RegionOne", + "interface": "internal" + }, { + "url": "http://swift/swiftapi/admin", + "region": "RegionOne", + "interface": "admin" + }], + "type": "object-store" + }] def setUp(self): super(V3IdentityPlugin, self).setUp() @@ -232,3 +309,55 @@ class V3IdentityPlugin(utils.TestCase): username=self.TEST_USER, password=self.TEST_PASS, domain_id='x', trust_id='x') self.assertRaises(exceptions.AuthorizationFailure, a.get_auth_ref, s) + + @httpretty.activate + def _do_service_url_test(self, base_url, endpoint_filter): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + self.stub_url(httpretty.GET, ['path'], + base_url=base_url, + body='SUCCESS', status=200) + + a = v3.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + resp = s.get('/path', endpoint_filter=endpoint_filter) + + self.assertEqual(resp.status_code, 200) + path = "%s/%s" % (urllib.parse.urlparse(base_url).path, 'path') + self.assertEqual(httpretty.last_request().path, path) + + def test_service_url(self): + endpoint_filter = {'service_type': 'compute', 'interface': 'admin'} + self._do_service_url_test('http://nova/novapi/admin', endpoint_filter) + + def test_service_url_defaults_to_public(self): + endpoint_filter = {'service_type': 'compute'} + self._do_service_url_test('http://nova/novapi/public', endpoint_filter) + + @httpretty.activate + def test_endpoint_filter_without_service_type_fails(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + + a = v3.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + self.assertRaises(exceptions.EndpointNotFound, s.get, '/path', + endpoint_filter={'interface': 'admin'}) + + @httpretty.activate + def test_full_url_overrides_endpoint_filter(self): + self.stub_auth(json=self.TEST_RESPONSE_DICT) + self.stub_url(httpretty.GET, [], + base_url='http://testurl/', + body='SUCCESS', status=200) + + a = v3.Password(self.TEST_URL, username=self.TEST_USER, + password=self.TEST_PASS) + s = session.Session(auth=a) + + resp = s.get('http://testurl/', + endpoint_filter={'service_type': 'compute'}) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.text, 'SUCCESS') diff --git a/keystoneclient/tests/test_session.py b/keystoneclient/tests/test_session.py index 5f2c42cf3..7ebb13071 100644 --- a/keystoneclient/tests/test_session.py +++ b/keystoneclient/tests/test_session.py @@ -281,18 +281,41 @@ class AuthPlugin(base.BaseAuthPlugin): TEST_TOKEN = 'aToken' + SERVICE_URLS = { + 'identity': {'public': 'http://identity-public:1111/v2.0', + 'admin': 'http://identity-admin:1111/v2.0'}, + 'compute': {'public': 'http://compute-public:2222/v1.0', + 'admin': 'http://compute-admin:2222/v1.0'}, + 'image': {'public': 'http://image-public:3333/v2.0', + 'admin': 'http://image-admin:3333/v2.0'} + } + def __init__(self, token=TEST_TOKEN): self.token = token def get_token(self, session): return self.token + def get_endpoint(self, session, service_type=None, interface=None, + **kwargs): + try: + return self.SERVICE_URLS[service_type][interface] + except (KeyError, AttributeError): + return None + class SessionAuthTests(utils.TestCase): TEST_URL = 'http://127.0.0.1:5000/' TEST_JSON = {'hello': 'world'} + def stub_service_url(self, service_type, interface, path, + method=httpretty.GET, **kwargs): + base_url = AuthPlugin.SERVICE_URLS[service_type][interface] + uri = "%s/%s" % (base_url.rstrip('/'), path.lstrip('/')) + + httpretty.register_uri(method, uri, **kwargs) + @httpretty.activate def test_auth_plugin_default_with_plugin(self): self.stub_url('GET', base_url=self.TEST_URL, json=self.TEST_JSON) @@ -315,3 +338,40 @@ class SessionAuthTests(utils.TestCase): self.assertDictEqual(resp.json(), self.TEST_JSON) self.assertRequestHeaderEqual('X-Auth-Token', None) + + @httpretty.activate + def test_service_type_urls(self): + service_type = 'compute' + interface = 'public' + path = '/instances' + status = 200 + body = 'SUCCESS' + + self.stub_service_url(service_type=service_type, + interface=interface, + path=path, + status=status, + body=body) + + sess = client_session.Session(auth=AuthPlugin()) + resp = sess.get(path, + endpoint_filter={'service_type': service_type, + 'interface': interface}) + + self.assertEqual(httpretty.last_request().path, '/v1.0/instances') + self.assertEqual(resp.text, body) + self.assertEqual(resp.status_code, status) + + def test_service_url_raises_if_no_auth_plugin(self): + sess = client_session.Session() + self.assertRaises(exceptions.MissingAuthPlugin, + sess.get, '/path', + endpoint_filter={'service_type': 'compute', + 'interface': 'public'}) + + def test_service_url_raises_if_no_url_returned(self): + sess = client_session.Session(auth=AuthPlugin()) + self.assertRaises(exceptions.EndpointNotFound, + sess.get, '/path', + endpoint_filter={'service_type': 'unknown', + 'interface': 'public'}) diff --git a/keystoneclient/v2_0/client.py b/keystoneclient/v2_0/client.py index 2907c8533..3a281ad1f 100644 --- a/keystoneclient/v2_0/client.py +++ b/keystoneclient/v2_0/client.py @@ -171,6 +171,9 @@ class Client(httpclient.HTTPClient): except (exceptions.AuthorizationFailure, exceptions.Unauthorized): _logger.debug("Authorization Failed.") raise + except exceptions.EndpointNotFound: + msg = 'There was no suitable authentication url for this request' + raise exceptions.AuthorizationFailure(msg) except Exception as e: raise exceptions.AuthorizationFailure("Authorization Failed: " "%s" % e) diff --git a/keystoneclient/v3/client.py b/keystoneclient/v3/client.py index f4d025ea3..a50a46c33 100644 --- a/keystoneclient/v3/client.py +++ b/keystoneclient/v3/client.py @@ -168,6 +168,9 @@ class Client(httpclient.HTTPClient): except (exceptions.AuthorizationFailure, exceptions.Unauthorized): _logger.debug('Authorization failed.') raise + except exceptions.EndpointNotFound: + msg = 'There was no suitable authentication url for this request' + raise exceptions.AuthorizationFailure(msg) except Exception as e: raise exceptions.AuthorizationFailure('Authorization failed: ' '%s' % e)