diff --git a/keystoneclient/auth/base.py b/keystoneclient/auth/base.py index 4d8b84003..27faa2abc 100644 --- a/keystoneclient/auth/base.py +++ b/keystoneclient/auth/base.py @@ -168,6 +168,19 @@ class BaseAuthPlugin(object): """ return None + def get_connection_params(self, session, **kwargs): + """Return any additional connection parameters required for the plugin. + + :param session: The session object that the auth_plugin belongs to. + :type session: keystoneclient.session.Session + + :returns: Headers that are set to authenticate a message or None for + failure. Note that when checking this value that the empty + dict is a valid, non-failure response. + :rtype: dict + """ + return {} + def invalidate(self): """Invalidate the current authentication data. diff --git a/keystoneclient/exceptions.py b/keystoneclient/exceptions.py index 0150bf528..fb0bd4138 100644 --- a/keystoneclient/exceptions.py +++ b/keystoneclient/exceptions.py @@ -97,6 +97,23 @@ class NoMatchingPlugin(ClientException): super(NoMatchingPlugin, self).__init__(msg) +class UnsupportedParameters(ClientException): + """A parameter that was provided or returned is not supported. + + :param list(str) names: Names of the unsupported parameters. + + .. py:attribute:: names + + Names of the unsupported parameters. + """ + + def __init__(self, names): + self.names = names + + m = _('The following parameters were given that are unsupported: %s') + super(UnsupportedParameters, self).__init__(m % ', '.join(self.names)) + + class InvalidResponse(ClientException): """The response from the server is not valid for this request.""" diff --git a/keystoneclient/session.py b/keystoneclient/session.py index 73ec0226b..b4a101c3e 100644 --- a/keystoneclient/session.py +++ b/keystoneclient/session.py @@ -379,6 +379,19 @@ class Session(object): send = functools.partial(self._send_request, url, method, redirect, log, logger, connect_retries) + + try: + connection_params = self.get_auth_connection_params(auth=auth) + except exceptions.MissingAuthPlugin: + # NOTE(jamielennox): If we've gotten this far without an auth + # plugin then we should be happy with allowing no additional + # connection params. This will be the typical case for plugins + # anyway. + pass + else: + if connection_params: + kwargs.update(connection_params) + resp = send(**kwargs) # handle getting a 401 Unauthorized response by invalidating the plugin @@ -635,6 +648,59 @@ class Session(object): auth = self._auth_required(auth, msg) return auth.get_endpoint(self, **kwargs) + def get_auth_connection_params(self, auth=None, **kwargs): + """Return auth connection params as provided by the auth plugin. + + An auth plugin may specify connection parameters to the request like + providing a client certificate for communication. + + We restrict the values that may be returned from this function to + prevent an auth plugin overriding values unrelated to connection + parmeters. The values that are currently accepted are: + + - `cert`: a path to a client certificate, or tuple of client + certificate and key pair that are used with this request. + - `verify`: a boolean value to indicate verifying SSL certificates + against the system CAs or a path to a CA file to verify with. + + These values are passed to the requests library and further information + on accepted values may be found there. + + :param auth: The auth plugin to use for tokens. Overrides the plugin + on the session. (optional) + :type auth: keystoneclient.auth.base.BaseAuthPlugin + + :raises keystoneclient.exceptions.AuthorizationFailure: if a new token + fetch fails. + :raises keystoneclient.exceptions.MissingAuthPlugin: if a plugin is not + available. + :raises keystoneclient.exceptions.UnsupportedParameters: if the plugin + returns a parameter that is not supported by this session. + + :returns: Authentication headers or None for failure. + :rtype: dict + """ + msg = _('An auth plugin is required to fetch connection params') + auth = self._auth_required(auth, msg) + params = auth.get_connection_params(self, **kwargs) + + # NOTE(jamielennox): There needs to be some consensus on what + # parameters are allowed to be modified by the auth plugin here. + # Ideally I think it would be only the send() parts of the request + # flow. For now lets just allow certain elements. + params_copy = params.copy() + + for arg in ('cert', 'verify'): + try: + kwargs[arg] = params_copy.pop(arg) + except KeyError: + pass + + if params_copy: + raise exceptions.UnsupportedParameters(list(params_copy.keys())) + + return params + def invalidate(self, auth=None): """Invalidate an authentication plugin. diff --git a/keystoneclient/tests/unit/auth/test_identity_common.py b/keystoneclient/tests/unit/auth/test_identity_common.py index 1f88250c8..d5652543c 100644 --- a/keystoneclient/tests/unit/auth/test_identity_common.py +++ b/keystoneclient/tests/unit/auth/test_identity_common.py @@ -14,12 +14,14 @@ import abc import datetime import uuid +import mock from oslo_utils import timeutils import six from keystoneclient import access from keystoneclient.auth import base from keystoneclient.auth import identity +from keystoneclient import exceptions from keystoneclient import fixture from keystoneclient import session from keystoneclient.tests.unit import utils @@ -411,6 +413,9 @@ class GenericPlugin(base.BaseAuthPlugin): self.headers = {'headerA': 'valueA', 'headerB': 'valueB'} + self.cert = '/path/to/cert' + self.connection_params = {'cert': self.cert, 'verify': False} + def url(self, prefix): return '%s/%s' % (self.endpoint, prefix) @@ -424,6 +429,9 @@ class GenericPlugin(base.BaseAuthPlugin): def get_endpoint(self, session, **kwargs): return self.endpoint + def get_connection_params(self, session, **kwargs): + return self.connection_params + class GenericAuthPluginTests(utils.TestCase): @@ -451,3 +459,37 @@ class GenericAuthPluginTests(utils.TestCase): self.session.get_auth_headers()) self.assertNotIn('X-Auth-Token', self.requests_mock.last_request.headers) + + def test_setting_connection_params(self): + text = uuid.uuid4().hex + + with mock.patch.object(self.session.session, 'request') as mocked: + mocked.return_value = utils.TestResponse({'status_code': 200, + 'text': text}) + resp = self.session.get('prefix', + endpoint_filter=self.ENDPOINT_FILTER) + + self.assertEqual(text, resp.text) + + # the cert and verify values passed to request are those that were + # returned from the auth plugin as connection params. + + mocked.assert_called_once_with('GET', + self.auth.url('prefix'), + headers=mock.ANY, + allow_redirects=False, + cert=self.auth.cert, + verify=False) + + def test_setting_bad_connection_params(self): + # The uuid name parameter here is unknown and not in the allowed params + # to be returned to the session and so an error will be raised. + name = uuid.uuid4().hex + self.auth.connection_params[name] = uuid.uuid4().hex + + e = self.assertRaises(exceptions.UnsupportedParameters, + self.session.get, + 'prefix', + endpoint_filter=self.ENDPOINT_FILTER) + + self.assertIn(name, str(e))