diff --git a/octavia/common/context.py b/octavia/common/context.py index ed1bfc3356..e1964f8e7b 100644 --- a/octavia/common/context.py +++ b/octavia/common/context.py @@ -14,6 +14,7 @@ from oslo_context import context as common_context +from octavia.common import policy from octavia.db import api as db_api @@ -21,6 +22,15 @@ class Context(common_context.RequestContext): _session = None + def __init__(self, user=None, project_id=None, is_admin=False, **kwargs): + + if project_id: + kwargs['tenant'] = project_id + + super(Context, self).__init__(is_admin=is_admin, **kwargs) + + self.policy = policy.Policy(self) + @property def session(self): if self._session is None: diff --git a/octavia/common/policy.py b/octavia/common/policy.py new file mode 100644 index 0000000000..bc95345741 --- /dev/null +++ b/octavia/common/policy.py @@ -0,0 +1,130 @@ +# 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. + +"""Policy Engine For Octavia.""" + +import logging + +from oslo_config import cfg +from oslo_policy import policy as oslo_policy +from oslo_utils import excutils + +from octavia.common import exceptions +from octavia.i18n import _LE +from octavia import policies + + +LOG = logging.getLogger(__name__) + + +class Policy(oslo_policy.Enforcer): + + def __init__(self, context, conf=cfg.CONF, policy_file=None, rules=None, + default_rule=None, use_conf=True, overwrite=True): + """Init an Enforcer class. + + :param context: A context object. + :param conf: A configuration object. + :param policy_file: Custom policy file to use, if none is + specified, ``conf.oslo_policy.policy_file`` + will be used. + :param rules: Default dictionary / Rules to use. It will be + considered just in the first instantiation. If + :meth:`load_rules` with ``force_reload=True``, + :meth:`clear` or :meth:`set_rules` with + ``overwrite=True`` is called this will be + overwritten. + :param default_rule: Default rule to use, conf.default_rule will + be used if none is specified. + :param use_conf: Whether to load rules from cache or config file. + :param overwrite: Whether to overwrite existing rules when reload + rules from config file. + """ + + super(Policy, self).__init__(conf, policy_file, rules, default_rule, + use_conf, overwrite) + self.context = context + self.register_defaults(policies.list_rules()) + + def authorize(self, action, target, do_raise=True, exc=None): + """Verifies that the action is valid on the target in this context. + + :param context: nova context + :param action: string representing the action to be checked + this should be colon separated for clarity. + i.e. ``compute:create_instance``, + ``compute:attach_volume``, + ``volume:attach_volume`` + :param target: dictionary representing the object of the action + for object creation this should be a dictionary representing the + location of the object e.g. + ``{'project_id': context.project_id}`` + :param do_raise: if True (the default), raises PolicyNotAuthorized; + if False, returns False + :param exc: Class of the exceptions to raise if the check fails. + Any remaining arguments passed to :meth:`enforce` (both + positional and keyword arguments) will be passed to + the exceptions class. If not specified, + :class:`PolicyNotAuthorized` will be used. + + :raises nova.exceptions.PolicyNotAuthorized: if verification fails + and do_raise is True. Or if 'exc' is specified it will raise an + exceptions of that type. + + :return: returns a non-False value (not necessarily "True") if + authorized, and the exact value False if not authorized and + do_raise is False. + """ + credentials = self.context.to_policy_values() + if not exc: + exc = exceptions.NotAuthorized + + try: + return super(Policy, self).authorize( + action, target, credentials, do_raise=do_raise, exc=exc) + except oslo_policy.PolicyNotRegistered: + with excutils.save_and_reraise_exception(): + LOG.exception(_LE('Policy not registered')) + except Exception: + credentials.pop('auth_token', None) + with excutils.save_and_reraise_exception(): + LOG.debug('Policy check for %(action)s failed with ' + 'credentials %(credentials)s', + {'action': action, 'credentials': credentials}) + + def check_is_admin(self): + """Does roles contains 'admin' role according to policy setting. + + """ + credentials = self.context.to_dict() + target = credentials + return self.enforce('context_is_admin', target, credentials) + + def get_rules(self): + return self.rules + + +@oslo_policy.register('is_admin') +class IsAdminCheck(oslo_policy.Check): + """An explicit check for is_admin.""" + + def __init__(self, kind, match): + """Initialize the check.""" + + self.expected = match.lower() == 'true' + + super(IsAdminCheck, self).__init__(kind, str(self.expected)) + + def __call__(self, target, creds, enforcer): + """Determine whether is_admin matches the requested value.""" + + return creds['is_admin'] == self.expected diff --git a/octavia/policies/__init__.py b/octavia/policies/__init__.py new file mode 100644 index 0000000000..a257df3ccc --- /dev/null +++ b/octavia/policies/__init__.py @@ -0,0 +1,22 @@ +# 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 itertools + +from octavia.policies import base + + +def list_rules(): + return itertools.chain( + base.list_rules(), + ) diff --git a/octavia/policies/base.py b/octavia/policies/base.py new file mode 100644 index 0000000000..9cff0ef02d --- /dev/null +++ b/octavia/policies/base.py @@ -0,0 +1,24 @@ +# 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_policy import policy + +rules = [ + policy.RuleDefault('context_is_admin', 'role:admin'), + policy.RuleDefault('admin_or_owner', + 'is_admin:True or project_id:%(project_id)s'), + policy.RuleDefault('admin_api', 'is_admin:True'), +] + + +def list_rules(): + return rules diff --git a/octavia/tests/unit/common/test_policy.py b/octavia/tests/unit/common/test_policy.py new file mode 100644 index 0000000000..86a2f3fa2a --- /dev/null +++ b/octavia/tests/unit/common/test_policy.py @@ -0,0 +1,215 @@ +# 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. + +"""Test of Policy Engine For Octavia.""" + +import tempfile + +from oslo_config import fixture as oslo_fixture +from oslo_policy import policy as oslo_policy +import requests_mock + +from octavia.common import config +from octavia.common import context +from octavia.common import exceptions +from octavia.common import policy +from octavia.tests.unit import base + +CONF = config.cfg.CONF + + +class PolicyFileTestCase(base.TestCase): + + def setUp(self): + super(PolicyFileTestCase, self).setUp() + + self.conf = self.useFixture(oslo_fixture.Config(CONF)) + self.target = {} + + def test_modified_policy_reloads(self): + with tempfile.NamedTemporaryFile(mode='w', delete=True) as tmp: + self.conf.load_raw_values( + group='oslo_policy', policy_file=tmp.name) + + self.context = context.Context('fake', 'fake') + + rule = oslo_policy.RuleDefault('example:test', "") + self.context.policy.register_defaults([rule]) + + action = "example:test" + tmp.write('{"example:test": ""}') + tmp.flush() + self.context.policy.authorize(action, self.target) + + tmp.seek(0) + tmp.write('{"example:test": "!"}') + tmp.flush() + self.context.policy.load_rules(True) + self.assertRaises(exceptions.NotAuthorized, + self.context.policy.authorize, + action, self.target) + + +class PolicyTestCase(base.TestCase): + + def setUp(self): + super(PolicyTestCase, self).setUp() + + self.conf = self.useFixture(oslo_fixture.Config()) + # diltram: this one must be removed after fixing issue in oslo.config + # https://bugs.launchpad.net/oslo.config/+bug/1645868 + self.conf.conf.__call__(args=[]) + + self.rules = [ + oslo_policy.RuleDefault("true", "@"), + oslo_policy.RuleDefault("example:allowed", "@"), + oslo_policy.RuleDefault("example:denied", "!"), + oslo_policy.RuleDefault("example:get_http", + "http://www.example.com"), + oslo_policy.RuleDefault("example:my_file", + "role:compute_admin or " + "project_id:%(project_id)s"), + oslo_policy.RuleDefault("example:early_and_fail", "! and @"), + oslo_policy.RuleDefault("example:early_or_success", "@ or !"), + oslo_policy.RuleDefault("example:lowercase_admin", + "role:admin or role:sysadmin"), + oslo_policy.RuleDefault("example:uppercase_admin", + "role:ADMIN or role:sysadmin"), + ] + self.context = context.Context('fake', 'fake', roles=['member']) + self.context.policy.register_defaults(self.rules) + self.target = {} + + def test_authorize_nonexistent_action_throws(self): + action = "example:noexist" + self.assertRaises( + oslo_policy.PolicyNotRegistered, self.context.policy.authorize, + action, self.target) + + def test_authorize_bad_action_throws(self): + action = "example:denied" + self.assertRaises( + exceptions.NotAuthorized, self.context.policy.authorize, + action, self.target) + + def test_authorize_bad_action_noraise(self): + action = "example:denied" + result = self.context.policy.authorize(action, self.target, False) + self.assertFalse(result) + + def test_authorize_good_action(self): + action = "example:allowed" + result = self.context.policy.authorize(action, self.target) + self.assertTrue(result) + + @requests_mock.mock() + def test_authorize_http(self, req_mock): + req_mock.post('http://www.example.com/', text='False') + action = "example:get_http" + self.assertRaises(exceptions.NotAuthorized, + self.context.policy.authorize, action, self.target) + + def test_templatized_authorization(self): + target_mine = {'project_id': 'fake'} + target_not_mine = {'project_id': 'another'} + action = "example:my_file" + + self.context.policy.authorize(action, target_mine) + self.assertRaises(exceptions.NotAuthorized, + self.context.policy.authorize, + action, target_not_mine) + + def test_early_AND_authorization(self): + action = "example:early_and_fail" + self.assertRaises(exceptions.NotAuthorized, + self.context.policy.authorize, action, self.target) + + def test_early_OR_authorization(self): + action = "example:early_or_success" + self.context.policy.authorize(action, self.target) + + def test_ignore_case_role_check(self): + lowercase_action = "example:lowercase_admin" + uppercase_action = "example:uppercase_admin" + + # NOTE(dprince) we mix case in the Admin role here to ensure + # case is ignored + self.context = context.Context('admin', 'fake', roles=['AdMiN']) + self.context.policy.register_defaults(self.rules) + + self.context.policy.authorize(lowercase_action, self.target) + self.context.policy.authorize(uppercase_action, self.target) + + def test_check_is_admin_fail(self): + self.assertFalse(self.context.policy.check_is_admin()) + + def test_check_is_admin(self): + self.context = context.Context('admin', 'fake', roles=['AdMiN']) + self.context.policy.register_defaults(self.rules) + + self.assertTrue(self.context.policy.check_is_admin()) + + +class IsAdminCheckTestCase(base.TestCase): + + def setUp(self): + super(IsAdminCheckTestCase, self).setUp() + self.context = context.Context('fake', 'fake') + + def test_init_true(self): + check = policy.IsAdminCheck('is_admin', 'True') + + self.assertEqual(check.kind, 'is_admin') + self.assertEqual(check.match, 'True') + self.assertTrue(check.expected) + + def test_init_false(self): + check = policy.IsAdminCheck('is_admin', 'nottrue') + + self.assertEqual(check.kind, 'is_admin') + self.assertEqual(check.match, 'False') + self.assertFalse(check.expected) + + def test_call_true(self): + check = policy.IsAdminCheck('is_admin', 'True') + + self.assertTrue( + check('target', dict(is_admin=True), self.context.policy)) + self.assertFalse( + check('target', dict(is_admin=False), self.context.policy)) + + def test_call_false(self): + check = policy.IsAdminCheck('is_admin', 'False') + + self.assertFalse( + check('target', dict(is_admin=True), self.context.policy)) + self.assertTrue( + check('target', dict(is_admin=False), self.context.policy)) + + +class AdminRolePolicyTestCase(base.TestCase): + + def setUp(self): + super(AdminRolePolicyTestCase, self).setUp() + self.context = context.Context('fake', 'fake', roles=['member']) + self.actions = self.context.policy.get_rules().keys() + self.target = {} + + def test_authorize_admin_actions_with_nonadmin_context_throws(self): + """Check if non-admin context passed to admin actions throws + + Policy not authorized exception + """ + for action in self.actions: + self.assertRaises( + oslo_policy.PolicyNotAuthorized, self.context.policy.authorize, + action, self.target) diff --git a/releasenotes/notes/add-policy-json-support-38929bb1fb581a7a.yaml b/releasenotes/notes/add-policy-json-support-38929bb1fb581a7a.yaml new file mode 100644 index 0000000000..a6f89f039a --- /dev/null +++ b/releasenotes/notes/add-policy-json-support-38929bb1fb581a7a.yaml @@ -0,0 +1,6 @@ +--- +features: + - Policy.json enforcement in Octavia. + + * Enables verification of privileges on specific API command for a specific + user role and project_id. diff --git a/requirements.txt b/requirements.txt index b4d21bb8e6..3f1b73265a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,6 +22,7 @@ oslo.i18n>=2.1.0 # Apache-2.0 oslo.log>=3.11.0 # Apache-2.0 oslo.messaging>=5.14.0 # Apache-2.0 oslo.middleware>=3.0.0 # Apache-2.0 +oslo.policy>=1.17.0 # Apache-2.0 oslo.reports>=0.6.0 # Apache-2.0 oslo.service>=1.10.0 # Apache-2.0 oslo.utils>=3.18.0 # Apache-2.0