diff --git a/bin/glance b/bin/glance index 7308b5d7d7..4e3003f9a1 100755 --- a/bin/glance +++ b/bin/glance @@ -59,7 +59,14 @@ def catch_error(action): try: ret = func(*args, **kwargs) return SUCCESS if ret is None else ret + except exception.NotAuthorized: + print "Not authorized to make this request. Check "\ + "your credentials (OS_AUTH_USER, OS_AUTH_KEY, ...)." + return FAILURE except Exception, e: + options = args[0] + if options.debug: + raise print "Failed to %s. Got error:" % action pieces = unicode(e).split('\n') for piece in pieces: @@ -963,9 +970,14 @@ def get_client(options): specified by the --host and --port options supplied to the CLI """ - return glance_client.Client(host=options.host, - port=options.port, - auth_tok=options.auth_token) + creds = dict(username=os.getenv('OS_AUTH_USER'), + password=os.getenv('OS_AUTH_KEY'), + tenant=os.getenv('OS_AUTH_TENANT'), + auth_url=os.getenv('OS_AUTH_URL'), + strategy=os.getenv('OS_AUTH_STRATEGY', 'noauth')) + + return glance_client.Client(host=options.host, port=options.port, + auth_tok=options.auth_token, creds=creds) def create_options(parser): @@ -977,6 +989,8 @@ def create_options(parser): """ parser.add_option('-v', '--verbose', default=False, action="store_true", help="Print more verbose output") + parser.add_option('-d', '--debug', default=False, action="store_true", + help="Print more verbose output") parser.add_option('-H', '--host', metavar="ADDRESS", default="0.0.0.0", help="Address of Glance API host. " "Default: %default") diff --git a/glance/client.py b/glance/client.py index 44fb048d35..74ef5f4923 100644 --- a/glance/client.py +++ b/glance/client.py @@ -36,26 +36,7 @@ class V1Client(base_client.BaseClient): """Main client class for accessing Glance resources""" DEFAULT_PORT = 9292 - - def __init__(self, host, port=None, use_ssl=False, doc_root="/v1", - auth_tok=None): - """ - Creates a new client to a Glance API service. - - :param host: The host where Glance resides - :param port: The port where Glance resides (defaults to 9292) - :param use_ssl: Should we use HTTPS? (defaults to False) - :param doc_root: Prefix for all URLs we request from host - :param auth_tok: The auth token to pass to the server - """ - port = port or self.DEFAULT_PORT - self.doc_root = doc_root - super(Client, self).__init__(host, port, use_ssl, auth_tok) - - def do_request(self, method, action, body=None, headers=None, params=None): - action = "%s/%s" % (self.doc_root, action.lstrip("/")) - return super(V1Client, self).do_request(method, action, body, - headers, params) + DEFAULT_DOC_ROOT = "/v1" def get_images(self, **kwargs): """ diff --git a/glance/common/auth.py b/glance/common/auth.py new file mode 100644 index 0000000000..3f0e483770 --- /dev/null +++ b/glance/common/auth.py @@ -0,0 +1,202 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +This auth module is intended to allow Openstack client-tools to select from a +variety of authentication strategies, including NoAuth (the default), and +Keystone (an identity management system). + + > auth_plugin = AuthPlugin(creds) + + > auth_plugin.authenticate() + + > auth_plugin.auth_token + abcdefg + + > auth_plugin.management_url + http://service_endpoint/ +""" +import httplib2 +import json +import urlparse + +from glance.common import exception + + +class BaseStrategy(object): + def __init__(self, creds): + self.creds = creds + self.auth_token = None + + # TODO(sirp): For now we're just dealing with one endpoint, eventually + # this should expose the entire service catalog so that the client can + # choose which service/region/(public/private net) combo they want. + self.management_url = None + + def authenticate(self): + raise NotImplementedError + + @property + def is_authenticated(self): + raise NotImplementedError + + +class NoAuthStrategy(BaseStrategy): + def authenticate(self): + pass + + @property + def is_authenticated(self): + return True + + +class KeystoneStrategy(BaseStrategy): + MAX_REDIRECTS = 10 + + def authenticate(self): + """Authenticate with the Keystone service. + + There are a few scenarios to consider here: + + 1. Which version of Keystone are we using? v1 which uses headers to + pass the credentials, or v2 which uses a JSON encoded request body? + + 2. Keystone may respond back with a redirection using a 305 status + code. + + 3. We may attempt a v1 auth when v2 is what's called for. In this + case, we rewrite the url to contain /v2.0/ and retry using the v2 + protocol. + """ + def _authenticate(auth_url): + token_url = urlparse.urljoin(auth_url, "tokens") + + # 1. Check Keystone version + is_v2 = auth_url.rstrip('/').endswith('v2.0') + if is_v2: + self._v2_auth(token_url) + else: + self._v1_auth(token_url) + + for required in ('username', 'password', 'auth_url'): + if required not in self.creds: + raise Exception(_("'%s' must be included in creds") % + required) + + auth_url = self.creds['auth_url'] + for _ in range(self.MAX_REDIRECTS): + try: + _authenticate(auth_url) + except exception.RedirectException as e: + # 2. Keystone may redirect us + auth_url = e.url + except exception.AuthorizationFailure: + # 3. In some configurations nova makes redirection to + # v2.0 keystone endpoint. Also, new location does not + # contain real endpoint, only hostname and port. + if 'v2.0' not in auth_url: + auth_url = urlparse.urljoin(auth_url, 'v2.0/') + else: + # If we sucessfully auth'd, then memorize the correct auth_url + # for future use. + self.creds['auth_url'] = auth_url + break + else: + # Guard against a redirection loop + raise Exception(_("Exceeded max redirects %s") % MAX_REDIRECTS) + + def _v1_auth(self, token_url): + creds = self.creds + + headers = {} + headers['X-Auth-User'] = creds['username'] + headers['X-Auth-Key'] = creds['password'] + + tenant = creds.get('tenant') + if tenant: + headers['X-Auth-Tenant'] = tenant + + resp, resp_body = self._do_request(token_url, 'GET', headers=headers) + + if resp.status in (200, 204): + try: + self.management_url = resp['x-server-management-url'] + self.auth_token = resp['x-auth-token'] + except KeyError: + raise exception.AuthorizationFailure() + elif resp.status == 305: + raise exception.RedirectException(resp['location']) + elif resp.status == 401: + raise exception.NotAuthorized() + else: + raise Exception(_('Unexpected response: %s' % resp.status)) + + def _v2_auth(self, token_url): + creds = self.creds + + creds = {"passwordCredentials": {"username": creds['username'], + "password": creds['password']}} + + tenant = creds.get('tenant') + if tenant: + creds['passwordCredentials']['tenantId'] = tenant + + headers = {} + headers['Content-Type'] = 'application/json' + req_body = json.dumps(creds) + + resp, resp_body = self._do_request( + token_url, 'POST', headers=headers, body=req_body) + + if resp.status == 200: + resp_auth = json.loads(resp_body)['auth'] + + # FIXME(sirp): for now just using the first endpoint we get back + # from the service catalog for glance, and using the public url. + glance_info = resp_auth['serviceCatalog']['glance'] + glance_endpoint = glance_info[0]['publicURL'] + + self.management_url = glance_endpoint + self.auth_token = resp_auth['token']['id'] + elif resp.status == 305: + raise RedirectException(resp['location']) + elif resp.status == 401: + raise exception.NotAuthorized() + else: + raise Exception(_('Unexpected response: %s') % resp.status) + + @property + def is_authenticated(self): + return self.auth_token is not None + + @staticmethod + def _do_request(url, method, headers=None, body=None): + headers = headers or {} + conn = httplib2.Http() + conn.force_exception_to_status_code = True + headers['User-Agent'] = 'glance-client' + resp, resp_body = conn.request(url, method, headers=headers, body=body) + return resp, resp_body + + +def get_plugin_from_strategy(strategy): + if strategy == 'noauth': + return NoAuthStrategy + elif strategy == 'keystone': + return KeystoneStrategy + else: + raise Exception(_("Unknown auth strategy '%s'") % strategy) diff --git a/glance/common/client.py b/glance/common/client.py index 84a061860d..ddf7453c88 100644 --- a/glance/common/client.py +++ b/glance/common/client.py @@ -2,6 +2,7 @@ import httplib import logging import socket import urllib +import urlparse # See http://code.google.com/p/python-nose/issues/detail?id=373 # The code below enables glance.client standalone to work with i18n _() blocks @@ -9,6 +10,7 @@ import __builtin__ if not hasattr(__builtin__, '_'): setattr(__builtin__, '_', lambda x: x) +from glance.common import auth from glance.common import exception @@ -46,8 +48,11 @@ class BaseClient(object): """A base client class""" CHUNKSIZE = 65536 + DEFAULT_PORT = 80 + DEFAULT_DOC_ROOT = None - def __init__(self, host, port, use_ssl, auth_tok): + def __init__(self, host, port=None, use_ssl=False, auth_tok=None, + creds=None, doc_root=None): """ Creates a new client to some service. @@ -55,19 +60,53 @@ class BaseClient(object): :param port: The port where service resides :param use_ssl: Should we use HTTPS? :param auth_tok: The auth token to pass to the server + :param creds: The credentials to pass to the auth plugin + :param doc_root: Prefix for all URLs we request from host """ self.host = host - self.port = port + self.port = port or self.DEFAULT_PORT self.use_ssl = use_ssl self.auth_tok = auth_tok + self.creds = creds or {} self.connection = None + self.doc_root = self.DEFAULT_DOC_ROOT if doc_root is None else doc_root + self.auth_plugin = self.make_auth_plugin(self.creds) def set_auth_token(self, auth_tok): """ Updates the authentication token for this client connection. """ + # FIXME(sirp): Nova image/glance.py currently calls this. Since this + # method isn't really doing anything useful[1], we should go ahead and + # rip it out, first in Nova, then here. Steps: + # + # 1. Change auth_tok in Glance to auth_token + # 2. Change image/glance.py in Nova to use client.auth_token + # 3. Remove this method + # + # [1] http://mail.python.org/pipermail/tutor/2003-October/025932.html self.auth_tok = auth_tok + def configure_from_url(self, url): + """ + Setups the connection based on the given url. + + The form is: + + ://:port/doc_root + """ + parsed = urlparse.urlparse(url) + self.use_ssl = parsed.scheme == 'https' + self.host = parsed.hostname + self.port = parsed.port or 80 + self.doc_root = parsed.path + + def make_auth_plugin(self, creds): + strategy = creds.get('strategy', 'noauth') + plugin_class = auth.get_plugin_from_strategy(strategy) + plugin = plugin_class(creds) + return plugin + def get_connection_type(self): """ Returns the proper connection type @@ -77,8 +116,38 @@ class BaseClient(object): else: return httplib.HTTPConnection + def _authenticate(self, force_reauth=False): + auth_plugin = self.auth_plugin + + if not auth_plugin.is_authenticated or force_reauth: + auth_plugin.authenticate() + + self.auth_tok = auth_plugin.auth_token + + management_url = auth_plugin.management_url + if management_url: + self.configure_from_url(management_url) + def do_request(self, method, action, body=None, headers=None, params=None): + headers = headers or {} + + if not self.auth_tok: + self._authenticate() + + try: + return self._do_request( + method, action, body=body, headers=headers, params=params) + except exception.NotAuthorized: + self._authenticate(force_reauth=True) + try: + return self._do_request( + method, action, body=body, headers=headers, params=params) + except exception.NotAuthorized: + raise + + def _do_request(self, method, action, body=None, headers=None, + params=None): """ Connects to the server and issues a request. Handles converting any returned HTTP error status codes to OpenStack/Glance exceptions @@ -113,10 +182,15 @@ class BaseClient(object): try: connection_type = self.get_connection_type() headers = headers or {} + if 'x-auth-token' not in headers and self.auth_tok: headers['x-auth-token'] = self.auth_tok + c = connection_type(self.host, self.port) + if self.doc_root: + action = '/'.join([self.doc_root, action.lstrip('/')]) + # Do a simple request or a chunked request, depending # on whether the body param is a file-like object and # the method is PUT or POST diff --git a/glance/common/context.py b/glance/common/context.py index fd88d079cc..91f04268d7 100644 --- a/glance/common/context.py +++ b/glance/common/context.py @@ -14,7 +14,6 @@ # 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 glance.common import config from glance.common import exception from glance.common import utils @@ -28,11 +27,13 @@ class RequestContext(object): accesses the system, as well as additional request information. """ - def __init__(self, auth_tok=None, user=None, tenant=None, is_admin=False, - read_only=False, show_deleted=False, owner_is_tenant=True): + def __init__(self, auth_tok=None, user=None, tenant=None, roles=None, + is_admin=False, read_only=False, show_deleted=False, + owner_is_tenant=True): self.auth_tok = auth_tok self.user = user self.tenant = tenant + self.roles = roles or [] self.is_admin = is_admin self.read_only = read_only self.show_deleted = show_deleted @@ -70,10 +71,47 @@ class ContextMiddleware(wsgi.Middleware): """ Extract any authentication information in the request and construct an appropriate context from it. + + A few scenarios exist: + + 1. If X-Auth-Token is passed in, then consult TENANT and ROLE headers + to determine permissions. + + 2. An X-Auth-Token was passed in, but the Identity-Status is not + confirmed. For now, just raising a NotAuthorized exception. + + 3. X-Auth-Token is omitted. If we were using Keystone, then the + tokenauth middleware would have rejected the request, so we must be + using NoAuth. In that case, assume that is_admin=True. """ - # Use the default empty context, with admin turned on for - # backwards compatibility - req.context = self.make_context(is_admin=True) + # TODO(sirp): should we be using the glance_tokeauth shim from + # Keystone here? If we do, we need to make sure it handles the NoAuth + # case + auth_tok = req.headers.get('X-Auth-Token', + req.headers.get('X-Storage-Token')) + if auth_tok: + if req.headers.get('X-Identity-Status') == 'Confirmed': + # 1. Auth-token is passed, check other headers + user = req.headers.get('X-User') + tenant = req.headers.get('X-Tenant') + roles = [r.strip() + for r in req.headers.get('X-Role', '').split(',')] + is_admin = 'Admin' in roles + else: + # 2. Indentity-Status not confirmed + # FIXME(sirp): not sure what the correct behavior in this case + # is; just raising NotAuthorized for now + raise exception.NotAuthorized() + else: + # 3. Auth-token is ommited, assume NoAuth + user = None + tenant = None + roles = [] + is_admin = True + + req.context = self.make_context( + auth_tok=auth_tok, user=user, tenant=tenant, roles=roles, + is_admin=is_admin) def filter_factory(global_conf, **local_conf): diff --git a/glance/common/exception.py b/glance/common/exception.py index 911ec427c2..be3523f24a 100644 --- a/glance/common/exception.py +++ b/glance/common/exception.py @@ -76,6 +76,10 @@ class Duplicate(Error): pass +class AuthorizationFailure(Error): + pass + + class NotAuthorized(Error): pass @@ -88,6 +92,11 @@ class Invalid(Error): pass +class RedirectException(Error): + def __init__(self, url): + self.url = url + + class BadInputError(Exception): """Error resulting from a client sending bad input to a server""" pass diff --git a/glance/registry/__init__.py b/glance/registry/__init__.py index f21a753911..ea4946b425 100644 --- a/glance/registry/__init__.py +++ b/glance/registry/__init__.py @@ -26,10 +26,10 @@ from glance.registry import client logger = logging.getLogger('glance.registry') -def get_registry_client(options, cxt): +def get_registry_client(options, context): host = options['registry_host'] port = int(options['registry_port']) - return client.RegistryClient(host, port, auth_tok=cxt.auth_tok) + return client.RegistryClient(host, port, auth_tok=context.auth_tok) def get_images_list(options, context, **kwargs): diff --git a/glance/registry/client.py b/glance/registry/client.py index 0f5044f19e..316a2442ac 100644 --- a/glance/registry/client.py +++ b/glance/registry/client.py @@ -33,18 +33,6 @@ class RegistryClient(BaseClient): DEFAULT_PORT = 9191 - def __init__(self, host, port=None, use_ssl=False, auth_tok=None): - """ - Creates a new client to a Glance Registry service. - - :param host: The host where Glance resides - :param port: The port where Glance resides (defaults to 9191) - :param use_ssl: Should we use HTTPS? (defaults to False) - :param auth_tok: The auth token to pass to the server - """ - port = port or self.DEFAULT_PORT - super(RegistryClient, self).__init__(host, port, use_ssl, auth_tok) - def get_images(self, **kwargs): """ Returns a list of image id/name mappings from Registry diff --git a/glance/tests/functional/test_bin_glance.py b/glance/tests/functional/test_bin_glance.py index 251da69fbb..37786da771 100644 --- a/glance/tests/functional/test_bin_glance.py +++ b/glance/tests/functional/test_bin_glance.py @@ -26,9 +26,17 @@ from glance.tests.utils import execute class TestBinGlance(functional.FunctionalTest): - """Functional tests for the bin/glance CLI tool""" + def setUp(self): + super(TestBinGlance, self).setUp() + + # NOTE(sirp): This is needed in case we are running the tests under an + # environment in which OS_AUTH_STRATEGY=keystone. The test server we + # spin up won't have keystone support, so we need to switch to the + # NoAuth strategy. + os.environ['OS_AUTH_STRATEGY'] = 'noauth' + def test_add_list_delete_list(self): """ We test the following: diff --git a/glance/tests/unit/test_clients.py b/glance/tests/unit/test_clients.py index eccec2de8c..892db63f39 100644 --- a/glance/tests/unit/test_clients.py +++ b/glance/tests/unit/test_clients.py @@ -1725,3 +1725,33 @@ class TestClient(unittest.TestCase): """Tests deleting image members""" self.assertRaises(exception.NotAuthorized, self.client.delete_member, 2, 'pattieblack') + + +class TestConfigureClientFromURL(unittest.TestCase): + def setUp(self): + self.client = client.Client("0.0.0.0", doc_root="") + + def assertConfiguration(self, url, host, port, use_ssl, doc_root): + self.client.configure_from_url(url) + self.assertEquals(host, self.client.host) + self.assertEquals(port, self.client.port) + self.assertEquals(use_ssl, self.client.use_ssl) + self.assertEquals(doc_root, self.client.doc_root) + + def test_no_port_no_ssl_no_doc_root(self): + self.assertConfiguration( + url='http://www.example.com', + host='www.example.com', + port=80, + use_ssl=False, + doc_root='' + ) + + def test_port_ssl_doc_root(self): + self.assertConfiguration( + url='https://www.example.com:8000/prefix/', + host='www.example.com', + port=8000, + use_ssl=True, + doc_root='/prefix/' + ) diff --git a/tools/nova_to_os_env.sh b/tools/nova_to_os_env.sh new file mode 100644 index 0000000000..5da4b743f4 --- /dev/null +++ b/tools/nova_to_os_env.sh @@ -0,0 +1,10 @@ +# This file is intended to be sourced to convert old-style NOVA environment +# variables to new-style OS. +# +# The plan is to add this to novarc, but until that lands, it's useful to have +# this in Glance. +export OS_AUTH_USER=$NOVA_USERNAME +export OS_AUTH_KEY=$NOVA_API_KEY +export OS_AUTH_TENANT=$NOVA_PROJECT_ID +export OS_AUTH_URL=$NOVA_URL +export OS_AUTH_STRATEGY=$NOVA_AUTH_STRATEGY