diff --git a/magnum/api/app.py b/magnum/api/app.py index aa80c99e45..2686c10b7a 100644 --- a/magnum/api/app.py +++ b/magnum/api/app.py @@ -54,4 +54,5 @@ def setup_app(config=None): logging=getattr(config, 'logging', {}), **app_conf ) - return auth.install(app, CONF) + + return auth.install(app, CONF, config.app.acl_public_routes) diff --git a/magnum/api/auth.py b/magnum/api/auth.py index 31811ec9ea..09fbc1970a 100644 --- a/magnum/api/auth.py +++ b/magnum/api/auth.py @@ -1,5 +1,8 @@ # -*- encoding: utf-8 -*- # +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann # # 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 @@ -13,22 +16,12 @@ # License for the specific language governing permissions and limitations # under the License. -import re - -from keystonemiddleware import auth_token +"""Access Control Lists (ACL's) control access the API server.""" from oslo.config import cfg -from oslo.utils import importutils -from pecan import hooks -from magnum.common import context -from magnum.openstack.common._i18n import _ -from magnum.openstack.common import log as logging +from magnum.api.middleware import auth_token -LOG = logging.getLogger(__name__) - -OPT_GROUP_NAME = 'keystone_authtoken' - AUTH_OPTS = [ cfg.BoolOpt('enable_authentication', default=True, @@ -39,99 +32,19 @@ AUTH_OPTS = [ CONF = cfg.CONF CONF.register_opts(AUTH_OPTS) -PUBLIC_ENDPOINTS = [ - "^/$" -] +def install(app, conf, public_routes): + """Install ACL check on application. -def install(app, conf): - if conf.get('enable_authentication'): - return AuthProtocolWrapper(app, conf=dict(conf.get(OPT_GROUP_NAME))) - else: - LOG.warning(_('Keystone authentication is disabled by Magnum ' - 'configuration parameter enable_authentication. ' - 'Magnum will not authenticate incoming request. ' - 'In order to enable authentication set ' - 'enable_authentication option to True.')) - - return app - - -class AuthHelper(object): - """Helper methods for Auth.""" - - def __init__(self): - endpoints_pattern = '|'.join(pe for pe in PUBLIC_ENDPOINTS) - self._public_endpoints_regexp = re.compile(endpoints_pattern) - - def is_endpoint_public(self, path): - return self._public_endpoints_regexp.match(path) - - -class AuthProtocolWrapper(auth_token.AuthProtocol): - """A wrapper on Keystone auth_token AuthProtocol. - - Does not perform verification of authentication tokens for pub routes in - the API. Public routes are those defined by PUBLIC_ENDPOINTS + :param app: A WSGI applicatin. + :param conf: Settings. Dict'ified and passed to keystonemiddleware + :param public_routes: The list of the routes which will be allowed to + access without authentication. + :return: The same WSGI application with ACL installed. """ - - def __call__(self, env, start_response): - path = env.get('PATH_INFO') - if AUTH.is_endpoint_public(path): - return self._app(env, start_response) - return super(AuthProtocolWrapper, self).__call__(env, start_response) - - -class AuthInformationHook(hooks.PecanHook): - - def before(self, state): - if not CONF.get('enable_authentication'): - return - # Skip authentication for public endpoints - if AUTH.is_endpoint_public(state.request.path): - return - - headers = state.request.headers - user_id = headers.get('X-User-Id') - user_id = headers.get('X-User', user_id) - if user_id is None: - LOG.debug("X-User-Id header was not found in the request") - raise Exception('Not authorized') - - tenant = state.request.headers.get('X-Tenant-Id') - tenant = state.request.headers.get('X-Tenant', tenant) - domain_id = state.request.headers.get('X-User-Domain-Id') - domain_name = state.request.headers.get('X-User-Domain-Name') - auth_token_info = state.request.environ.get('keystone.token_info') - - # Get the auth token - try: - recv_auth_token = headers.get('X-Auth-Token', - headers.get( - 'X-Storage-Token')) - except ValueError: - LOG.debug("No auth token found in the request.") - raise Exception('Not authorized') - auth_url = headers.get('X-Auth-Url') - if auth_url is None: - importutils.import_module('keystonemiddleware.auth_token') - auth_url = cfg.CONF.keystone_authtoken.auth_uri - - identity_status = headers.get('X-Identity-Status') - if identity_status == 'Confirmed': - ctx = context.RequestContext(auth_token=recv_auth_token, - auth_url=auth_url, - auth_token_info=auth_token_info, - user=user_id, - tenant=tenant, - domain_id=domain_id, - domain_name=domain_name) - state.request.context = ctx - else: - LOG.debug("The provided identity is not confirmed.") - raise Exception('Not authorized. Identity not confirmed.') - return - - -AUTH = AuthHelper() + if not cfg.CONF.get('enable_authentication'): + return app + return auth_token.AuthTokenMiddleware(app, + conf=dict(conf), + public_api_routes=public_routes) diff --git a/magnum/api/config.py b/magnum/api/config.py index cb85638604..a01904b96b 100644 --- a/magnum/api/config.py +++ b/magnum/api/config.py @@ -14,14 +14,19 @@ # License for the specific language governing permissions and limitations # under the License. -from magnum.api import auth +from magnum.api import hooks # Pecan Application Configurations app = { 'root': 'magnum.api.controllers.root.RootController', 'modules': ['magnum.api'], 'debug': False, - 'hooks': [auth.AuthInformationHook()] + 'hooks': [ + hooks.ContextHook() + ], + 'acl_public_routes': [ + '/' + ], } # Custom Configurations must be in Python dictionary format:: diff --git a/magnum/api/hooks.py b/magnum/api/hooks.py new file mode 100644 index 0000000000..76d60fcae3 --- /dev/null +++ b/magnum/api/hooks.py @@ -0,0 +1,67 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Doug Hellmann +# +# 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. + + +from oslo.config import cfg +from oslo.utils import importutils +from pecan import hooks + +from magnum.common import context + + +class ContextHook(hooks.PecanHook): + """Configures a request context and attaches it to the request. + + The following HTTP request headers are used: + + X-User-Id or X-User: + Used for context.user_id. + + X-Tenant-Id or X-Tenant: + Used for context.tenant. + + X-Auth-Token: + Used for context.auth_token. + + """ + + def before(self, state): + headers = state.request.headers + user_id = headers.get('X-User-Id') + user_id = headers.get('X-User', user_id) + tenant = state.request.headers.get('X-Tenant-Id') + tenant = state.request.headers.get('X-Tenant', tenant) + domain_id = state.request.headers.get('X-User-Domain-Id') + domain_name = state.request.headers.get('X-User-Domain-Name') + auth_token = state.request.headers.get('X-Storage-Token') + auth_token = state.request.headers.get('X-Auth-Token', auth_token) + auth_token_info = state.request.environ.get('keystone.token_info') + + auth_url = headers.get('X-Auth-Url') + if auth_url is None: + importutils.import_module('keystonemiddleware.auth_token') + auth_url = cfg.CONF.keystone_authtoken.auth_uri + + state.request.context = context.RequestContext( + auth_token=auth_token, + auth_url=auth_url, + auth_token_info=auth_token_info, + user=user_id, + tenant=tenant, + domain_id=domain_id, + domain_name=domain_name) \ No newline at end of file diff --git a/magnum/api/middleware/__init__.py b/magnum/api/middleware/__init__.py new file mode 100644 index 0000000000..819cae3aac --- /dev/null +++ b/magnum/api/middleware/__init__.py @@ -0,0 +1,20 @@ +# -*- encoding: utf-8 -*- +# +# 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. + +from magnum.api.middleware import auth_token + + +AuthTokenMiddleware = auth_token.AuthTokenMiddleware + +__all__ = (AuthTokenMiddleware) diff --git a/magnum/api/middleware/auth_token.py b/magnum/api/middleware/auth_token.py new file mode 100644 index 0000000000..7a7d24728b --- /dev/null +++ b/magnum/api/middleware/auth_token.py @@ -0,0 +1,60 @@ +# -*- encoding: utf-8 -*- +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import re + +from keystonemiddleware import auth_token + +from magnum.common import exception +from magnum.common import utils +from magnum.openstack.common._i18n import _ +from magnum.openstack.common import log + +LOG = log.getLogger(__name__) + + +class AuthTokenMiddleware(auth_token.AuthProtocol): + """A wrapper on Keystone auth_token middleware. + + Does not perform verification of authentication tokens + for public routes in the API. + + """ + def __init__(self, app, conf, public_api_routes=[]): + route_pattern_tpl = '%s(\.json|\.xml)?$' + + try: + self.public_api_routes = [re.compile(route_pattern_tpl % route_tpl) + for route_tpl in public_api_routes] + except re.error as e: + msg = _('Cannot compile public API routes: %s') % e + + LOG.error(msg) + raise exception.ConfigInvalid(error_msg=msg) + + super(AuthTokenMiddleware, self).__init__(app, conf) + + def __call__(self, env, start_response): + path = utils.safe_rstrip(env.get('PATH_INFO'), '/') + + # The information whether the API call is being performed against the + # public API is required for some other components. Saving it to the + # WSGI environment is reasonable thereby. + env['is_public_api'] = any(map(lambda pattern: re.match(pattern, path), + self.public_api_routes)) + + if env['is_public_api']: + return self._app(env, start_response) + + return super(AuthTokenMiddleware, self).__call__(env, start_response) diff --git a/magnum/tests/api/test_app.py b/magnum/tests/api/test_app.py index ed654c8994..98548247c7 100644 --- a/magnum/tests/api/test_app.py +++ b/magnum/tests/api/test_app.py @@ -14,8 +14,8 @@ # limitations under the License. from magnum.api import app as api_app -from magnum.api import auth from magnum.api import config as api_config +from magnum.api import hooks from magnum.tests import base @@ -30,4 +30,4 @@ class TestAppConfig(base.BaseTestCase): self.assertEqual(config_d['modules'], api_config.app['modules']) self.assertEqual(config_d['root'], api_config.app['root']) - self.assertIsInstance(config_d['hooks'][0], auth.AuthInformationHook) + self.assertIsInstance(config_d['hooks'][0], hooks.ContextHook) diff --git a/magnum/tests/api/test_auth.py b/magnum/tests/api/test_auth.py index bce9f6885e..775714b1a7 100644 --- a/magnum/tests/api/test_auth.py +++ b/magnum/tests/api/test_auth.py @@ -17,12 +17,11 @@ import mock from oslo.config import fixture from magnum.api import auth -from magnum.common import context from magnum.tests import base from magnum.tests import fakes -@mock.patch('magnum.api.auth.AuthProtocolWrapper', +@mock.patch('magnum.api.middleware.auth_token.AuthTokenMiddleware', new_callable=fakes.FakeAuthProtocol) class TestAuth(base.BaseTestCase): @@ -32,51 +31,11 @@ class TestAuth(base.BaseTestCase): self.app = fakes.FakeApp() def test_check_auth_option_enabled(self, mock_auth): - - self.CONF.config(auth_protocol="footp", - auth_version="v2.0", - auth_uri=None, - group=auth.OPT_GROUP_NAME) self.CONF.config(enable_authentication=True) - result = auth.install(self.app, self.CONF.conf) + result = auth.install(self.app, self.CONF.conf, ['/']) self.assertIsInstance(result, fakes.FakeAuthProtocol) def test_check_auth_option_disabled(self, mock_auth): - self.CONF.config(auth_protocol="footp", - auth_version="v2.0", - auth_uri=None, - group=auth.OPT_GROUP_NAME) self.CONF.config(enable_authentication=False) - result = auth.install(self.app, self.CONF.conf) + result = auth.install(self.app, self.CONF.conf, ['/']) self.assertIsInstance(result, fakes.FakeApp) - - def test_auth_hook_before_method(self, mock_cls): - state = mock.Mock(request=fakes.FakePecanRequest()) - hook = auth.AuthInformationHook() - hook.before(state) - ctx = state.request.context - self.assertIsInstance(ctx, context.RequestContext) - self.assertEqual(ctx.auth_token, - fakes.fakeAuthTokenHeaders['X-Auth-Token']) - self.assertEqual(ctx.tenant, - fakes.fakeAuthTokenHeaders['X-Tenant-Id']) - self.assertEqual(ctx.user, - fakes.fakeAuthTokenHeaders['X-User-Id']) - self.assertEqual(ctx.auth_url, - fakes.fakeAuthTokenHeaders['X-Auth-Url']) - self.assertEqual(ctx.domain_name, - fakes.fakeAuthTokenHeaders['X-User-Domain-Name']) - self.assertEqual(ctx.domain_id, - fakes.fakeAuthTokenHeaders['X-User-Domain-Id']) - self.assertIsNone(ctx.auth_token_info) - - def test_auth_hook_before_method_auth_info(self, mock_cls): - state = mock.Mock(request=fakes.FakePecanRequest()) - state.request.environ['keystone.token_info'] = 'assert_this' - hook = auth.AuthInformationHook() - hook.before(state) - ctx = state.request.context - self.assertIsInstance(ctx, context.RequestContext) - self.assertEqual(fakes.fakeAuthTokenHeaders['X-Auth-Token'], - ctx.auth_token) - self.assertEqual('assert_this', ctx.auth_token_info) diff --git a/magnum/tests/api/test_hooks.py b/magnum/tests/api/test_hooks.py new file mode 100644 index 0000000000..552554b098 --- /dev/null +++ b/magnum/tests/api/test_hooks.py @@ -0,0 +1,59 @@ +# Copyright 2014 +# The Cloudscaling Group, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not +# use this file except in compliance with the License. You may obtain a copy +# of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import mock + +from magnum.api import hooks +from magnum.common import context +from magnum.tests import base +from magnum.tests import fakes + + +class TestHooks(base.BaseTestCase): + + def setUp(self): + super(TestHooks, self).setUp() + self.app = fakes.FakeApp() + + def test_context_hook_before_method(self): + state = mock.Mock(request=fakes.FakePecanRequest()) + hook = hooks.ContextHook() + hook.before(state) + ctx = state.request.context + self.assertIsInstance(ctx, context.RequestContext) + self.assertEqual(ctx.auth_token, + fakes.fakeAuthTokenHeaders['X-Auth-Token']) + self.assertEqual(ctx.tenant, + fakes.fakeAuthTokenHeaders['X-Tenant-Id']) + self.assertEqual(ctx.user, + fakes.fakeAuthTokenHeaders['X-User-Id']) + self.assertEqual(ctx.auth_url, + fakes.fakeAuthTokenHeaders['X-Auth-Url']) + self.assertEqual(ctx.domain_name, + fakes.fakeAuthTokenHeaders['X-User-Domain-Name']) + self.assertEqual(ctx.domain_id, + fakes.fakeAuthTokenHeaders['X-User-Domain-Id']) + self.assertIsNone(ctx.auth_token_info) + + def test_context_hook_before_method_auth_info(self): + state = mock.Mock(request=fakes.FakePecanRequest()) + state.request.environ['keystone.token_info'] = 'assert_this' + hook = hooks.ContextHook() + hook.before(state) + ctx = state.request.context + self.assertIsInstance(ctx, context.RequestContext) + self.assertEqual(fakes.fakeAuthTokenHeaders['X-Auth-Token'], + ctx.auth_token) + self.assertEqual('assert_this', ctx.auth_token_info) diff --git a/magnum/tests/config.py b/magnum/tests/config.py index d1d674259d..0d26b21174 100644 --- a/magnum/tests/config.py +++ b/magnum/tests/config.py @@ -10,6 +10,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from magnum.api import hooks + # Server Specific Configurations server = { 'port': '8080', @@ -20,13 +22,13 @@ server = { app = { 'root': 'magnum.api.controllers.root.RootController', 'modules': ['magnum.api'], - 'static_root': '%(confdir)s/../../public', - 'template_path': '%(confdir)s/../templates', 'debug': True, - 'errors': { - '404': '/error/404', - '__force_dict__': True - } + 'hooks': [ + hooks.ContextHook(), + ], + 'acl_public_routes': [ + '/' + ], } # Custom Configurations must be in Python dictionary format::