diff options
author | Jamie Lennox <jamielennox@redhat.com> | 2014-03-18 12:37:46 +1000 |
---|---|---|
committer | Jamie Lennox <jamielennox@redhat.com> | 2014-09-03 21:34:10 +1000 |
commit | d070347988e1fbc9f84439f1b63cb4d52a9bfcda (patch) | |
tree | c7e82c888d246fe37135af0e90f1bf6f056e244a | |
parent | 1643f7da32b1f729f12d042565d8c67f10f91b8c (diff) | |
download | python-keystoneclient-d070347988e1fbc9f84439f1b63cb4d52a9bfcda.tar.gz |
Version independent plugins
A Framework for creating plugins that work across identity versions.
Upon creating a generic plugin the plugin will go and discover what
versions are available on the server and then attemp to construct a
suitable plugin.
Blueprint: version-independant-plugins
Change-Id: If7fed94aaf4636e80a9c3a834cf6c5430f20e489
-rw-r--r-- | keystoneclient/auth/identity/base.py | 12 | ||||
-rw-r--r-- | keystoneclient/auth/identity/generic/__init__.py | 21 | ||||
-rw-r--r-- | keystoneclient/auth/identity/generic/base.py | 179 | ||||
-rw-r--r-- | keystoneclient/auth/identity/generic/password.py | 83 | ||||
-rw-r--r-- | keystoneclient/auth/identity/generic/token.py | 52 | ||||
-rw-r--r-- | keystoneclient/tests/auth/test_password.py | 40 | ||||
-rw-r--r-- | keystoneclient/tests/auth/test_token.py | 29 | ||||
-rw-r--r-- | keystoneclient/tests/auth/utils.py | 114 | ||||
-rw-r--r-- | setup.cfg | 3 |
9 files changed, 528 insertions, 5 deletions
diff --git a/keystoneclient/auth/identity/base.py b/keystoneclient/auth/identity/base.py index 4b02f94..dbdcc1f 100644 --- a/keystoneclient/auth/identity/base.py +++ b/keystoneclient/auth/identity/base.py @@ -24,6 +24,12 @@ from keystoneclient import utils LOG = logging.getLogger(__name__) +def get_options(): + return [ + cfg.StrOpt('auth-url', help='Authentication URL'), + ] + + @six.add_metaclass(abc.ABCMeta) class BaseIdentityPlugin(base.BaseAuthPlugin): @@ -256,9 +262,5 @@ class BaseIdentityPlugin(base.BaseAuthPlugin): @classmethod def get_options(cls): options = super(BaseIdentityPlugin, cls).get_options() - - options.extend([ - cfg.StrOpt('auth-url', help='Authentication URL'), - ]) - + options.extend(get_options()) return options diff --git a/keystoneclient/auth/identity/generic/__init__.py b/keystoneclient/auth/identity/generic/__init__.py new file mode 100644 index 0000000..b24c3d6 --- /dev/null +++ b/keystoneclient/auth/identity/generic/__init__.py @@ -0,0 +1,21 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from keystoneclient.auth.identity.generic.base import BaseGenericPlugin # noqa +from keystoneclient.auth.identity.generic.password import Password # noqa +from keystoneclient.auth.identity.generic.token import Token # noqa + + +__all__ = ['BaseGenericPlugin', + 'Password', + 'Token', + ] diff --git a/keystoneclient/auth/identity/generic/base.py b/keystoneclient/auth/identity/generic/base.py new file mode 100644 index 0000000..94d48ec --- /dev/null +++ b/keystoneclient/auth/identity/generic/base.py @@ -0,0 +1,179 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import abc +import logging + +from oslo.config import cfg +import six +import six.moves.urllib.parse as urlparse + +from keystoneclient import _discover +from keystoneclient.auth.identity import base +from keystoneclient import exceptions + +LOG = logging.getLogger(__name__) + + +def get_options(): + return base.get_options() + [ + cfg.StrOpt('domain-id', help='Domain ID to scope to'), + cfg.StrOpt('domain-name', help='Domain name to scope to'), + cfg.StrOpt('tenant-id', help='Tenant ID to scope to'), + cfg.StrOpt('tenant-name', help='Tenant name to scope to'), + cfg.StrOpt('project-id', help='Project ID to scope to'), + cfg.StrOpt('project-name', help='Project name to scope to'), + cfg.StrOpt('project-domain-id', + help='Domain ID containing project'), + cfg.StrOpt('project-domain-name', + help='Domain name containing project'), + cfg.StrOpt('trust-id', help='Trust ID'), + ] + + +@six.add_metaclass(abc.ABCMeta) +class BaseGenericPlugin(base.BaseIdentityPlugin): + """An identity plugin that is not version dependant. + + Internally we will construct a version dependant plugin with the resolved + URL and then proxy all calls from the base plugin to the versioned one. + """ + + def __init__(self, auth_url, + tenant_id=None, + tenant_name=None, + project_id=None, + project_name=None, + project_domain_id=None, + project_domain_name=None, + domain_id=None, + domain_name=None, + trust_id=None): + super(BaseGenericPlugin, self).__init__(auth_url=auth_url) + + self._project_id = project_id or tenant_id + self._project_name = project_name or tenant_name + self._project_domain_id = project_domain_id + self._project_domain_name = project_domain_name + self._domain_id = domain_id + self._domain_name = domain_name + self._trust_id = trust_id + + self._plugin = None + + @abc.abstractmethod + def create_plugin(self, session, version, url, raw_status=None): + """Create a plugin from the given paramters. + + This function will be called multiple times with the version and url + of a potential endpoint. If a plugin can be constructed that fits the + params then it should return it. If not return None and then another + call will be made with other available URLs. + + :param Session session: A session object. + :param tuple version: A tuple of the API version at the URL. + :param string url: The base URL for this version. + :param string raw_status: The status that was in the discovery field. + + :returns: A plugin that can match the parameters or None if nothing. + """ + return None + + @property + def _has_domain_scope(self): + """Are there domain parameters. + + Domain parameters are v3 only so returns if any are set. + + :returns: True if a domain parameter is set, false otherwise. + """ + return any([self._domain_id, self._domain_name, + self._project_domain_id, self._project_domain_name]) + + @property + def _v2_params(self): + """Parameters that are common to v2 plugins.""" + return {'trust_id': self._trust_id, + 'tenant_id': self._project_id, + 'tenant_name': self._project_name} + + @property + def _v3_params(self): + """Parameters that are common to v3 plugins.""" + return {'trust_id': self._trust_id, + 'project_id': self._project_id, + 'project_name': self._project_name, + 'project_domain_id': self._project_domain_id, + 'project_domain_name': self._project_domain_name, + 'domain_id': self._domain_id, + 'domain_name': self._domain_name} + + def _do_create_plugin(self, session): + plugin = None + + try: + disc = self.get_discovery(session, + self.auth_url, + authenticated=False) + except (exceptions.DiscoveryFailure, + exceptions.HTTPError, + exceptions.ConnectionError): + LOG.warn('Discovering versions from the identity service failed ' + 'when creating the password plugin. Attempting to ' + 'determine version from URL.') + + url_parts = urlparse.urlparse(self.auth_url) + path = url_parts.path.lower() + + if path.startswith('/v2.0') and not self._has_domain_scope: + plugin = self.create_plugin(session, (2, 0), self.auth_url) + elif path.startswith('/v3'): + plugin = self.create_plugin(session, (3, 0), self.auth_url) + + else: + disc_data = disc.version_data() + + for data in disc_data: + version = data['version'] + + if (_discover.version_match((2,), version) and + self._has_domain_scope): + # NOTE(jamielennox): if there are domain parameters there + # is no point even trying against v2 APIs. + continue + + plugin = self.create_plugin(session, + version, + data['url'], + raw_status=data['raw_status']) + + if plugin: + break + + if plugin: + return plugin + + # so there were no URLs that i could use for auth of any version. + msg = 'Could not determine a suitable URL for the plugin' + raise exceptions.DiscoveryFailure(msg) + + def get_auth_ref(self, session, **kwargs): + if not self._plugin: + self._plugin = self._do_create_plugin(session) + + return self._plugin.get_auth_ref(session, **kwargs) + + @classmethod + def get_options(cls): + options = super(BaseGenericPlugin, cls).get_options() + options.extend(get_options()) + return options diff --git a/keystoneclient/auth/identity/generic/password.py b/keystoneclient/auth/identity/generic/password.py new file mode 100644 index 0000000..c8d9b7a --- /dev/null +++ b/keystoneclient/auth/identity/generic/password.py @@ -0,0 +1,83 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from oslo.config import cfg + +from keystoneclient import _discover +from keystoneclient.auth.identity.generic import base +from keystoneclient.auth.identity import v2 +from keystoneclient.auth.identity import v3 +from keystoneclient import utils + +LOG = logging.getLogger(__name__) + + +def get_options(): + return [ + cfg.StrOpt('user-name', dest='username', help='Username', + deprecated_name='username'), + cfg.StrOpt('user-domain-id', help="User's domain id"), + cfg.StrOpt('user-domain-name', help="User's domain name"), + cfg.StrOpt('password', help="User's password"), + ] + + +class Password(base.BaseGenericPlugin): + """A common user/password authentication plugin.""" + + @utils.positional() + def __init__(self, auth_url, username=None, user_id=None, password=None, + user_domain_id=None, user_domain_name=None, **kwargs): + """Construct plugin. + + :param string username: Username for authentication. + :param string user_id: User ID for authentication. + :param string password: Password for authentication. + :param string user_domain_id: User's domain ID for authentication. + :param string user_domain_name: User's domain name for authentication. + """ + super(Password, self).__init__(auth_url=auth_url, **kwargs) + + self._username = username + self._user_id = user_id + self._password = password + self._user_domain_id = user_domain_id + self._user_domain_name = user_domain_name + + def create_plugin(self, session, version, url, raw_status=None): + if _discover.version_match((2,), version): + if self._user_domain_id or self._user_domain_name: + # If you specify any domain parameters it won't work so quit. + return None + + return v2.Password(auth_url=url, + user_id=self._user_id, + username=self._username, + password=self._password, + **self._v2_params) + + elif _discover.version_match((3,), version): + return v3.Password(auth_url=url, + user_id=self._user_id, + username=self._username, + user_domain_id=self._user_domain_id, + user_domain_name=self._user_domain_name, + password=self._password, + **self._v3_params) + + @classmethod + def get_options(cls): + options = super(Password, cls).get_options() + options.extend(get_options()) + return options diff --git a/keystoneclient/auth/identity/generic/token.py b/keystoneclient/auth/identity/generic/token.py new file mode 100644 index 0000000..547ce36 --- /dev/null +++ b/keystoneclient/auth/identity/generic/token.py @@ -0,0 +1,52 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from oslo.config import cfg + +from keystoneclient import _discover +from keystoneclient.auth.identity.generic import base +from keystoneclient.auth.identity import v2 +from keystoneclient.auth.identity import v3 + +LOG = logging.getLogger(__name__) + + +def get_options(): + return [ + cfg.StrOpt('token', help='Token to authenticate with'), + ] + + +class Token(base.BaseGenericPlugin): + + def __init__(self, auth_url, token=None, **kwargs): + """Construct a plugin. + + :param string token: Token for authentication. + """ + super(Token, self).__init__(auth_url, **kwargs) + self._token = token + + def create_plugin(self, session, version, url, raw_status=None): + if _discover.version_match((2,), version): + return v2.Token(url, self._token, **self._v2_params) + + elif _discover.version_match((3,), version): + return v3.Token(url, self._token, **self._v3_params) + + @classmethod + def get_options(cls): + options = super(Token, cls).get_options() + options.extend(get_options()) + return options diff --git a/keystoneclient/tests/auth/test_password.py b/keystoneclient/tests/auth/test_password.py new file mode 100644 index 0000000..5c93864 --- /dev/null +++ b/keystoneclient/tests/auth/test_password.py @@ -0,0 +1,40 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid + +from keystoneclient.auth.identity.generic import password +from keystoneclient.auth.identity import v2 +from keystoneclient.auth.identity import v3 +from keystoneclient.tests.auth import utils + + +class PasswordTests(utils.GenericPluginTestCase): + + PLUGIN_CLASS = password.Password + V2_PLUGIN_CLASS = v2.Password + V3_PLUGIN_CLASS = v3.Password + + def new_plugin(self, **kwargs): + kwargs.setdefault('username', uuid.uuid4().hex) + kwargs.setdefault('password', uuid.uuid4().hex) + return super(PasswordTests, self).new_plugin(**kwargs) + + def test_with_user_domain_params(self): + self.stub_discovery() + + self.assertCreateV3(domain_id=uuid.uuid4().hex, + user_domain_id=uuid.uuid4().hex) + + def test_v3_user_params_v2_url(self): + self.stub_discovery(v3=False) + self.assertDiscoveryFailure(user_domain_id=uuid.uuid4().hex) diff --git a/keystoneclient/tests/auth/test_token.py b/keystoneclient/tests/auth/test_token.py new file mode 100644 index 0000000..d9a11e0 --- /dev/null +++ b/keystoneclient/tests/auth/test_token.py @@ -0,0 +1,29 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid + +from keystoneclient.auth.identity.generic import token +from keystoneclient.auth.identity import v2 +from keystoneclient.auth.identity import v3 +from keystoneclient.tests.auth import utils + + +class TokenTests(utils.GenericPluginTestCase): + + PLUGIN_CLASS = token.Token + V2_PLUGIN_CLASS = v2.Token + V3_PLUGIN_CLASS = v3.Token + + def new_plugin(self, **kwargs): + kwargs.setdefault('token', uuid.uuid4().hex) + return super(TokenTests, self).new_plugin(**kwargs) diff --git a/keystoneclient/tests/auth/utils.py b/keystoneclient/tests/auth/utils.py index 5cc7011..c3dae8f 100644 --- a/keystoneclient/tests/auth/utils.py +++ b/keystoneclient/tests/auth/utils.py @@ -11,12 +11,17 @@ # under the License. import functools +import uuid import mock from oslo.config import cfg import six +from keystoneclient import access from keystoneclient.auth import base +from keystoneclient import exceptions +from keystoneclient import fixture +from keystoneclient import session from keystoneclient.tests import utils @@ -81,3 +86,112 @@ class TestCase(utils.TestCase): def assertTestVals(self, plugin, vals=TEST_VALS): for k, v in six.iteritems(vals): self.assertEqual(v, plugin[k]) + + +class GenericPluginTestCase(utils.TestCase): + + TEST_URL = 'http://keystone.host:5000/' + + # OVERRIDE THESE IN SUB CLASSES + PLUGIN_CLASS = None + V2_PLUGIN_CLASS = None + V3_PLUGIN_CLASS = None + + def setUp(self): + super(GenericPluginTestCase, self).setUp() + + self.token_v2 = fixture.V2Token() + self.token_v3 = fixture.V3Token() + self.token_v3_id = uuid.uuid4().hex + self.session = session.Session() + + self.stub_url('POST', ['v2.0', 'tokens'], json=self.token_v2) + self.stub_url('POST', ['v3', 'auth', 'tokens'], + headers={'X-Subject-Token': self.token_v3_id}, + json=self.token_v3) + + def new_plugin(self, **kwargs): + kwargs.setdefault('auth_url', self.TEST_URL) + return self.PLUGIN_CLASS(**kwargs) + + def stub_discovery(self, base_url=None, **kwargs): + kwargs.setdefault('href', self.TEST_URL) + disc = fixture.DiscoveryList(**kwargs) + self.stub_url('GET', json=disc, base_url=base_url, status_code=300) + return disc + + def assertCreateV3(self, **kwargs): + auth = self.new_plugin(**kwargs) + auth_ref = auth.get_auth_ref(self.session) + self.assertIsInstance(auth_ref, access.AccessInfoV3) + self.assertEqual(self.TEST_URL + 'v3/auth/tokens', + self.requests.last_request.url) + self.assertIsInstance(auth._plugin, self.V3_PLUGIN_CLASS) + return auth + + def assertCreateV2(self, **kwargs): + auth = self.new_plugin(**kwargs) + auth_ref = auth.get_auth_ref(self.session) + self.assertIsInstance(auth_ref, access.AccessInfoV2) + self.assertEqual(self.TEST_URL + 'v2.0/tokens', + self.requests.last_request.url) + self.assertIsInstance(auth._plugin, self.V2_PLUGIN_CLASS) + return auth + + def assertDiscoveryFailure(self, **kwargs): + plugin = self.new_plugin(**kwargs) + self.assertRaises(exceptions.DiscoveryFailure, + plugin.get_auth_ref, + self.session) + + def test_create_v3_if_domain_params(self): + self.stub_discovery() + + self.assertCreateV3(domain_id=uuid.uuid4().hex) + self.assertCreateV3(domain_name=uuid.uuid4().hex) + self.assertCreateV3(project_name=uuid.uuid4().hex, + project_domain_name=uuid.uuid4().hex) + self.assertCreateV3(project_name=uuid.uuid4().hex, + project_domain_id=uuid.uuid4().hex) + + def test_create_v2_if_no_domain_params(self): + self.stub_discovery() + self.assertCreateV2() + self.assertCreateV2(project_id=uuid.uuid4().hex) + self.assertCreateV2(project_name=uuid.uuid4().hex) + self.assertCreateV2(tenant_id=uuid.uuid4().hex) + self.assertCreateV2(tenant_name=uuid.uuid4().hex) + + def test_v3_params_v2_url(self): + self.stub_discovery(v3=False) + self.assertDiscoveryFailure(domain_name=uuid.uuid4().hex) + + def test_v2_params_v3_url(self): + self.stub_discovery(v2=False) + self.assertCreateV3() + + def test_no_urls(self): + self.stub_discovery(v2=False, v3=False) + self.assertDiscoveryFailure() + + def test_path_based_url_v2(self): + self.stub_url('GET', ['v2.0'], status_code=403) + self.assertCreateV2(auth_url=self.TEST_URL + 'v2.0') + + def test_path_based_url_v3(self): + self.stub_url('GET', ['v3'], status_code=403) + self.assertCreateV3(auth_url=self.TEST_URL + 'v3') + + def test_disc_error_for_failure(self): + self.stub_url('GET', [], status_code=403) + self.assertDiscoveryFailure() + + def test_v3_plugin_from_failure(self): + url = self.TEST_URL + 'v3' + self.stub_url('GET', [], base_url=url, status_code=403) + self.assertCreateV3(auth_url=url) + + def test_unknown_discovery_version(self): + # make a v4 entry that's mostly the same as a v3 + self.stub_discovery(v2=False, v3_id='v4.0') + self.assertDiscoveryFailure() @@ -28,6 +28,8 @@ console_scripts = keystone = keystoneclient.shell:main keystoneclient.auth.plugin = + password = keystoneclient.auth.identity.generic:Password + token = keystoneclient.auth.identity.generic:Token v2password = keystoneclient.auth.identity.v2:Password v2token = keystoneclient.auth.identity.v2:Token v3password = keystoneclient.auth.identity.v3:Password @@ -35,6 +37,7 @@ keystoneclient.auth.plugin = v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken v3scopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2ScopedToken + [build_sphinx] source-dir = doc/source build-dir = doc/build |