Split noauth context middleware into new class

Use UnauthenticatedContextMiddleware in the case that you want to deploy
without an auth service. ContextMiddleware is now solely for authenticated
requests.

* Rewrite ownership tests for adding/updating images through the v1 API
* Fixes bug 992859

Change-Id: I608671aac8300c9195769542708708afda991e04
This commit is contained in:
Brian Waldon 2012-05-01 15:41:06 -07:00
parent 64ab035f96
commit eb4aaa97bd
12 changed files with 228 additions and 208 deletions

1
.gitignore vendored
View File

@ -7,6 +7,7 @@
AUTHORS
build
dist
*.egg
glance.egg-info
glance/vcsversion.py
tests.sqlite

View File

@ -40,12 +40,13 @@ Configuring the Glance servers to use Keystone
Keystone is integrated with Glance through the use of middleware. The
default configuration files for both the Glance API and the Glance
Registry use a single piece of middleware called ``context``, which
generates a request context containing all the necesary authorization
Registry use a single piece of middleware called ``unauthenticated-context``,
which generates a request context containing blank authentication
information. In order to configure Glance to use Keystone, the
``authtoken`` middleware must also be deployed (which may be found in the
Keystone distribution). The ``authtoken`` middleware performs the Keystone
token validation, which is the heart of Keystone authentication.
``authtoken`` and ``context`` middlewares must be deployed in place of the
``unauthenticated-context`` middleware. The ``authtoken`` middleware performs
the authentication token validation and retrieves actual user authentication
information. It can be found in the Keystone distribution.
Configuring Glance API to use Keystone
--------------------------------------
@ -80,11 +81,11 @@ Finally, to actually enable using Keystone authentication, the
application pipeline must be modified. By default, it looks like::
[pipeline:glance-api]
pipeline = versionnegotiation context apiv1app
pipeline = versionnegotiation unauthenticated-context apiv1app
(Your particular pipeline may vary depending on other options, such as
the image cache.) This must be changed by inserting ``authtoken``
before ``context``::
Your particular pipeline may vary depending on other options, such as
the image cache. This must be changed by replacing ``unauthenticated-context``
with ``authtoken`` and ``context``::
[pipeline:glance-api]
pipeline = versionnegotiation authtoken context apiv1app

View File

@ -1,6 +1,6 @@
# Default minimal pipeline
[pipeline:glance-api]
pipeline = versionnegotiation context rootapp
pipeline = versionnegotiation unauthenticated-context rootapp
# Use the following pipeline for keystone auth
# i.e. in glance-api.conf:
@ -62,6 +62,10 @@ glance.filter_factory = glance.api.middleware.cache_manage:CacheManageFilter
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:ContextMiddleware
[filter:unauthenticated-context]
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:UnauthenticatedContextMiddleware
[filter:authtoken]
paste.filter_factory = keystone.middleware.auth_token:filter_factory
auth_host = 127.0.0.1

View File

@ -1,6 +1,6 @@
# Default minimal pipeline
[pipeline:glance-registry]
pipeline = context registryapp
pipeline = unauthenticated-context registryapp
# Use the following pipeline for keystone auth
# i.e. in glance-registry.conf:
@ -18,6 +18,10 @@ glance.app_factory = glance.registry.api.v1:API
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:ContextMiddleware
[filter:unauthenticated-context]
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:UnauthenticatedContextMiddleware
[filter:authtoken]
paste.filter_factory = keystone.middleware.auth_token:filter_factory
auth_host = 127.0.0.1

View File

