Adding Keystone support for Glance client.

Implements bp pluggable-auth

Change-Id: I2a6e3b1ab4c50200ece64a2e07bf81e9e6467efd
This commit is contained in:
Rick Harris 2011-08-26 22:25:49 +00:00
parent ecbcc09ce5
commit be6d6294f9
11 changed files with 400 additions and 46 deletions

View File

@ -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")

View File

@ -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):
"""

202
glance/common/auth.py Normal file
View File

@ -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)

View File

@ -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:
<http|https>://<host>: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

View File

@ -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):

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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:

View File

@ -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/'
)

10
tools/nova_to_os_env.sh Normal file
View File

@ -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