From 0c77a9fe8baa4df9ea2d0055db9c700af3cae310 Mon Sep 17 00:00:00 2001 From: Matthieu Huin Date: Fri, 18 Jul 2014 19:18:25 +0200 Subject: [PATCH] Support for keystone auth plugins This patch allows the user to choose which authentication plugin to use with the CLI. The arguments needed by the auth plugins are automatically added to the argument parser. Some examples with the currently available authentication plugins:: OS_USERNAME=admin OS_PROJECT_NAME=admin OS_AUTH_URL=http://keystone:5000/v2.0 \ OS_PASSWORD=admin openstack user list OS_USERNAME=admin OS_PROJECT_DOMAIN_NAME=default OS_USER_DOMAIN_NAME=default \ OS_PROJECT_NAME=admin OS_AUTH_URL=http://keystone:5000/v3 OS_PASSWORD=admin \ OS_IDENTITY_API_VERSION=3 OS_AUTH_PLUGIN=v3password openstack project list OS_TOKEN=1234 OS_URL=http://service_url:35357/v2.0 \ OS_IDENTITY_API_VERSION=2.0 openstack user list The --os-auth-plugin option can be omitted; if so the CLI will attempt to guess which plugin to use from the other options. Change-Id: I330c20ddb8d96b3a4287c68b57c36c4a0f869669 Co-Authored-By: Florent Flament --- doc/source/man/openstack.rst | 34 ++++ openstackclient/api/auth.py | 180 ++++++++++++++++++ openstackclient/common/clientmanager.py | 125 +++++------- openstackclient/identity/client.py | 21 +- openstackclient/shell.py | 164 +++++----------- .../tests/common/test_clientmanager.py | 170 ++++++++++++----- openstackclient/tests/fakes.py | 136 +++++++++++++ openstackclient/tests/test_shell.py | 86 ++++----- requirements.txt | 1 + 9 files changed, 613 insertions(+), 304 deletions(-) create mode 100644 openstackclient/api/auth.py diff --git a/doc/source/man/openstack.rst b/doc/source/man/openstack.rst index b8dcbd6b6..de2bbe92f 100644 --- a/doc/source/man/openstack.rst +++ b/doc/source/man/openstack.rst @@ -21,6 +21,10 @@ DESCRIPTION equivalent to the CLIs provided by the OpenStack project client libraries, but with a distinct and consistent command structure. + +AUTHENTICATION METHODS +====================== + :program:`openstack` uses a similar authentication scheme as the OpenStack project CLIs, with the credential information supplied either as environment variables or as options on the command line. The primary difference is the use of 'project' in the name of the options @@ -33,6 +37,15 @@ command line. The primary difference is the use of 'project' in the name of the export OS_USERNAME= export OS_PASSWORD= # (optional) +:program:`openstack` can use different types of authentication plugins provided by the keystoneclient library. The following default plugins are available: + +* ``token``: Authentication with a token +* ``password``: Authentication with a username and a password + +Refer to the keystoneclient library documentation for more details about these plugins and their options, and for a complete list of available plugins. +Please bear in mind that some plugins might not support all of the functionalities of :program:`openstack`; for example the v3unscopedsaml plugin can deliver only unscoped tokens, some commands might not be available through this authentication method. + +Additionally, it is possible to use Keystone's service token to authenticate, by setting the options :option:`--os-token` and :option:`--os-url` (or the environment variables :envvar:`OS_TOKEN` and :envvar:`OS_URL` respectively). This method takes precedence over authentication plugins. OPTIONS ======= @@ -41,9 +54,16 @@ OPTIONS :program:`openstack` recognizes the following global topions: +:option:`--os-auth-plugin` + The authentication plugin to use when connecting to the Identity service. If this option is not set, :program:`openstack` will attempt to guess the authentication method to use based on the other options. + If this option is set, its version must match :option:`--os-identity-api-version` + :option:`--os-auth-url` Authentication URL +:option:`--os-url` + Service URL, when using a service token for authentication + :option:`--os-domain-name` | :option:`--os-domain-id` Domain-level authorization scope (name or ID) @@ -59,6 +79,9 @@ OPTIONS :option:`--os-password` Authentication password +:option:`--os-token` + Authenticated token or service token + :option:`--os-user-domain-name` | :option:`--os-user-domain-id` Domain name or id containing user @@ -86,6 +109,7 @@ OPTIONS :option:`--os-XXXX-api-version` Additional API version options will be available depending on the installed API libraries. + COMMANDS ======== @@ -174,9 +198,15 @@ ENVIRONMENT VARIABLES The following environment variables can be set to alter the behaviour of :program:`openstack`. Most of them have corresponding command-line options that take precedence if set. +:envvar:`OS_AUTH_PLUGIN` + The authentication plugin to use when connecting to the Identity service, its version must match the Identity API version + :envvar:`OS_AUTH_URL` Authentication URL +:envvar:`OS_URL` + Service URL (when using the service token) + :envvar:`OS_DOMAIN_NAME` Domain-level authorization scope (name or ID) @@ -189,6 +219,9 @@ The following environment variables can be set to alter the behaviour of :progra :envvar:`OS_USERNAME` Authentication username +:envvar:`OS_TOKEN` + Authenticated or service token + :envvar:`OS_PASSWORD` Authentication password @@ -213,6 +246,7 @@ The following environment variables can be set to alter the behaviour of :progra :envvar:`OS_XXXX_API_VERSION` Additional API version options will be available depending on the installed API libraries. + BUGS ==== diff --git a/openstackclient/api/auth.py b/openstackclient/api/auth.py new file mode 100644 index 000000000..2bd5271f7 --- /dev/null +++ b/openstackclient/api/auth.py @@ -0,0 +1,180 @@ +# 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. +# + +"""Authentication Library""" + +import argparse +import logging + +import stevedore + +from keystoneclient.auth import base + +from openstackclient.common import exceptions as exc +from openstackclient.common import utils + + +LOG = logging.getLogger(__name__) + + +# Initialize the list of Authentication plugins early in order +# to get the command-line options +PLUGIN_LIST = stevedore.ExtensionManager( + base.PLUGIN_NAMESPACE, + invoke_on_load=False, + propagate_map_exceptions=True, +) +# TODO(dtroyer): add some method to list the plugins for the +# --os_auth_plugin option + +# Get the command line options so the help action has them available +OPTIONS_LIST = {} +for plugin in PLUGIN_LIST: + for o in plugin.plugin.get_options(): + os_name = o.dest.lower().replace('_', '-') + os_env_name = 'OS_' + os_name.upper().replace('-', '_') + OPTIONS_LIST.setdefault(os_name, {'env': os_env_name, 'help': ''}) + # TODO(mhu) simplistic approach, would be better to only add + # help texts if they vary from one auth plugin to another + # also the text rendering is ugly in the CLI ... + OPTIONS_LIST[os_name]['help'] += 'With %s: %s\n' % ( + plugin.name, + o.help, + ) + + +def _guess_authentication_method(options): + """If no auth plugin was specified, pick one based on other options""" + + if options.os_url: + # service token authentication, do nothing + return + auth_plugin = None + if options.os_password: + if options.os_identity_api_version == '3': + auth_plugin = 'v3password' + elif options.os_identity_api_version == '2.0': + auth_plugin = 'v2password' + else: + # let keystoneclient figure it out itself + auth_plugin = 'password' + elif options.os_token: + if options.os_identity_api_version == '3': + auth_plugin = 'v3token' + elif options.os_identity_api_version == '2.0': + auth_plugin = 'v2token' + else: + # let keystoneclient figure it out itself + auth_plugin = 'token' + else: + raise exc.CommandError( + "Could not figure out which authentication method " + "to use, please set --os-auth-plugin" + ) + LOG.debug("No auth plugin selected, picking %s from other " + "options" % auth_plugin) + options.os_auth_plugin = auth_plugin + + +def build_auth_params(cmd_options): + auth_params = {} + if cmd_options.os_url: + return {'token': cmd_options.os_token} + if cmd_options.os_auth_plugin: + auth_plugin = base.get_plugin_class(cmd_options.os_auth_plugin) + plugin_options = auth_plugin.get_options() + for option in plugin_options: + option_name = 'os_' + option.dest + LOG.debug('fetching option %s' % option_name) + auth_params[option.dest] = getattr(cmd_options, option_name, None) + # grab tenant from project for v2.0 API compatibility + if cmd_options.os_auth_plugin.startswith("v2"): + auth_params['tenant_id'] = getattr( + cmd_options, + 'os_project_id', + None, + ) + auth_params['tenant_name'] = getattr( + cmd_options, + 'os_project_name', + None, + ) + else: + # delay the plugin choice, grab every option + plugin_options = set([o.replace('-', '_') for o in OPTIONS_LIST]) + for option in plugin_options: + option_name = 'os_' + option + LOG.debug('fetching option %s' % option_name) + auth_params[option] = getattr(cmd_options, option_name, None) + return auth_params + + +def build_auth_plugins_option_parser(parser): + """Auth plugins options builder + + Builds dynamically the list of options expected by each available + authentication plugin. + + """ + available_plugins = [plugin.name for plugin in PLUGIN_LIST] + parser.add_argument( + '--os-auth-plugin', + metavar='', + default=utils.env('OS_AUTH_PLUGIN'), + help='The authentication method to use. If this option is not set, ' + 'openstackclient will attempt to guess the authentication method ' + 'to use based on the other options. If this option is set, ' + 'the --os-identity-api-version argument must be consistent ' + 'with the version of the method.\nAvailable methods are ' + + ', '.join(available_plugins), + choices=available_plugins + ) + # make sur we catch old v2.0 env values + envs = { + 'OS_PROJECT_NAME': utils.env( + 'OS_PROJECT_NAME', + default=utils.env('OS_TENANT_NAME') + ), + 'OS_PROJECT_ID': utils.env( + 'OS_PROJECT_ID', + default=utils.env('OS_TENANT_ID') + ), + } + for o in OPTIONS_LIST: + # remove allusion to tenants from v2.0 API + if 'tenant' not in o: + parser.add_argument( + '--os-' + o, + metavar='' % o, + default=envs.get(OPTIONS_LIST[o]['env'], + utils.env(OPTIONS_LIST[o]['env'])), + help='%s\n(Env: %s)' % (OPTIONS_LIST[o]['help'], + OPTIONS_LIST[o]['env']), + ) + # add tenant-related options for compatibility + # this is deprecated but still used in some tempest tests... + parser.add_argument( + '--os-tenant-name', + metavar='', + dest='os_project_name', + default=utils.env('OS_TENANT_NAME'), + help=argparse.SUPPRESS, + ) + parser.add_argument( + '--os-tenant-id', + metavar='', + dest='os_project_id', + default=utils.env('OS_TENANT_ID'), + help=argparse.SUPPRESS, + ) + return parser diff --git a/openstackclient/common/clientmanager.py b/openstackclient/common/clientmanager.py index 4206ad001..0542b4736 100644 --- a/openstackclient/common/clientmanager.py +++ b/openstackclient/common/clientmanager.py @@ -19,9 +19,11 @@ import logging import pkg_resources import sys -from keystoneclient.auth.identity import v2 as v2_auth -from keystoneclient.auth.identity import v3 as v3_auth +from keystoneclient.auth import base from keystoneclient import session +import requests + +from openstackclient.api import auth from openstackclient.identity import client as identity_client @@ -45,105 +47,66 @@ class ClientManager(object): """Manages access to API clients, including authentication.""" identity = ClientCache(identity_client.make_client) - def __init__(self, token=None, url=None, auth_url=None, - domain_id=None, domain_name=None, - project_name=None, project_id=None, - username=None, password=None, - user_domain_id=None, user_domain_name=None, - project_domain_id=None, project_domain_name=None, - region_name=None, api_version=None, verify=True, - trust_id=None, timing=None): - self._token = token - self._url = url - self._auth_url = auth_url - self._domain_id = domain_id - self._domain_name = domain_name - self._project_name = project_name - self._project_id = project_id - self._username = username - self._password = password - self._user_domain_id = user_domain_id - self._user_domain_name = user_domain_name - self._project_domain_id = project_domain_id - self._project_domain_name = project_domain_name - self._region_name = region_name + def __getattr__(self, name): + # this is for the auth-related parameters. + if name in ['_' + o.replace('-', '_') + for o in auth.OPTIONS_LIST]: + return self._auth_params[name[1:]] + + def __init__(self, auth_options, api_version=None, verify=True): + + if not auth_options.os_auth_plugin: + auth._guess_authentication_method(auth_options) + + self._auth_plugin = auth_options.os_auth_plugin + self._url = auth_options.os_url + self._auth_params = auth.build_auth_params(auth_options) + self._region_name = auth_options.os_region_name self._api_version = api_version - self._trust_id = trust_id self._service_catalog = None - self.timing = timing + self.timing = auth_options.timing + + # For compatability until all clients can be updated + if 'project_name' in self._auth_params: + self._project_name = self._auth_params['project_name'] + elif 'tenant_name' in self._auth_params: + self._project_name = self._auth_params['tenant_name'] # verify is the Requests-compatible form self._verify = verify # also store in the form used by the legacy client libs self._cacert = None - if verify is True or verify is False: + if isinstance(verify, bool): self._insecure = not verify else: self._cacert = verify self._insecure = False - ver_prefix = identity_client.AUTH_VERSIONS[ - self._api_version[identity_client.API_NAME] - ] - # Get logging from root logger root_logger = logging.getLogger('') LOG.setLevel(root_logger.getEffectiveLevel()) - # NOTE(dtroyer): These plugins are hard-coded for the first step - # in using the new Keystone auth plugins. - - if self._url: - LOG.debug('Using token auth %s', ver_prefix) - if ver_prefix == 'v2': - self.auth = v2_auth.Token( - auth_url=url, - token=token, - ) - else: - self.auth = v3_auth.Token( - auth_url=url, - token=token, - ) - else: - LOG.debug('Using password auth %s', ver_prefix) - if ver_prefix == 'v2': - self.auth = v2_auth.Password( - auth_url=auth_url, - username=username, - password=password, - trust_id=trust_id, - tenant_id=project_id, - tenant_name=project_name, - ) - else: - self.auth = v3_auth.Password( - auth_url=auth_url, - username=username, - password=password, - trust_id=trust_id, - user_domain_id=user_domain_id, - user_domain_name=user_domain_name, - domain_id=domain_id, - domain_name=domain_name, - project_id=project_id, - project_name=project_name, - project_domain_id=project_domain_id, - project_domain_name=project_domain_name, - ) - - self.session = session.Session( - auth=self.auth, - verify=verify, - ) + self.session = None + if not self._url: + LOG.debug('Using auth plugin: %s' % self._auth_plugin) + auth_plugin = base.get_plugin_class(self._auth_plugin) + self.auth = auth_plugin.load_from_options(**self._auth_params) + # needed by SAML authentication + request_session = requests.session() + self.session = session.Session( + auth=self.auth, + session=request_session, + verify=verify, + ) self.auth_ref = None - if not self._url: - # Trigger the auth call + if not self._auth_plugin.endswith("token") and not self._url: + LOG.debug("Populate other password flow attributes") self.auth_ref = self.session.auth.get_auth_ref(self.session) - # Populate other password flow attributes self._token = self.session.auth.get_token(self.session) self._service_catalog = self.auth_ref.service_catalog + else: + self._token = self._auth_params.get('token') return @@ -156,7 +119,7 @@ class ClientManager(object): service_type=service_type) else: # Hope we were given the correct URL. - endpoint = self._url + endpoint = self._auth_url or self._url return endpoint diff --git a/openstackclient/identity/client.py b/openstackclient/identity/client.py index a43b50e37..bc10a6d23 100644 --- a/openstackclient/identity/client.py +++ b/openstackclient/identity/client.py @@ -16,9 +16,9 @@ import logging from keystoneclient.v2_0 import client as identity_client_v2_0 +from openstackclient.api import auth from openstackclient.common import utils - LOG = logging.getLogger(__name__) DEFAULT_IDENTITY_API_VERSION = '2.0' @@ -47,16 +47,15 @@ def make_client(instance): # TODO(dtroyer): Something doesn't like the session.auth when using # token auth, chase that down. if instance._url: - LOG.debug('Using token auth') + LOG.debug('Using service token auth') client = identity_client( endpoint=instance._url, - token=instance._token, + token=instance._auth_params['token'], cacert=instance._cacert, - insecure=instance._insecure, - trust_id=instance._trust_id, + insecure=instance._insecure ) else: - LOG.debug('Using password auth') + LOG.debug('Using auth plugin: %s' % instance._auth_plugin) client = identity_client( session=instance.session, cacert=instance._cacert, @@ -66,7 +65,6 @@ def make_client(instance): # so we can remove it if not instance._url: instance.auth_ref = instance.auth.get_auth_ref(instance.session) - return client @@ -81,14 +79,7 @@ def build_option_parser(parser): help='Identity API version, default=' + DEFAULT_IDENTITY_API_VERSION + ' (Env: OS_IDENTITY_API_VERSION)') - parser.add_argument( - '--os-trust-id', - metavar='', - default=utils.env('OS_TRUST_ID'), - help='Trust ID to use when authenticating. ' - 'This can only be used with Keystone v3 API ' - '(Env: OS_TRUST_ID)') - return parser + return auth.build_auth_plugins_option_parser(parser) class IdentityClientv2_0(identity_client_v2_0.Client): diff --git a/openstackclient/shell.py b/openstackclient/shell.py index 8db1656c8..626e3f7da 100644 --- a/openstackclient/shell.py +++ b/openstackclient/shell.py @@ -15,7 +15,6 @@ """Command-line interface to the OpenStack APIs""" -import argparse import getpass import logging import sys @@ -171,89 +170,13 @@ class OpenStackShell(app.App): parser = super(OpenStackShell, self).build_option_parser( description, version) - + # service token auth argument + parser.add_argument( + '--os-url', + metavar='', + default=utils.env('OS_URL'), + help='Defaults to env[OS_URL]') # Global arguments - parser.add_argument( - '--os-auth-url', - metavar='', - default=utils.env('OS_AUTH_URL'), - help='Authentication URL (Env: OS_AUTH_URL)') - parser.add_argument( - '--os-domain-name', - metavar='', - default=utils.env('OS_DOMAIN_NAME'), - help='Domain name of the requested domain-level ' - 'authorization scope (Env: OS_DOMAIN_NAME)', - ) - parser.add_argument( - '--os-domain-id', - metavar='', - default=utils.env('OS_DOMAIN_ID'), - help='Domain ID of the requested domain-level ' - 'authorization scope (Env: OS_DOMAIN_ID)', - ) - parser.add_argument( - '--os-project-name', - metavar='', - default=utils.env('OS_PROJECT_NAME', - default=utils.env('OS_TENANT_NAME')), - help='Project name of the requested project-level ' - 'authorization scope (Env: OS_PROJECT_NAME)', - ) - parser.add_argument( - '--os-tenant-name', - metavar='', - dest='os_project_name', - help=argparse.SUPPRESS, - ) - parser.add_argument( - '--os-project-id', - metavar='', - default=utils.env('OS_PROJECT_ID', - default=utils.env('OS_TENANT_ID')), - help='Project ID of the requested project-level ' - 'authorization scope (Env: OS_PROJECT_ID)', - ) - parser.add_argument( - '--os-tenant-id', - metavar='', - dest='os_project_id', - help=argparse.SUPPRESS, - ) - parser.add_argument( - '--os-username', - metavar='', - default=utils.env('OS_USERNAME'), - help='Authentication username (Env: OS_USERNAME)') - parser.add_argument( - '--os-password', - metavar='', - default=utils.env('OS_PASSWORD'), - help='Authentication password (Env: OS_PASSWORD)') - parser.add_argument( - '--os-user-domain-name', - metavar='', - default=utils.env('OS_USER_DOMAIN_NAME'), - help='Domain name of the user (Env: OS_USER_DOMAIN_NAME)') - parser.add_argument( - '--os-user-domain-id', - metavar='', - default=utils.env('OS_USER_DOMAIN_ID'), - help='Domain ID of the user (Env: OS_USER_DOMAIN_ID)') - parser.add_argument( - '--os-project-domain-name', - metavar='', - default=utils.env('OS_PROJECT_DOMAIN_NAME'), - help='Domain name of the project which is the requested ' - 'project-level authorization scope ' - '(Env: OS_PROJECT_DOMAIN_NAME)') - parser.add_argument( - '--os-project-domain-id', - metavar='', - default=utils.env('OS_PROJECT_DOMAIN_ID'), - help='Domain ID of the project which is the requested ' - 'project-level authorization scope ' - '(Env: OS_PROJECT_DOMAIN_ID)') parser.add_argument( '--os-region-name', metavar='', @@ -284,16 +207,6 @@ class OpenStackShell(app.App): help='Default domain ID, default=' + DEFAULT_DOMAIN + ' (Env: OS_DEFAULT_DOMAIN)') - parser.add_argument( - '--os-token', - metavar='', - default=utils.env('OS_TOKEN'), - help='Defaults to env[OS_TOKEN]') - parser.add_argument( - '--os-url', - metavar='', - default=utils.env('OS_URL'), - help='Defaults to env[OS_URL]') parser.add_argument( '--timing', default=False, @@ -306,20 +219,42 @@ class OpenStackShell(app.App): def authenticate_user(self): """Verify the required authentication credentials are present""" - self.log.debug('validating authentication options') - if self.options.os_token or self.options.os_url: + self.log.debug("validating authentication options") + + # Assuming all auth plugins will be named in the same fashion, + # ie vXpluginName + if (not self.options.os_url and + self.options.os_auth_plugin.startswith('v') and + self.options.os_auth_plugin[1] != + self.options.os_identity_api_version[0]): + raise exc.CommandError( + "Auth plugin %s not compatible" + " with requested API version" % self.options.os_auth_plugin + ) + # TODO(mhu) All these checks should be exposed at the plugin level + # or just dropped altogether, as the client instantiation will fail + # anyway + if self.options.os_url and not self.options.os_token: + # service token needed + raise exc.CommandError( + "You must provide a service token via" + " either --os-token or env[OS_TOKEN]") + + if (self.options.os_auth_plugin.endswith('token') and + (self.options.os_token or self.options.os_auth_url)): # Token flow auth takes priority if not self.options.os_token: raise exc.CommandError( "You must provide a token via" " either --os-token or env[OS_TOKEN]") - if not self.options.os_url: + if not self.options.os_auth_url: raise exc.CommandError( "You must provide a service URL via" - " either --os-url or env[OS_URL]") + " either --os-auth-url or env[OS_AUTH_URL]") - else: + if (not self.options.os_url and + not self.options.os_auth_plugin.endswith('token')): # Validate password flow auth if not self.options.os_username: raise exc.CommandError( @@ -347,13 +282,15 @@ class OpenStackShell(app.App): (self.options.os_domain_id or self.options.os_domain_name) or self.options.os_trust_id): - raise exc.CommandError( - "You must provide authentication scope as a project " - "or a domain via --os-project-id or env[OS_PROJECT_ID], " - "--os-project-name or env[OS_PROJECT_NAME], " - "--os-domain-id or env[OS_DOMAIN_ID], or" - "--os-domain-name or env[OS_DOMAIN_NAME], or " - "--os-trust-id or env[OS_TRUST_ID].") + if self.options.os_auth_plugin.endswith('password'): + raise exc.CommandError( + "You must provide authentication scope as a project " + "or a domain via --os-project-id " + "or env[OS_PROJECT_ID], " + "--os-project-name or env[OS_PROJECT_NAME], " + "--os-domain-id or env[OS_DOMAIN_ID], or" + "--os-domain-name or env[OS_DOMAIN_NAME], or " + "--os-trust-id or env[OS_TRUST_ID].") if not self.options.os_auth_url: raise exc.CommandError( @@ -375,24 +312,9 @@ class OpenStackShell(app.App): "Pick one of project, domain or trust.") self.client_manager = clientmanager.ClientManager( - token=self.options.os_token, - url=self.options.os_url, - auth_url=self.options.os_auth_url, - domain_id=self.options.os_domain_id, - domain_name=self.options.os_domain_name, - project_name=self.options.os_project_name, - project_id=self.options.os_project_id, - user_domain_id=self.options.os_user_domain_id, - user_domain_name=self.options.os_user_domain_name, - project_domain_id=self.options.os_project_domain_id, - project_domain_name=self.options.os_project_domain_name, - username=self.options.os_username, - password=self.options.os_password, - region_name=self.options.os_region_name, + auth_options=self.options, verify=self.verify, - timing=self.options.timing, api_version=self.api_version, - trust_id=self.options.os_trust_id, ) return diff --git a/openstackclient/tests/common/test_clientmanager.py b/openstackclient/tests/common/test_clientmanager.py index 0bb657adb..18461fb7e 100644 --- a/openstackclient/tests/common/test_clientmanager.py +++ b/openstackclient/tests/common/test_clientmanager.py @@ -12,34 +12,25 @@ # License for the specific language governing permissions and limitations # under the License. # - import mock +from requests_mock.contrib import fixture from keystoneclient.auth.identity import v2 as auth_v2 +from keystoneclient.openstack.common import jsonutils +from keystoneclient import service_catalog + +from openstackclient.api import auth from openstackclient.common import clientmanager +from openstackclient.common import exceptions as exc +from openstackclient.tests import fakes from openstackclient.tests import utils -AUTH_REF = {'a': 1} -AUTH_TOKEN = "foobar" -AUTH_URL = "http://0.0.0.0" -USERNAME = "itchy" -PASSWORD = "scratchy" -SERVICE_CATALOG = {'sc': '123'} +API_VERSION = {"identity": "2.0"} -API_VERSION = { - 'identity': '2.0', -} - - -def FakeMakeClient(instance): - return FakeClient() - - -class FakeClient(object): - auth_ref = AUTH_REF - auth_token = AUTH_TOKEN - service_catalog = SERVICE_CATALOG +AUTH_REF = {'version': 'v2.0'} +AUTH_REF.update(fakes.TEST_RESPONSE_DICT['access']) +SERVICE_CATALOG = service_catalog.ServiceCatalogV2(AUTH_REF) class Container(object): @@ -49,6 +40,18 @@ class Container(object): pass +class FakeOptions(object): + def __init__(self, **kwargs): + for option in auth.OPTIONS_LIST: + setattr(self, 'os_' + option.replace('-', '_'), None) + self.os_auth_plugin = None + self.os_identity_api_version = '2.0' + self.timing = None + self.os_region_name = None + self.os_url = None + self.__dict__.update(kwargs) + + class TestClientCache(utils.TestCase): def test_singleton(self): @@ -58,30 +61,38 @@ class TestClientCache(utils.TestCase): self.assertEqual(c.attr, c.attr) -@mock.patch('keystoneclient.session.Session') class TestClientManager(utils.TestCase): def setUp(self): super(TestClientManager, self).setUp() + self.mock = mock.Mock() + self.requests = self.useFixture(fixture.Fixture()) + # fake v2password token retrieval + self.stub_auth(json=fakes.TEST_RESPONSE_DICT) + # fake v3password token retrieval + self.stub_auth(json=fakes.TEST_RESPONSE_DICT_V3, + url='/'.join([fakes.AUTH_URL, 'auth/tokens'])) + # fake password version endpoint discovery + self.stub_auth(json=fakes.TEST_VERSIONS, + url=fakes.AUTH_URL, + verb='GET') - clientmanager.ClientManager.identity = \ - clientmanager.ClientCache(FakeMakeClient) - - def test_client_manager_token(self, mock): + def test_client_manager_token(self): client_manager = clientmanager.ClientManager( - token=AUTH_TOKEN, - url=AUTH_URL, - verify=True, + auth_options=FakeOptions(os_token=fakes.AUTH_TOKEN, + os_auth_url=fakes.AUTH_URL, + os_auth_plugin='v2token'), api_version=API_VERSION, + verify=True ) self.assertEqual( - AUTH_TOKEN, + fakes.AUTH_TOKEN, client_manager._token, ) self.assertEqual( - AUTH_URL, - client_manager._url, + fakes.AUTH_URL, + client_manager._auth_url, ) self.assertIsInstance( client_manager.auth, @@ -90,26 +101,26 @@ class TestClientManager(utils.TestCase): self.assertFalse(client_manager._insecure) self.assertTrue(client_manager._verify) - def test_client_manager_password(self, mock): + def test_client_manager_password(self): client_manager = clientmanager.ClientManager( - auth_url=AUTH_URL, - username=USERNAME, - password=PASSWORD, - verify=False, + auth_options=FakeOptions(os_auth_url=fakes.AUTH_URL, + os_username=fakes.USERNAME, + os_password=fakes.PASSWORD), api_version=API_VERSION, + verify=False, ) self.assertEqual( - AUTH_URL, + fakes.AUTH_URL, client_manager._auth_url, ) self.assertEqual( - USERNAME, + fakes.USERNAME, client_manager._username, ) self.assertEqual( - PASSWORD, + fakes.PASSWORD, client_manager._password, ) self.assertIsInstance( @@ -119,16 +130,87 @@ class TestClientManager(utils.TestCase): self.assertTrue(client_manager._insecure) self.assertFalse(client_manager._verify) - def test_client_manager_password_verify_ca(self, mock): + # These need to stick around until the old-style clients are gone + self.assertEqual( + AUTH_REF, + client_manager.auth_ref, + ) + self.assertEqual( + fakes.AUTH_TOKEN, + client_manager._token, + ) + self.assertEqual( + dir(SERVICE_CATALOG), + dir(client_manager._service_catalog), + ) + + def stub_auth(self, json=None, url=None, verb=None, **kwargs): + subject_token = fakes.AUTH_TOKEN + base_url = fakes.AUTH_URL + if json: + text = jsonutils.dumps(json) + headers = {'X-Subject-Token': subject_token, + 'Content-Type': 'application/json'} + if not url: + url = '/'.join([base_url, 'tokens']) + url = url.replace("/?", "?") + if not verb: + verb = 'POST' + self.requests.register_uri(verb, + url, + headers=headers, + text=text) + + def test_client_manager_password_verify_ca(self): client_manager = clientmanager.ClientManager( - auth_url=AUTH_URL, - username=USERNAME, - password=PASSWORD, - verify='cafile', + auth_options=FakeOptions(os_auth_url=fakes.AUTH_URL, + os_username=fakes.USERNAME, + os_password=fakes.PASSWORD, + os_auth_plugin='v2password'), api_version=API_VERSION, + verify='cafile', ) self.assertFalse(client_manager._insecure) self.assertTrue(client_manager._verify) self.assertEqual('cafile', client_manager._cacert) + + def _client_manager_guess_auth_plugin(self, auth_params, + api_version, auth_plugin): + auth_params['os_auth_plugin'] = auth_plugin + auth_params['os_identity_api_version'] = api_version + client_manager = clientmanager.ClientManager( + auth_options=FakeOptions(**auth_params), + api_version=API_VERSION, + verify=True + ) + self.assertEqual( + auth_plugin, + client_manager._auth_plugin, + ) + + def test_client_manager_guess_auth_plugin(self): + # test token auth + params = dict(os_token=fakes.AUTH_TOKEN, + os_auth_url=fakes.AUTH_URL) + self._client_manager_guess_auth_plugin(params, '2.0', 'v2token') + self._client_manager_guess_auth_plugin(params, '3', 'v3token') + self._client_manager_guess_auth_plugin(params, 'XXX', 'token') + # test service auth + params = dict(os_token=fakes.AUTH_TOKEN, os_url='test') + self._client_manager_guess_auth_plugin(params, 'XXX', '') + # test password auth + params = dict(os_auth_url=fakes.AUTH_URL, + os_username=fakes.USERNAME, + os_password=fakes.PASSWORD) + self._client_manager_guess_auth_plugin(params, '2.0', 'v2password') + self._client_manager_guess_auth_plugin(params, '3', 'v3password') + self._client_manager_guess_auth_plugin(params, 'XXX', 'password') + + def test_client_manager_guess_auth_plugin_failure(self): + self.assertRaises(exc.CommandError, + clientmanager.ClientManager, + auth_options=FakeOptions(os_auth_plugin=''), + api_version=API_VERSION, + verify=True) diff --git a/openstackclient/tests/fakes.py b/openstackclient/tests/fakes.py index 5a1fc005e..f8b7bb6f3 100644 --- a/openstackclient/tests/fakes.py +++ b/openstackclient/tests/fakes.py @@ -22,6 +22,142 @@ import requests AUTH_TOKEN = "foobar" AUTH_URL = "http://0.0.0.0" +USERNAME = "itchy" +PASSWORD = "scratchy" +TEST_RESPONSE_DICT = { + "access": { + "metadata": { + "is_admin": 0, + "roles": [ + "1234", + ] + }, + "serviceCatalog": [ + { + "endpoints": [ + { + "adminURL": AUTH_URL + "/v2.0", + "id": "1234", + "internalURL": AUTH_URL + "/v2.0", + "publicURL": AUTH_URL + "/v2.0", + "region": "RegionOne" + } + ], + "endpoints_links": [], + "name": "keystone", + "type": "identity" + } + ], + "token": { + "expires": "2035-01-01T00:00:01Z", + "id": AUTH_TOKEN, + "issued_at": "2013-01-01T00:00:01.692048", + "tenant": { + "description": None, + "enabled": True, + "id": "1234", + "name": "testtenant" + } + }, + "user": { + "id": "5678", + "name": USERNAME, + "roles": [ + { + "name": "testrole" + }, + ], + "roles_links": [], + "username": USERNAME + } + } +} +TEST_RESPONSE_DICT_V3 = { + "token": { + "audit_ids": [ + "a" + ], + "catalog": [ + ], + "expires_at": "2034-09-29T18:27:15.978064Z", + "extras": {}, + "issued_at": "2014-09-29T17:27:15.978097Z", + "methods": [ + "password" + ], + "project": { + "domain": { + "id": "default", + "name": "Default" + }, + "id": "bbb", + "name": "project" + }, + "roles": [ + ], + "user": { + "domain": { + "id": "default", + "name": "Default" + }, + "id": "aaa", + "name": USERNAME + } + } +} +TEST_VERSIONS = { + "versions": { + "values": [ + { + "id": "v3.0", + "links": [ + { + "href": AUTH_URL, + "rel": "self" + } + ], + "media-types": [ + { + "base": "application/json", + "type": "application/vnd.openstack.identity-v3+json" + }, + { + "base": "application/xml", + "type": "application/vnd.openstack.identity-v3+xml" + } + ], + "status": "stable", + "updated": "2013-03-06T00:00:00Z" + }, + { + "id": "v2.0", + "links": [ + { + "href": AUTH_URL, + "rel": "self" + }, + { + "href": "http://docs.openstack.org/", + "rel": "describedby", + "type": "text/html" + } + ], + "media-types": [ + { + "base": "application/json", + "type": "application/vnd.openstack.identity-v2.0+json" + }, + { + "base": "application/xml", + "type": "application/vnd.openstack.identity-v2.0+xml" + } + ], + "status": "stable", + "updated": "2014-04-17T00:00:00Z" + } + ] + } +} class FakeStdout: diff --git a/openstackclient/tests/test_shell.py b/openstackclient/tests/test_shell.py index c180289e7..b0c1452ef 100644 --- a/openstackclient/tests/test_shell.py +++ b/openstackclient/tests/test_shell.py @@ -34,6 +34,8 @@ DEFAULT_PASSWORD = "password" DEFAULT_REGION_NAME = "ZZ9_Plural_Z_Alpha" DEFAULT_TOKEN = "token" DEFAULT_SERVICE_URL = "http://127.0.0.1:8771/v3.0/" +DEFAULT_AUTH_PLUGIN = "v2password" + DEFAULT_COMPUTE_API_VERSION = "2" DEFAULT_IDENTITY_API_VERSION = "2.0" @@ -106,6 +108,8 @@ class TestShell(utils.TestCase): default_args["region_name"]) self.assertEqual(_shell.options.os_trust_id, default_args["trust_id"]) + self.assertEqual(_shell.options.os_auth_plugin, + default_args['auth_plugin']) def _assert_token_auth(self, cmd_options, default_args): with mock.patch("openstackclient.shell.OpenStackShell.initialize_app", @@ -115,7 +119,8 @@ class TestShell(utils.TestCase): self.app.assert_called_with(["list", "role"]) self.assertEqual(_shell.options.os_token, default_args["os_token"]) - self.assertEqual(_shell.options.os_url, default_args["os_url"]) + self.assertEqual(_shell.options.os_auth_url, + default_args["os_auth_url"]) def _assert_cli(self, cmd_options, default_args): with mock.patch("openstackclient.shell.OpenStackShell.initialize_app", @@ -175,9 +180,9 @@ class TestShellPasswordAuth(TestShell): "auth_url": DEFAULT_AUTH_URL, "project_id": "", "project_name": "", + "user_domain_id": "", "domain_id": "", "domain_name": "", - "user_domain_id": "", "user_domain_name": "", "project_domain_id": "", "project_domain_name": "", @@ -185,6 +190,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -204,6 +210,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -223,44 +230,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", - } - self._assert_password_auth(flag, kwargs) - - def test_only_tenant_id_flow(self): - flag = "--os-tenant-id " + DEFAULT_PROJECT_ID - kwargs = { - "auth_url": "", - "project_id": DEFAULT_PROJECT_ID, - "project_name": "", - "domain_id": "", - "domain_name": "", - "user_domain_id": "", - "user_domain_name": "", - "project_domain_id": "", - "project_domain_name": "", - "username": "", - "password": "", - "region_name": "", - "trust_id": "", - } - self._assert_password_auth(flag, kwargs) - - def test_only_tenant_name_flow(self): - flag = "--os-tenant-name " + DEFAULT_PROJECT_NAME - kwargs = { - "auth_url": "", - "project_id": "", - "project_name": DEFAULT_PROJECT_NAME, - "domain_id": "", - "domain_name": "", - "user_domain_id": "", - "user_domain_name": "", - "project_domain_id": "", - "project_domain_name": "", - "username": "", - "password": "", - "region_name": "", - "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -280,6 +250,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -299,6 +270,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -318,6 +290,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -337,6 +310,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -356,6 +330,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -375,6 +350,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -394,6 +370,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -413,6 +390,7 @@ class TestShellPasswordAuth(TestShell): "password": DEFAULT_PASSWORD, "region_name": "", "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -432,6 +410,7 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": DEFAULT_REGION_NAME, "trust_id": "", + "auth_plugin": "", } self._assert_password_auth(flag, kwargs) @@ -451,6 +430,27 @@ class TestShellPasswordAuth(TestShell): "password": "", "region_name": "", "trust_id": "1234", + "auth_plugin": "", + } + self._assert_password_auth(flag, kwargs) + + def test_only_auth_plugin_flow(self): + flag = "--os-auth-plugin " + "v2password" + kwargs = { + "auth_url": "", + "project_id": "", + "project_name": "", + "domain_id": "", + "domain_name": "", + "user_domain_id": "", + "user_domain_name": "", + "project_domain_id": "", + "project_domain_name": "", + "username": "", + "password": "", + "region_name": "", + "trust_id": "", + "auth_plugin": DEFAULT_AUTH_PLUGIN } self._assert_password_auth(flag, kwargs) @@ -460,7 +460,7 @@ class TestShellTokenAuth(TestShell): super(TestShellTokenAuth, self).setUp() env = { "OS_TOKEN": DEFAULT_TOKEN, - "OS_URL": DEFAULT_SERVICE_URL, + "OS_AUTH_URL": DEFAULT_SERVICE_URL, } self.orig_env, os.environ = os.environ, env.copy() @@ -472,7 +472,7 @@ class TestShellTokenAuth(TestShell): flag = "" kwargs = { "os_token": DEFAULT_TOKEN, - "os_url": DEFAULT_SERVICE_URL + "os_auth_url": DEFAULT_SERVICE_URL } self._assert_token_auth(flag, kwargs) @@ -481,7 +481,7 @@ class TestShellTokenAuth(TestShell): flag = "" kwargs = { "os_token": "", - "os_url": "" + "os_auth_url": "" } self._assert_token_auth(flag, kwargs) diff --git a/requirements.txt b/requirements.txt index 04f448834..447b534a5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,3 +12,4 @@ python-cinderclient>=1.1.0 python-neutronclient>=2.3.6,<3 requests>=1.2.1,!=2.4.0 six>=1.7.0 +stevedore>=1.0.0