@ -15,6 +15,8 @@
# License for the specific language governing permissions and limitations
# under the License.
import webob.exc
from glance.common import exception
from glance.common import wsgi
from glance.openstack.common import cfg
@ -130,60 +132,60 @@ class ContextMiddleware(wsgi.Middleware):
opts = [
cfg.BoolOpt('owner_is_tenant', default=True),
cfg.StrOpt('admin_role', default='admin'),
]
]
def __init__(self, app, conf, **local_conf):
self.conf = conf
self.conf.register_opts(self.opts)
super(ContextMiddleware, self).__init__(app)
def make_context(self, *args, **kwargs):
"""
Create a context with the given arguments.
"""
kwargs.setdefault('owner_is_tenant', self.conf.owner_is_tenant)
def process_request(self, req):
"""Convert authentication informtion into a request context
return RequestContext(*args, **kwargs)
Generate a RequestContext object from the available
authentication headers and store on the 'context' attribute
of the req object.
:param req: wsgi request object that will be given the context object
:raises webob.exc.HTTPUnauthorized: when value of the X-Identity-Status
header is not 'Confirmed'
"""
if req.headers.get('X-Identity-Status') != 'Confirmed':
raise webob.exc.HTTPUnauthorized()
#NOTE(bcwaldon): X-Roles is a csv string, but we need to parse
# it into a list to be useful
roles_header = req.headers.get('X-Roles', '')
roles = [r.strip() for r in roles_header.split(',')]
#NOTE(bcwaldon): This header is deprecated in favor of X-Auth-Token
deprecated_token = req.headers.get('X-Storage-Token')
kwargs = {
'user': req.headers.get('X-User-Id'),
'tenant': req.headers.get('X-Tenant-Id'),
'roles': roles,
'is_admin': self.conf.admin_role in roles,
'auth_tok': req.headers.get('X-Auth-Token', deprecated_token),
'owner_is_tenant': self.conf.owner_is_tenant,
}
req.context = RequestContext(**kwargs)
class UnauthenticatedContextMiddleware(wsgi.Middleware):
def __init__(self, app, conf, **local_conf):
self.conf = conf
super(UnauthenticatedContextMiddleware, self).__init__(app)
def process_request(self, req):
"""
Extract any authentication information in the request and
construct an appropriate context from it.
"""Create a context without an authorized user."""
kwargs = {
'user': None,
'tenant': None,
'roles': [],
'is_admin': True,
}
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 NotAuthenticated 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.
"""
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-Id')
tenant = req.headers.get('X-Tenant-Id')
roles = [r.strip()
for r in req.headers.get('X-Roles', '').split(',')]
is_admin = self.conf.admin_role in roles
else:
# 2. Indentity-Status not confirmed
# FIXME(sirp): not sure what the correct behavior in this case
# is; just raising NotAuthenticated for now
raise exception.NotAuthenticated()
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)
req.context = RequestContext(**kwargs)

View File

@ -252,13 +252,18 @@ policy_default_rule = %(policy_default_rule)s
flavor = %(deployment_flavor)s
"""
self.paste_conf_base = """[pipeline:glance-api]
pipeline = versionnegotiation context rootapp
pipeline = versionnegotiation unauthenticated-context rootapp
[pipeline:glance-api-caching]
pipeline = versionnegotiation context cache rootapp
pipeline = versionnegotiation unauthenticated-context cache rootapp
[pipeline:glance-api-cachemanagement]
pipeline = versionnegotiation context cache cache_manage rootapp
pipeline =
versionnegotiation
unauthenticated-context
cache
cache_manage
rootapp
[pipeline:glance-api-fakeauth]
pipeline = versionnegotiation fakeauth context rootapp
@ -297,6 +302,10 @@ glance.filter_factory = glance.api.middleware.cache_manage:CacheManageFilter
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:ContextMiddleware
[filter:unauthenticated-context]
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:UnauthenticatedContextMiddleware
[filter:fakeauth]
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.tests.utils:FakeAuthMiddleware
@ -337,7 +346,7 @@ owner_is_tenant = %(owner_is_tenant)s
flavor = %(deployment_flavor)s
"""
self.paste_conf_base = """[pipeline:glance-registry]
pipeline = context registryapp
pipeline = unauthenticated-context registryapp
[pipeline:glance-registry-fakeauth]
pipeline = fakeauth context registryapp
@ -350,6 +359,10 @@ glance.app_factory = glance.registry.api.v1:API
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:ContextMiddleware
[filter:unauthenticated-context]
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.common.context:UnauthenticatedContextMiddleware
[filter:fakeauth]
paste.filter_factory = glance.common.wsgi:filter_factory
glance.filter_factory = glance.tests.utils:FakeAuthMiddleware

View File

