Create a discoverable plugin

Move the version detection code into a plugin because it
does not belong in connection.  Create a new discoverable plugin
that looks at the auth_url and makes a best guess if it should
use v2 or v3 auth.

The plugin also now accepts access_info so a cached version
of the authorization can be reproduced without hitting
keystone.

Change-Id: Ia5d32457c90716627a75be2c99267c4ed7903197
This commit is contained in:
Terry Howe 2014-11-05 17:21:41 +01:00
parent 5edaf879ee
commit 72210d2ee6
10 changed files with 256 additions and 22 deletions

View File

@ -230,6 +230,13 @@ def option_parser():
default=env('OS_PASSWORD'), default=env('OS_PASSWORD'),
help='Authentication password (Env: OS_PASSWORD)', help='Authentication password (Env: OS_PASSWORD)',
) )
parser.add_argument(
'--os-access-info',
dest='access_info',
metavar='<access-info>',
default=env('OS_ACCESS_INFO'),
help='Access info (Env: OS_ACCESS_INFO)',
)
parser.add_argument( parser.add_argument(
'--os-api-name', '--os-api-name',
dest='user_preferences', dest='user_preferences',

View File

@ -0,0 +1,78 @@
# 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.
"""
Identity discoverable authorization plugin must be constructed with an
auhorization URL and a user id, user name or token. A user id or user name
would also require a password. The arguments that apply to the selected v2
or v3 plugin will be used. The rest of the arguments will be ignored. For
example::
from openstack.auth.identity import discoverable
from openstack import transport
args = {
'password': 'openSesame',
'auth_url': 'https://10.1.1.1:5000/v3/',
'user_name': 'alibaba',
}
auth = discoverable.Auth(**args)
xport = transport.Transport()
accessInfo = auth.authorize(xport)
"""
from openstack.auth.identity import base
from openstack.auth.identity import v2
from openstack.auth.identity import v3
from openstack import exceptions
class Auth(base.BaseIdentityPlugin):
#: Valid options for this plugin
valid_options = list(set(v2.Auth.valid_options + v3.Auth.valid_options))
def __init__(self, auth_url=None, **auth_args):
"""Construct an Identity Authentication Plugin.
This authorization plugin should be constructed with an auth_url
and everything needed by either a v2 or v3 identity plugin.
:param string auth_url: Identity service endpoint for authentication.
:raises TypeError: if a user_id, user_name or token is not provided.
"""
super(Auth, self).__init__(auth_url=auth_url)
if not auth_url:
msg = ("The authorization URL auth_url was not provided.")
raise exceptions.AuthorizationFailure(msg)
endpoint_version = auth_url.split('v')[-1][0]
if endpoint_version == '2':
plugin = v2.Auth
else:
plugin = v3.Auth
valid_list = plugin.valid_options
args = dict((n, auth_args[n]) for n in valid_list if n in auth_args)
self.auth_plugin = plugin(auth_url, **args)
@property
def token_url(self):
"""The full URL where we will send authentication data."""
return self.auth_plugin.token_url
def authorize(self, transport, **kwargs):
return self.auth_plugin.authorize(transport, **kwargs)
def invalidate(self):
return self.auth_plugin.invalidate()

View File

@ -41,6 +41,7 @@ class Auth(base.BaseIdentityPlugin):
#: Valid options for this plugin #: Valid options for this plugin
valid_options = [ valid_options = [
'access_info',
'auth_url', 'auth_url',
'user_name', 'user_name',
'user_id', 'user_id',
@ -53,6 +54,7 @@ class Auth(base.BaseIdentityPlugin):
] ]
def __init__(self, auth_url, def __init__(self, auth_url,
access_info=None,
user_name=None, user_name=None,
user_id=None, user_id=None,
password='', password='',
@ -68,6 +70,7 @@ class Auth(base.BaseIdentityPlugin):
:class:`~openstack.auth.identity.base.BaseIdentityPlugin`. :class:`~openstack.auth.identity.base.BaseIdentityPlugin`.
:param string auth_url: Identity service endpoint for authorization. :param string auth_url: Identity service endpoint for authorization.
:param string access_info: Access info from previous authentication.
:param string user_name: Username for authentication. :param string user_name: Username for authentication.
:param string user_id: User ID for authentication. :param string user_id: User ID for authentication.
:param string password: Password for authentication. :param string password: Password for authentication.
@ -86,6 +89,7 @@ class Auth(base.BaseIdentityPlugin):
msg = 'You need to specify either a user_name, user_id or token' msg = 'You need to specify either a user_name, user_id or token'
raise TypeError(msg) raise TypeError(msg)
self.access_info = access_info or None
self.user_id = user_id self.user_id = user_id
self.user_name = user_name self.user_name = user_name
self.password = password self.password = password
@ -96,6 +100,9 @@ class Auth(base.BaseIdentityPlugin):
def authorize(self, transport, **kwargs): def authorize(self, transport, **kwargs):
"""Obtain access information from an OpenStack Identity Service.""" """Obtain access information from an OpenStack Identity Service."""
if self.token and self.access_info:
return access.AccessInfoV2(**self.access_info)
headers = {'Accept': 'application/json'} headers = {'Accept': 'application/json'}
url = self.auth_url.rstrip('/') + '/tokens' url = self.auth_url.rstrip('/') + '/tokens'
params = {'auth': self.get_auth_data(headers)} params = {'auth': self.get_auth_data(headers)}
@ -132,6 +139,7 @@ class Auth(base.BaseIdentityPlugin):
def invalidate(self): def invalidate(self):
"""Invalidate the current authentication data.""" """Invalidate the current authentication data."""
if super(Auth, self).invalidate(): if super(Auth, self).invalidate():
self.access_info = None
self.token = None self.token = None
return True return True
return False return False

View File

@ -44,6 +44,7 @@ class Auth(base.BaseIdentityPlugin):
#: Valid options for this plugin #: Valid options for this plugin
valid_options = [ valid_options = [
'access_info',
'auth_url', 'auth_url',
'domain_id', 'domain_id',
'domain_name', 'domain_name',
@ -62,6 +63,7 @@ class Auth(base.BaseIdentityPlugin):
] ]
def __init__(self, auth_url, def __init__(self, auth_url,
access_info=None,
domain_id=None, domain_id=None,
domain_name=None, domain_name=None,
password='', password='',
@ -84,6 +86,7 @@ class Auth(base.BaseIdentityPlugin):
base class :class:`~openstack.auth.identity.base.BaseIdentityPlugin`. base class :class:`~openstack.auth.identity.base.BaseIdentityPlugin`.
:param string auth_url: Identity service endpoint for authentication. :param string auth_url: Identity service endpoint for authentication.
:param string access_info: Access info including service catalog.
:param string domain_id: Domain ID for domain scoping. :param string domain_id: Domain ID for domain scoping.
:param string domain_name: Domain name for domain scoping. :param string domain_name: Domain name for domain scoping.
:param string password: User password for authentication. :param string password: User password for authentication.
@ -109,6 +112,7 @@ class Auth(base.BaseIdentityPlugin):
msg = 'You need to specify either a user_name, user_id or token' msg = 'You need to specify either a user_name, user_id or token'
raise TypeError(msg) raise TypeError(msg)
self.access_info = access_info
self.domain_id = domain_id self.domain_id = domain_id
self.domain_name = domain_name self.domain_name = domain_name
self.project_domain_id = project_domain_id self.project_domain_id = project_domain_id
@ -128,6 +132,7 @@ class Auth(base.BaseIdentityPlugin):
self.token_method = TokenMethod(token=token) self.token_method = TokenMethod(token=token)
self.auth_methods = [self.token_method] self.auth_methods = [self.token_method]
else: else:
self.token_method = None
self.auth_methods = [self.password_method] self.auth_methods = [self.password_method]
@property @property
@ -141,6 +146,10 @@ class Auth(base.BaseIdentityPlugin):
body = {'auth': {'identity': {}}} body = {'auth': {'identity': {}}}
ident = body['auth']['identity'] ident = body['auth']['identity']
if self.token_method and self.access_info:
return access.AccessInfoV3(self.token_method.token,
**self.access_info)
for method in self.auth_methods: for method in self.auth_methods:
name, auth_data = method.get_auth_data(transport, self, headers) name, auth_data = method.get_auth_data(transport, self, headers)
ident.setdefault('methods', []).append(name) ident.setdefault('methods', []).append(name)
@ -192,6 +201,7 @@ class Auth(base.BaseIdentityPlugin):
"""Invalidate the current authentication data.""" """Invalidate the current authentication data."""
if super(Auth, self).invalidate(): if super(Auth, self).invalidate():
self.auth_methods = [self.password_method] self.auth_methods = [self.password_method]
self.access_info = None
return True return True
return False return False

View File

@ -63,7 +63,6 @@ import sys
from stevedore import driver from stevedore import driver
from openstack import exceptions
from openstack import session from openstack import session
from openstack import transport as xport from openstack import transport as xport
@ -142,15 +141,7 @@ class Connection(object):
if authenticator: if authenticator:
return authenticator return authenticator
if auth_plugin is None: if auth_plugin is None:
if 'auth_url' not in auth_args: auth_plugin = 'identity'
msg = ("auth_url was not provided.")
raise exceptions.AuthorizationFailure(msg)
auth_url = auth_args['auth_url']
endpoint_version = auth_url.split('v')[-1][0]
if endpoint_version == '2':
auth_plugin = 'identity_v2'
else:
auth_plugin = 'identity_v3'
mgr = driver.DriverManager( mgr = driver.DriverManager(
namespace=self.AUTH_PLUGIN_NAMESPACE, namespace=self.AUTH_PLUGIN_NAMESPACE,

View File

@ -0,0 +1,97 @@
# 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 mock
import testtools
from openstack.auth.identity import discoverable
from openstack import exceptions
from openstack.tests.auth import common
class TestDiscoverableAuth(testtools.TestCase):
def test_valid_options(self):
expected = [
'access_info',
'auth_url',
'domain_id',
'domain_name',
'password',
'project_domain_id',
'project_domain_name',
'project_id',
'project_name',
'reauthenticate',
'token',
'trust_id',
'user_domain_id',
'user_domain_name',
'user_id',
'user_name',
]
self.assertEqual(expected, sorted(discoverable.Auth.valid_options))
def test_create2(self):
auth_args = {
'auth_url': 'http://localhost/v2',
'user_name': '1',
'password': '2',
}
auth = discoverable.Auth(**auth_args)
self.assertEqual('openstack.auth.identity.v2',
auth.auth_plugin.__class__.__module__)
def test_create3(self):
auth_args = {
'auth_url': 'http://localhost/v3',
'user_name': '1',
'password': '2',
}
auth = discoverable.Auth(**auth_args)
self.assertEqual('openstack.auth.identity.v3',
auth.auth_plugin.__class__.__module__)
def test_create_who_knows(self):
auth_args = {
'auth_url': 'http://localhost:5000/',
'user_name': '1',
'password': '2',
}
auth = discoverable.Auth(**auth_args)
self.assertEqual('openstack.auth.identity.v3',
auth.auth_plugin.__class__.__module__)
def test_create_authenticator_no_nothing(self):
self.assertRaises(
exceptions.AuthorizationFailure,
discoverable.Auth,
)
def test_methods(self):
auth_args = {
'auth_url': 'http://localhost:5000/',
'user_name': '1',
'password': '2',
}
auth = discoverable.Auth(**auth_args)
self.assertEqual('http://localhost:5000/auth/tokens', auth.token_url)
xport = mock.MagicMock()
xport.post = mock.Mock()
response = mock.Mock()
response.json = mock.Mock()
response.json.return_value = common.TEST_RESPONSE_DICT_V3
response.headers = {'X-Subject-Token': common.TEST_SUBJECT}
xport.post.return_value = response
result = auth.authorize(xport)
self.assertEqual(common.TEST_SUBJECT, result.auth_token)
self.assertEqual(True, auth.invalidate())

View File

@ -170,6 +170,20 @@ class TestV2Auth(testtools.TestCase):
ecatalog['version'] = 'v2.0' ecatalog['version'] = 'v2.0'
self.assertEqual(ecatalog, resp._info) self.assertEqual(ecatalog, resp._info)
def test_authorize_token_access_info(self):
ecatalog = TEST_RESPONSE_DICT['access'].copy()
ecatalog['version'] = 'v2.0'
kargs = {
'access_info': ecatalog,
'token': common.TEST_TOKEN,
}
sot = v2.Auth(TEST_URL, **kargs)
xport = self.create_mock_transport(TEST_RESPONSE_DICT)
resp = sot.authorize(xport)
self.assertEqual(ecatalog, resp._info)
def test_authorize_bad_response(self): def test_authorize_bad_response(self):
kargs = {'token': common.TEST_TOKEN} kargs = {'token': common.TEST_TOKEN}
sot = v2.Auth(TEST_URL, **kargs) sot = v2.Auth(TEST_URL, **kargs)
@ -179,6 +193,7 @@ class TestV2Auth(testtools.TestCase):
def test_invalidate(self): def test_invalidate(self):
kargs = { kargs = {
'access_info': {'a': 'b'},
'password': common.TEST_PASS, 'password': common.TEST_PASS,
'token': common.TEST_TOKEN, 'token': common.TEST_TOKEN,
'user_name': common.TEST_USER, 'user_name': common.TEST_USER,
@ -194,11 +209,14 @@ class TestV2Auth(testtools.TestCase):
expected = {'passwordCredentials': {'password': common.TEST_PASS, expected = {'passwordCredentials': {'password': common.TEST_PASS,
'username': common.TEST_USER}} 'username': common.TEST_USER}}
headers = {} headers = {}
self.assertEqual(None, sot.token)
self.assertEqual(None, sot.access_info)
self.assertEqual(expected, sot.get_auth_data(headers)) self.assertEqual(expected, sot.get_auth_data(headers))
self.assertEqual({}, headers) self.assertEqual({}, headers)
def test_valid_options(self): def test_valid_options(self):
expected = [ expected = [
'access_info',
'auth_url', 'auth_url',
'user_name', 'user_name',
'user_id', 'user_id',

View File

@ -138,6 +138,21 @@ class TestV3Auth(testtools.TestCase):
ecatalog['version'] = 'v3' ecatalog['version'] = 'v3'
self.assertEqual(ecatalog, resp._info) self.assertEqual(ecatalog, resp._info)
def test_authorize_token_access_info(self):
ecatalog = common.TEST_RESPONSE_DICT_V3['token'].copy()
kargs = {
'access_info': ecatalog,
'token': common.TEST_TOKEN,
}
sot = v3.Auth(TEST_URL, **kargs)
xport = self.create_mock_transport(common.TEST_RESPONSE_DICT_V3)
resp = sot.authorize(xport)
ecatalog['auth_token'] = common.TEST_TOKEN
ecatalog['version'] = 'v3'
self.assertEqual(ecatalog, resp._info)
def test_authorize_token_domain_id(self): def test_authorize_token_domain_id(self):
kargs = { kargs = {
'domain_id': common.TEST_DOMAIN_ID, 'domain_id': common.TEST_DOMAIN_ID,
@ -317,6 +332,7 @@ class TestV3Auth(testtools.TestCase):
'user_name': common.TEST_USER, 'user_name': common.TEST_USER,
'password': common.TEST_PASS, 'password': common.TEST_PASS,
'token': common.TEST_TOKEN, 'token': common.TEST_TOKEN,
'access_info': {},
} }
sot = v3.Auth(TEST_URL, **kargs) sot = v3.Auth(TEST_URL, **kargs)
self.assertEqual(1, len(sot.auth_methods)) self.assertEqual(1, len(sot.auth_methods))
@ -325,6 +341,7 @@ class TestV3Auth(testtools.TestCase):
self.assertEqual(True, sot.invalidate()) self.assertEqual(True, sot.invalidate())
self.assertEqual(None, sot.access_info)
self.assertEqual(1, len(sot.auth_methods)) self.assertEqual(1, len(sot.auth_methods))
auther = sot.auth_methods[0] auther = sot.auth_methods[0]
self.assertEqual(common.TEST_USER, auther.user_name) self.assertEqual(common.TEST_USER, auther.user_name)
@ -332,6 +349,7 @@ class TestV3Auth(testtools.TestCase):
def test_valid_options(self): def test_valid_options(self):
expected = [ expected = [
'access_info',
'auth_url', 'auth_url',
'domain_id', 'domain_id',
'domain_name', 'domain_name',

View File

@ -62,24 +62,30 @@ class TestConnection(base.TestCase):
self.assertEqual('1', conn.authenticator.password_method.user_name) self.assertEqual('1', conn.authenticator.password_method.user_name)
self.assertEqual('2', conn.authenticator.password_method.password) self.assertEqual('2', conn.authenticator.password_method.password)
def test_create_authenticator_no_name_2(self): def test_create_authenticator_discoverable(self):
auth_args = {
'auth_url': '0',
'user_name': '1',
'password': '2',
}
conn = connection.Connection(transport='0', auth_plugin='identity',
**auth_args)
self.assertEqual('0', conn.authenticator.auth_url)
self.assertEqual(
'1',
conn.authenticator.auth_plugin.password_method.user_name)
self.assertEqual(
'2',
conn.authenticator.auth_plugin.password_method.password)
def test_create_authenticator_no_name(self):
auth_args = { auth_args = {
'auth_url': 'http://localhost/v2', 'auth_url': 'http://localhost/v2',
'user_name': '1', 'user_name': '1',
'password': '2', 'password': '2',
} }
conn = connection.Connection(transport='0', **auth_args) conn = connection.Connection(transport='0', **auth_args)
self.assertEqual('openstack.auth.identity.v2', self.assertEqual('openstack.auth.identity.discoverable',
conn.authenticator.__class__.__module__)
def test_create_authenticator_no_name_3(self):
auth_args = {
'auth_url': 'http://localhost/v3',
'user_name': '1',
'password': '2',
}
conn = connection.Connection(transport='0', **auth_args)
self.assertEqual('openstack.auth.identity.v3',
conn.authenticator.__class__.__module__) conn.authenticator.__class__.__module__)
def test_create_authenticator_no_nothing(self): def test_create_authenticator_no_nothing(self):

View File

@ -52,3 +52,4 @@ universal = 1
openstack.auth.plugin = openstack.auth.plugin =
identity_v2 = openstack.auth.identity.v2:Auth identity_v2 = openstack.auth.identity.v2:Auth
identity_v3 = openstack.auth.identity.v3:Auth identity_v3 = openstack.auth.identity.v3:Auth
identity = openstack.auth.identity.discoverable:Auth