From 0c29ef7ffe9a7f8fb561d41152c1eb4aebc7ad69 Mon Sep 17 00:00:00 2001 From: Boden R Date: Wed, 6 Apr 2016 15:30:53 -0400 Subject: [PATCH] Add Neutron context module and some policy methods Here we add the context module from Neutron, although it is currently marked as private because it will undergo changes related to the ongoing enginefacade and Keystone V3 work. We also add some of the roles management methods from Neutron's policy module. These are required for the initial DB patches that follow this one. Partially implements: Blueprint neutron-lib Change-Id: I77f8a05d6e3167f3096ad637d124e47ac39a83df --- neutron_lib/_context.py | 142 +++++++++++++++++++ neutron_lib/db/_api.py | 40 ++++++ neutron_lib/exceptions.py | 8 ++ neutron_lib/policy.py | 61 +++++++++ neutron_lib/tests/_base.py | 25 ++++ neutron_lib/tests/etc/neutron_lib.conf | 8 ++ neutron_lib/tests/etc/no_policy.json | 2 + neutron_lib/tests/etc/policy.json | 5 + neutron_lib/tests/unit/test_context.py | 183 +++++++++++++++++++++++++ neutron_lib/tests/unit/test_policy.py | 68 +++++++++ requirements.txt | 3 + 11 files changed, 545 insertions(+) create mode 100644 neutron_lib/_context.py create mode 100644 neutron_lib/db/_api.py create mode 100644 neutron_lib/policy.py create mode 100644 neutron_lib/tests/etc/neutron_lib.conf create mode 100644 neutron_lib/tests/etc/no_policy.json create mode 100644 neutron_lib/tests/etc/policy.json create mode 100644 neutron_lib/tests/unit/test_context.py create mode 100644 neutron_lib/tests/unit/test_policy.py diff --git a/neutron_lib/_context.py b/neutron_lib/_context.py new file mode 100644 index 000000000..bb1cab9b5 --- /dev/null +++ b/neutron_lib/_context.py @@ -0,0 +1,142 @@ +# 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. + +"""Context: context for security/db session.""" + +import copy +import datetime + +from oslo_context import context as oslo_context +from oslo_db.sqlalchemy import enginefacade + +# TODO(HenryG): replace db/_api.py with the real db/api.py +from neutron_lib.db import _api as db_api +from neutron_lib import policy + + +class ContextBase(oslo_context.RequestContext): + """Security context and request information. + + Represents the user taking a given action within the system. + + """ + + def __init__(self, user_id, tenant_id, is_admin=None, roles=None, + timestamp=None, request_id=None, tenant_name=None, + user_name=None, overwrite=True, auth_token=None, + is_advsvc=None, **kwargs): + """Object initialization. + + :param overwrite: Set to False to ensure that the greenthread local + copy of the index is not overwritten. + + :param kwargs: Extra arguments that might be present, but we ignore + because they possibly came in from older rpc messages. + """ + super(ContextBase, self).__init__(auth_token=auth_token, + user=user_id, tenant=tenant_id, + is_admin=is_admin, + request_id=request_id, + overwrite=overwrite, + roles=roles) + self.user_name = user_name + self.tenant_name = tenant_name + + if not timestamp: + timestamp = datetime.datetime.utcnow() + self.timestamp = timestamp + self.is_advsvc = is_advsvc + if self.is_advsvc is None: + self.is_advsvc = self.is_admin or policy.check_is_advsvc(self) + if self.is_admin is None: + self.is_admin = policy.check_is_admin(self) + + @property + def project_id(self): + return self.tenant + + @property + def tenant_id(self): + return self.tenant + + @tenant_id.setter + def tenant_id(self, tenant_id): + self.tenant = tenant_id + + @property + def user_id(self): + return self.user + + @user_id.setter + def user_id(self, user_id): + self.user = user_id + + def to_dict(self): + context = super(ContextBase, self).to_dict() + context.update({ + 'user_id': self.user_id, + 'tenant_id': self.tenant_id, + 'project_id': self.project_id, + 'timestamp': str(self.timestamp), + 'tenant_name': self.tenant_name, + 'project_name': self.tenant_name, + 'user_name': self.user_name, + }) + return context + + @classmethod + def from_dict(cls, values): + return cls(**values) + + def elevated(self): + """Return a version of this context with admin flag set.""" + context = copy.copy(self) + context.is_admin = True + + if 'admin' not in [x.lower() for x in context.roles]: + context.roles = context.roles + ["admin"] + + return context + + +@enginefacade.transaction_context_provider +class ContextBaseWithSession(ContextBase): + pass + + +class Context(ContextBaseWithSession): + def __init__(self, *args, **kwargs): + super(Context, self).__init__(*args, **kwargs) + self._session = None + + @property + def session(self): + # TODO(akamyshnikova): checking for session attribute won't be needed + # when reader and writer will be used + if hasattr(super(Context, self), 'session'): + return super(Context, self).session + if self._session is None: + self._session = db_api.get_session() + return self._session + + +def get_admin_context(): + return Context(user_id=None, + tenant_id=None, + is_admin=True, + overwrite=False) + + +def get_admin_context_without_session(): + return ContextBase(user_id=None, + tenant_id=None, + is_admin=True) diff --git a/neutron_lib/db/_api.py b/neutron_lib/db/_api.py new file mode 100644 index 000000000..023f14468 --- /dev/null +++ b/neutron_lib/db/_api.py @@ -0,0 +1,40 @@ +# 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. + +""" +TEMPORARY: use the old EngineFacade and lazy init. + +TODO(HenryG): replace this file with the new db/api.py from neutron. +""" + +from oslo_config import cfg +from oslo_db.sqlalchemy import session + +_FACADE = None + + +def _create_facade_lazily(): + global _FACADE + + # NOTE: This is going to change with bug 1520719 + if _FACADE is None: + _FACADE = session.EngineFacade.from_config(cfg.CONF, sqlite_fk=True) + + return _FACADE + + +def get_session(autocommit=True, expire_on_commit=False, use_slave=False): + """Helper method to grab session.""" + facade = _create_facade_lazily() + return facade.get_session(autocommit=autocommit, + expire_on_commit=expire_on_commit, + use_slave=use_slave) diff --git a/neutron_lib/exceptions.py b/neutron_lib/exceptions.py index 77e65bc2c..eb8bb6b99 100644 --- a/neutron_lib/exceptions.py +++ b/neutron_lib/exceptions.py @@ -240,3 +240,11 @@ class NetworkTunnelRangeError(NeutronException): if isinstance(kwargs['tunnel_range'], tuple): kwargs['tunnel_range'] = "%d:%d" % kwargs['tunnel_range'] super(NetworkTunnelRangeError, self).__init__(**kwargs) + + +class PolicyInitError(NeutronException): + message = _("Failed to initialize policy %(policy)s because %(reason)s.") + + +class PolicyCheckError(NeutronException): + message = _("Failed to check policy %(policy)s because %(reason)s.") diff --git a/neutron_lib/policy.py b/neutron_lib/policy.py new file mode 100644 index 000000000..473c541e3 --- /dev/null +++ b/neutron_lib/policy.py @@ -0,0 +1,61 @@ +# 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_policy import policy + + +_ENFORCER = None +_ADMIN_CTX_POLICY = 'context_is_admin' +_ADVSVC_CTX_POLICY = 'context_is_advsvc' + + +def reset(): + global _ENFORCER + if _ENFORCER: + _ENFORCER.clear() + _ENFORCER = None + + +def init(conf=cfg.CONF, policy_file=None): + """Init an instance of the Enforcer class.""" + + global _ENFORCER + if not _ENFORCER: + _ENFORCER = policy.Enforcer(conf, policy_file=policy_file) + _ENFORCER.load_rules(True) + + +def refresh(policy_file=None): + """Reset policy and init a new instance of Enforcer.""" + reset() + init(policy_file=policy_file) + + +def check_is_admin(context): + """Verify context has admin rights according to policy settings.""" + init() + # the target is user-self + credentials = context.to_dict() + if _ADMIN_CTX_POLICY not in _ENFORCER.rules: + return False + return _ENFORCER.enforce(_ADMIN_CTX_POLICY, credentials, credentials) + + +def check_is_advsvc(context): + """Verify context has advsvc rights according to policy settings.""" + init() + # the target is user-self + credentials = context.to_dict() + if _ADVSVC_CTX_POLICY not in _ENFORCER.rules: + return False + return _ENFORCER.enforce(_ADVSVC_CTX_POLICY, credentials, credentials) diff --git a/neutron_lib/tests/_base.py b/neutron_lib/tests/_base.py index 366fac36f..8c8a9526d 100644 --- a/neutron_lib/tests/_base.py +++ b/neutron_lib/tests/_base.py @@ -23,11 +23,13 @@ import mock from oslo_config import cfg from oslo_db import options as db_options from oslo_utils import strutils +import pbr.version import six import testtools from neutron_lib._i18n import _ from neutron_lib import constants + from neutron_lib.tests import _post_mortem_debug as post_mortem_debug from neutron_lib.tests import _tools as tools @@ -99,6 +101,19 @@ class AttributeDict(dict): class BaseTestCase(testtools.TestCase): + @staticmethod + def config_parse(conf=None, args=None): + """Create the default configurations.""" + if args is None: + args = [] + args += ['--config-file', etcdir('neutron_lib.conf')] + if conf is None: + version_info = pbr.version.VersionInfo('neutron-lib') + cfg.CONF(args=args, project='neutron_lib', + version='%%(prog)s %s' % version_info.release_string()) + else: + conf(args) + def setUp(self): super(BaseTestCase, self).setUp() @@ -110,6 +125,12 @@ class BaseTestCase(testtools.TestCase): sqlite_db='', max_pool_size=10, max_overflow=20, pool_timeout=10) + self.useFixture(fixtures.MonkeyPatch( + 'oslo_config.cfg.find_config_files', + lambda project=None, prog=None, extension=None: [])) + + self.setup_config() + # Configure this first to ensure pm debugging support for setUp() debugger = os.environ.get('OS_POST_MORTEM_DEBUGGER') if debugger: @@ -195,3 +216,7 @@ class BaseTestCase(testtools.TestCase): self.assertEqual(v, actual_superset[k], "Key %(key)s expected: %(exp)r, actual %(act)r" % {'key': k, 'exp': v, 'act': actual_superset[k]}) + + def setup_config(self, args=None): + """Tests that need a non-default config can override this method.""" + self.config_parse(args=args) diff --git a/neutron_lib/tests/etc/neutron_lib.conf b/neutron_lib/tests/etc/neutron_lib.conf new file mode 100644 index 000000000..df281ba2d --- /dev/null +++ b/neutron_lib/tests/etc/neutron_lib.conf @@ -0,0 +1,8 @@ +[DEFAULT] +# Show debugging output in logs (sets DEBUG log level output) +debug = False + +lock_path = $state_path/lock + +[database] +connection = 'sqlite://' diff --git a/neutron_lib/tests/etc/no_policy.json b/neutron_lib/tests/etc/no_policy.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/neutron_lib/tests/etc/no_policy.json @@ -0,0 +1,2 @@ +{ +} diff --git a/neutron_lib/tests/etc/policy.json b/neutron_lib/tests/etc/policy.json new file mode 100644 index 000000000..f5fca034b --- /dev/null +++ b/neutron_lib/tests/etc/policy.json @@ -0,0 +1,5 @@ +{ + "context_is_admin": "role:admin", + "context_is_advsvc": "role:advsvc", + "default": "rule:admin_or_owner" +} diff --git a/neutron_lib/tests/unit/test_context.py b/neutron_lib/tests/unit/test_context.py new file mode 100644 index 000000000..735b184a9 --- /dev/null +++ b/neutron_lib/tests/unit/test_context.py @@ -0,0 +1,183 @@ +# 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 oslo_context import context as oslo_context +from testtools import matchers + +from neutron_lib import _context +from neutron_lib.tests import _base + + +class TestNeutronContext(_base.BaseTestCase): + + def test_neutron_context_create(self): + ctx = _context.Context('user_id', 'tenant_id') + self.assertEqual('user_id', ctx.user_id) + self.assertEqual('tenant_id', ctx.project_id) + self.assertEqual('tenant_id', ctx.tenant_id) + request_id = ctx.request_id + if isinstance(request_id, bytes): + request_id = request_id.decode('utf-8') + self.assertThat(request_id, matchers.StartsWith('req-')) + self.assertEqual('user_id', ctx.user) + self.assertEqual('tenant_id', ctx.tenant) + self.assertIsNone(ctx.user_name) + self.assertIsNone(ctx.tenant_name) + self.assertIsNone(ctx.auth_token) + + def test_neutron_context_getter_setter(self): + ctx = _context.Context('Anakin', 'Skywalker') + self.assertEqual('Anakin', ctx.user_id) + self.assertEqual('Skywalker', ctx.tenant_id) + ctx.user_id = 'Darth' + ctx.tenant_id = 'Vader' + self.assertEqual('Darth', ctx.user_id) + self.assertEqual('Vader', ctx.tenant_id) + + def test_neutron_context_create_with_name(self): + ctx = _context.Context('user_id', 'tenant_id', + tenant_name='tenant_name', + user_name='user_name') + # Check name is set + self.assertEqual('user_name', ctx.user_name) + self.assertEqual('tenant_name', ctx.tenant_name) + # Check user/tenant contains its ID even if user/tenant_name is passed + self.assertEqual('user_id', ctx.user) + self.assertEqual('tenant_id', ctx.tenant) + + def test_neutron_context_create_with_request_id(self): + ctx = _context.Context('user_id', 'tenant_id', request_id='req_id_xxx') + self.assertEqual('req_id_xxx', ctx.request_id) + + def test_neutron_context_create_with_timestamp(self): + now = "Right Now!" + ctx = _context.Context('user_id', 'tenant_id', timestamp=now) + self.assertEqual(now, ctx.timestamp) + + def test_neutron_context_create_is_advsvc(self): + ctx = _context.Context('user_id', 'tenant_id', is_advsvc=True) + self.assertFalse(ctx.is_admin) + self.assertTrue(ctx.is_advsvc) + + def test_neutron_context_create_with_auth_token(self): + ctx = _context.Context('user_id', 'tenant_id', + auth_token='auth_token_xxx') + self.assertEqual('auth_token_xxx', ctx.auth_token) + + def test_neutron_context_from_dict(self): + owner = {'user_id': 'Luke', 'tenant_id': 'Skywalker'} + ctx = _context.Context.from_dict(owner) + self.assertEqual(owner['user_id'], ctx.user_id) + self.assertEqual(owner['tenant_id'], ctx.tenant_id) + + def test_neutron_context_to_dict(self): + ctx = _context.Context('user_id', 'tenant_id') + ctx_dict = ctx.to_dict() + self.assertEqual('user_id', ctx_dict['user_id']) + self.assertEqual('tenant_id', ctx_dict['project_id']) + self.assertEqual(ctx.request_id, ctx_dict['request_id']) + self.assertEqual('user_id', ctx_dict['user']) + self.assertEqual('tenant_id', ctx_dict['tenant']) + self.assertIsNone(ctx_dict['user_name']) + self.assertIsNone(ctx_dict['tenant_name']) + self.assertIsNone(ctx_dict['project_name']) + self.assertIsNone(ctx_dict['auth_token']) + + def test_neutron_context_to_dict_with_name(self): + ctx = _context.Context('user_id', 'tenant_id', + tenant_name='tenant_name', + user_name='user_name') + ctx_dict = ctx.to_dict() + self.assertEqual('user_name', ctx_dict['user_name']) + self.assertEqual('tenant_name', ctx_dict['tenant_name']) + self.assertEqual('tenant_name', ctx_dict['project_name']) + + def test_neutron_context_to_dict_with_auth_token(self): + ctx = _context.Context('user_id', 'tenant_id', + auth_token='auth_token_xxx') + ctx_dict = ctx.to_dict() + self.assertEqual('auth_token_xxx', ctx_dict['auth_token']) + + def test_neutron_context_admin_to_dict(self): + ctx = _context.get_admin_context() + ctx_dict = ctx.to_dict() + self.assertIsNone(ctx_dict['user_id']) + self.assertIsNone(ctx_dict['tenant_id']) + self.assertIsNone(ctx_dict['auth_token']) + self.assertTrue(ctx_dict['is_admin']) + self.assertIsNotNone(ctx.session) + self.assertNotIn('session', ctx_dict) + + def test_neutron_context_admin_without_session_to_dict(self): + ctx = _context.get_admin_context_without_session() + ctx_dict = ctx.to_dict() + self.assertIsNone(ctx_dict['user_id']) + self.assertIsNone(ctx_dict['tenant_id']) + self.assertIsNone(ctx_dict['auth_token']) + self.assertFalse(hasattr(ctx, 'session')) + + def test_neutron_context_elevated_retains_request_id(self): + ctx = _context.Context('user_id', 'tenant_id') + self.assertFalse(ctx.is_admin) + req_id_before = ctx.request_id + + elevated_ctx = ctx.elevated() + self.assertTrue(elevated_ctx.is_admin) + self.assertEqual(req_id_before, elevated_ctx.request_id) + + def test_neutron_context_elevated_idempotent(self): + ctx = _context.Context('user_id', 'tenant_id') + self.assertFalse(ctx.is_admin) + elevated_ctx = ctx.elevated() + self.assertTrue(elevated_ctx.is_admin) + elevated2_ctx = elevated_ctx.elevated() + self.assertTrue(elevated2_ctx.is_admin) + + def test_neutron_context_overwrite(self): + ctx1 = _context.Context('user_id', 'tenant_id') + self.assertEqual(ctx1.request_id, + oslo_context.get_current().request_id) + + # If overwrite is not specified, request_id should be updated. + ctx2 = _context.Context('user_id', 'tenant_id') + self.assertNotEqual(ctx2.request_id, ctx1.request_id) + self.assertEqual(ctx2.request_id, + oslo_context.get_current().request_id) + + # If overwrite is specified, request_id should be kept. + ctx3 = _context.Context('user_id', 'tenant_id', overwrite=False) + self.assertNotEqual(ctx3.request_id, ctx2.request_id) + self.assertEqual(ctx2.request_id, + oslo_context.get_current().request_id) + + def test_neutron_context_get_admin_context_not_update_local_store(self): + ctx = _context.Context('user_id', 'tenant_id') + req_id_before = oslo_context.get_current().request_id + self.assertEqual(ctx.request_id, req_id_before) + + ctx_admin = _context.get_admin_context() + self.assertEqual(req_id_before, oslo_context.get_current().request_id) + self.assertNotEqual(req_id_before, ctx_admin.request_id) + + @mock.patch.object(_context.ContextBaseWithSession, 'session') + def test_superclass_session(self, mocked_session): + ctx = _context.Context('user_id', 'tenant_id') + # make sure context uses parent class session that is mocked + self.assertEqual(mocked_session, ctx.session) + + def test_session_cached(self): + ctx = _context.Context('user_id', 'tenant_id') + session1 = ctx.session + session2 = ctx.session + self.assertIs(session1, session2) diff --git a/neutron_lib/tests/unit/test_policy.py b/neutron_lib/tests/unit/test_policy.py new file mode 100644 index 000000000..3694f5262 --- /dev/null +++ b/neutron_lib/tests/unit/test_policy.py @@ -0,0 +1,68 @@ +# 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 neutron_lib import _context +from neutron_lib import policy + +from neutron_lib.tests import _base as base + + +class TestPolicyEnforcer(base.BaseTestCase): + def setUp(self): + super(TestPolicyEnforcer, self).setUp() + # Isolate one _ENFORCER per test case + mock.patch.object(policy, '_ENFORCER', None).start() + + def test_init_reset_refresh(self): + self.assertIsNone(policy._ENFORCER) + policy.init() + self.assertIsNotNone(policy._ENFORCER) + policy.reset() + self.assertIsNone(policy._ENFORCER) + policy.refresh() + self.assertIsNotNone(policy._ENFORCER) + + def test_check_user_is_not_admin(self): + ctx = _context.Context('me', 'my_project') + self.assertFalse(policy.check_is_admin(ctx)) + + def test_check_user_elevated_is_admin(self): + ctx = _context.Context('me', 'my_project', roles=['user']).elevated() + self.assertTrue(policy.check_is_admin(ctx)) + + def test_check_is_admin_no_roles_no_admin(self): + policy.init(policy_file='no_policy.json') + ctx = _context.Context('me', 'my_project', roles=['user']).elevated() + # With no admin role, elevated() should not work. + self.assertFalse(policy.check_is_admin(ctx)) + + def test_check_is_advsvc_role(self): + ctx = _context.Context('me', 'my_project', roles=['advsvc']) + self.assertTrue(policy.check_is_advsvc(ctx)) + + def test_check_is_not_advsvc_user(self): + ctx = _context.Context('me', 'my_project', roles=['user']) + self.assertFalse(policy.check_is_advsvc(ctx)) + + def test_check_is_not_advsvc_admin(self): + ctx = _context.Context('me', 'my_project').elevated() + self.assertTrue(policy.check_is_admin(ctx)) + self.assertFalse(policy.check_is_advsvc(ctx)) + + def test_check_is_advsvc_no_roles_no_advsvc(self): + policy.init(policy_file='no_policy.json') + ctx = _context.Context('me', 'my_project', roles=['advsvc']) + # No advsvc role in the policy file, so cannot assume the role. + self.assertFalse(policy.check_is_advsvc(ctx)) diff --git a/requirements.txt b/requirements.txt index 0f348d723..22df1c14a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,8 +7,11 @@ Babel>=2.3.4 # BSD debtcollector>=1.2.0 # Apache-2.0 oslo.config>=3.12.0 # Apache-2.0 +oslo.context>=2.4.0,!=2.6.0 # Apache-2.0 oslo.db>=4.1.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0 oslo.log>=1.14.0 # Apache-2.0 oslo.messaging>=5.2.0 # Apache-2.0 +oslo.policy>=1.9.0 # Apache-2.0 +oslo.service>=1.10.0 # Apache-2.0 oslo.utils>=3.16.0 # Apache-2.0