@ -31,36 +31,6 @@ from glance.tests.functional.store_utils import (setup_http,
get_http_uri)
class TestBinGlanceAuth(functional.FunctionalTest):
"""Functional tests for bin/glance with some amount of auth"""
def assertIn(self, key, bag):
msg = 'Expected to find substring "%s" in "%s"' % (key, bag)
self.assertTrue(key in bag, msg)
def assertNotIn(self, key, bag):
msg = 'Expected not to find substring "%s" in "%s"' % (key, bag)
self.assertFalse(key in bag, msg)
def test_index_with_https_auth(self):
self.cleanup()
self.start_servers(**self.__dict__.copy())
api_port = self.api_port
cmd = ("bin/glance --port=%d -N https://this.url.doesnt.matter/ "
"-A aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa index") % api_port
exitcode, out, err = execute(cmd, raise_error=False)
#NOTE(markwash): we should expect the command to fail because the
# testing glance api server is not configured to authenticate, and the
# token we provide is invalid. However, it should fail due to
# NotAuthenticated, rather than because of an SSL error.
self.assertNotEqual(0, exitcode)
self.assertNotIn('SSL23_GET_SERVER_HELLO', out)
self.assertIn('NotAuthenticated: You are not authenticated.', out)
class TestBinGlance(functional.FunctionalTest):
"""Functional tests for the bin/glance CLI tool"""
@ -78,6 +48,20 @@ class TestBinGlance(functional.FunctionalTest):
msg = 'expected "%s" to start with "%s"' % (str, prefix)
self.assertTrue(str.startswith(prefix), msg)
def _assertNotIn(self, key, bag):
msg = 'Expected not to find substring "%s" in "%s"' % (key, bag)
self.assertFalse(key in bag, msg)
def test_index_with_https(self):
self.cleanup()
self.start_servers(**self.__dict__.copy())
cmd = ("bin/glance -N https://auth/ --port=%d index") % self.api_port
exitcode, out, err = execute(cmd, raise_error=False)
self.assertNotEqual(0, exitcode)
self._assertNotIn('SSL23_GET_SERVER_HELLO', out)
def test_add_with_location_and_id(self):
self.cleanup()
self.start_servers(**self.__dict__.copy())
@ -176,103 +160,6 @@ class TestBinGlance(functional.FunctionalTest):
self.assertEqual('0', size, "Expected image to be 0 bytes in size, "
"but got %s. " % size)
def _verify_owner(self, owner, image_id):
cmd = "bin/glance --port=%d show %s" % (self.api_port, image_id)
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
# verify expected owner as first class attribute
self.assertTrue(('Owner: %s' % owner) in out)
# ensure owner does not appear as a custom property
self.assertFalse("Property 'owner':" in out)
def _create_by_admin(self, owner):
# ownership set by admin user (defaults as such due to no-auth)
cmd = minimal_add_command(self.api_port,
'MyImage',
'--silent-upload owner=%s' % owner)
exitcode, out, err = execute(cmd)
self.assertEqual(0, exitcode)
self.assertTrue(out.strip().startswith('Added new image with ID:'))
return out.strip().replace('Added new image with ID: ', '')
def test_add_with_owner_admin(self):
"""Test setting ownership of new image by admin user"""
self.cleanup()
self.start_servers(**self.__dict__.copy())
image_id = self._create_by_admin('42')
self._verify_owner('42', image_id)
def test_add_with_owner_non_admin(self):
"""Test setting ownership of new image by non-admin user"""
self.cleanup()
self.api_server.deployment_flavor = 'fakeauth'
self.registry_server.deployment_flavor = 'fakeauth'
self.start_servers(**self.__dict__.copy())
# ownership set by non-admin user (setup as such by fakeauth pipeline)
headers = {'X-Image-Meta-Name': 'MyImage',
'X-Image-Meta-disk_format': 'raw',
'X-Image-Meta-container_format': 'ovf',
'X-Image-Meta-Is-Public': 'True',
'X-Image-Meta-Owner': '42',
'X-Auth-Token': 'Confirmed:pattieblack:froggy:demo',
}
path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
http = httplib2.Http()
response, content = http.request(path, 'POST', headers=headers)
self.assertEqual(response.status, 201)
data = json.loads(content)
image_id = data['image']['id']
self._verify_owner('froggy', image_id)
def test_update_with_owner_admin(self):
"""Test updating ownership of existing image by admin user"""
self.cleanup()
self.start_servers(**self.__dict__.copy())
image_id = self._create_by_admin('user1')
self._verify_owner('user1', image_id)
# ownership updated by admin user (defaults as such due to no-auth)
cmd = "bin/glance update %s owner=user2 -p %d" % (image_id,
self.api_port)
exitcode, out, err = execute(cmd, raise_error=False)
self.assertEqual(0, exitcode)
self.assertTrue(out.strip().endswith('Updated image %s' % image_id))
self._verify_owner('user2', image_id)
def test_update_with_owner_non_admin(self):
"""Test updating ownership of existing image by non-admin user"""
self.cleanup()
self.api_server.deployment_flavor = 'fakeauth'
self.registry_server.deployment_flavor = 'fakeauth'
self.start_servers(**self.__dict__.copy())
image_id = self._create_by_admin('user1')
# ownership update attempted by non-admin user
# (setup as such by fakeauth pipeline)
headers = {'X-Image-Meta-Owner': 'user2',
'X-Auth-Token': 'Confirmed:pattieblack:froggy:demo',
}
path = "http://%s:%d/v1/images/%s" % ("0.0.0.0",
self.api_port,
image_id)
http = httplib2.Http()
response, content = http.request(path, 'PUT', headers=headers)
self.assertEqual(response.status, 403)
def test_add_no_name(self):
self.cleanup()
self.start_servers(**self.__dict__.copy())

View File

@ -1241,3 +1241,115 @@ class TestApi(functional.FunctionalTest):
@skip_if_disabled
def _do_test_put_image_content_missing_disk_format(self):
self._do_test_put_image_content_missing_format('disk_format')
@skip_if_disabled
def test_ownership(self):
self.cleanup()
self.api_server.deployment_flavor = 'fakeauth'
self.registry_server.deployment_flavor = 'fakeauth'
self.start_servers(**self.__dict__.copy())
# Add an image with admin privileges and ensure the owner
# can be set to something other than what was used to authenticate
auth_headers = {
'X-Auth-Token': 'user1:tenant1:admin',
}
create_headers = {
'X-Image-Meta-Name': 'MyImage',
'X-Image-Meta-disk_format': 'raw',
'X-Image-Meta-container_format': 'ovf',
'X-Image-Meta-Is-Public': 'True',
'X-Image-Meta-Owner': 'tenant2',
}
create_headers.update(auth_headers)
path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
http = httplib2.Http()
response, content = http.request(path, 'POST', headers=create_headers)
self.assertEqual(response.status, 201)
data = json.loads(content)
image_id = data['image']['id']
path = ("http://%s:%d/v1/images/%s" %
("0.0.0.0", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'HEAD', headers=auth_headers)
self.assertEqual(response.status, 200)
self.assertEqual('tenant2', response['x-image-meta-owner'])
# Now add an image without admin privileges and ensure the owner
# cannot be set to something other than what was used to authenticate
auth_headers = {
'X-Auth-Token': 'user1:tenant1:role1',
}
create_headers.update(auth_headers)
path = "http://%s:%d/v1/images" % ("0.0.0.0", self.api_port)
http = httplib2.Http()
response, content = http.request(path, 'POST', headers=create_headers)
self.assertEqual(response.status, 201)
data = json.loads(content)
image_id = data['image']['id']
# We have to be admin to see the owner
auth_headers = {
'X-Auth-Token': 'user1:tenant1:admin',
}
create_headers.update(auth_headers)
path = ("http://%s:%d/v1/images/%s" %
("0.0.0.0", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'HEAD', headers=auth_headers)
self.assertEqual(response.status, 200)
self.assertEqual('tenant1', response['x-image-meta-owner'])
# Make sure the non-privileged user can't update their owner either
update_headers = {
'X-Image-Meta-Name': 'MyImage2',
'X-Image-Meta-Owner': 'tenant2',
'X-Auth-Token': 'user1:tenant1:role1',
}
path = ("http://%s:%d/v1/images/%s" %
("0.0.0.0", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'PUT', headers=update_headers)
self.assertEqual(response.status, 200)
# We have to be admin to see the owner
auth_headers = {
'X-Auth-Token': 'user1:tenant1:admin',
}
path = ("http://%s:%d/v1/images/%s" %
("0.0.0.0", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'HEAD', headers=auth_headers)
self.assertEqual(response.status, 200)
self.assertEqual('tenant1', response['x-image-meta-owner'])
# An admin user should be able to update the owner
auth_headers = {
'X-Auth-Token': 'user1:tenant3:admin',
}
update_headers = {
'X-Image-Meta-Name': 'MyImage2',
'X-Image-Meta-Owner': 'tenant2',
}
update_headers.update(auth_headers)
path = ("http://%s:%d/v1/images/%s" %
("0.0.0.0", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'PUT', headers=update_headers)
self.assertEqual(response.status, 200)
path = ("http://%s:%d/v1/images/%s" %
("0.0.0.0", self.api_port, image_id))
http = httplib2.Http()
response, content = http.request(path, 'HEAD', headers=auth_headers)
self.assertEqual(response.status, 200)
self.assertEqual('tenant2', response['x-image-meta-owner'])

View File

@ -72,7 +72,8 @@ def stub_out_registry_and_store_server(stubs, base_dir):
'verbose': VERBOSE,
'debug': DEBUG
})
api = context.ContextMiddleware(rserver.API(conf), conf)
api = context.UnauthenticatedContextMiddleware(
rserver.API(conf), conf)
res = self.req.get_response(api)
# httplib.Response has a read() method...fake it out
@ -160,7 +161,8 @@ def stub_out_registry_and_store_server(stubs, base_dir):
'filesystem_store_datadir': base_dir,
'policy_file': os.path.join(base_dir, 'policy.json'),
})
api = context.ContextMiddleware(router.API(conf), conf)
api = context.UnauthenticatedContextMiddleware(
router.API(conf), conf)
res = self.req.get_response(api)
# httplib.Response has a read() method...fake it out
@ -240,7 +242,8 @@ def stub_out_registry_server(stubs, **kwargs):
'verbose': VERBOSE,
'debug': DEBUG
})
api = context.ContextMiddleware(rserver.API(conf), conf)
api = context.UnauthenticatedContextMiddleware(
rserver.API(conf), conf)
res = self.req.get_response(api)
# httplib.Response has a read() method...fake it out

View File

@ -67,7 +67,7 @@ class TestPasteApp(unittest.TestCase):
os.rmdir(os.path.dirname(conf.temp_file))
def test_load_paste_app(self):
expected_middleware = context.ContextMiddleware
expected_middleware = context.UnauthenticatedContextMiddleware
self._do_test_load_paste_app(expected_middleware)
def test_load_paste_app_with_paste_flavor(self):
@ -83,7 +83,7 @@ class TestPasteApp(unittest.TestCase):
'etc/glance-registry-paste.ini')
paste_group = {'paste_deploy': {'config_file': paste_config_file}}
expected_middleware = context.ContextMiddleware
expected_middleware = context.UnauthenticatedContextMiddleware
self._do_test_load_paste_app(expected_middleware,
paste_group, paste_copy=False)

View File

@ -93,7 +93,8 @@ class TestRegistryAPI(base.IsolatedUnitTest):
def setUp(self):
"""Establish a clean test environment"""
super(TestRegistryAPI, self).setUp()
self.api = context.ContextMiddleware(rserver.API(self.conf), self.conf)
self.api = context.UnauthenticatedContextMiddleware(
rserver.API(self.conf), self.conf)
self.FIXTURES = [
{'id': UUID1,
'name': 'fake image #1',
@ -1867,15 +1868,6 @@ class TestRegistryAPI(base.IsolatedUnitTest):
self.assertEquals(res.status_int,
webob.exc.HTTPNotFound.code)
def test_delete_image_public_not_owned(self):
req = webob.Request.blank('/images/%s' % UUID2)
req.method = 'DELETE'
req.headers['x-auth-token'] = 'toke'
req.headers['x-identity-status'] = 'Confirmed'
res = req.get_response(self.api)
self.assertEquals(res.status_int, 403)
def test_get_image_members(self):
"""
Tests members listing for existing images
@ -1955,7 +1947,8 @@ class TestGlanceAPI(base.IsolatedUnitTest):
def setUp(self):
"""Establish a clean test environment"""
super(TestGlanceAPI, self).setUp()
self.api = context.ContextMiddleware(router.API(self.conf), self.conf)
self.api = context.UnauthenticatedContextMiddleware(
router.API(self.conf), self.conf)
self.FIXTURES = [
{'id': UUID1,
'name': 'fake image #1',

View File

@ -384,8 +384,8 @@ class FakeAuthMiddleware(wsgi.Middleware):
def process_request(self, req):
auth_tok = req.headers.get('X-Auth-Token')
if auth_tok:
status, user, tenant, role = auth_tok.split(':')
req.headers['X-Identity-Status'] = status
user, tenant, role = auth_tok.split(':')
req.headers['X-User-Id'] = user
req.headers['X-Tenant-Id'] = tenant
req.headers['X-Role'] = role
req.headers['X-Roles'] = role
req.headers['X-Identity-Status'] = 'Confirmed'