diff --git a/cinder/context.py b/cinder/context.py index babfc43dc8f..ca26768c85d 100644 --- a/cinder/context.py +++ b/cinder/context.py @@ -25,7 +25,9 @@ from oslo_log import log as logging from oslo_utils import timeutils import six +from cinder import exception from cinder.i18n import _ +from cinder.objects import base as objects_base from cinder import policy context_opts = [ @@ -94,7 +96,7 @@ class RequestContext(context.RequestContext): # when policy.check_is_admin invokes request logging # to make it loggable. if self.is_admin is None: - self.is_admin = policy.check_is_admin(self.roles, self) + self.is_admin = policy.check_is_admin(self) elif self.is_admin and 'admin' not in self.roles: self.roles.append('admin') @@ -145,6 +147,42 @@ class RequestContext(context.RequestContext): user_domain=values.get('user_domain'), project_domain=values.get('project_domain')) + def authorize(self, action, target=None, target_obj=None, fatal=True): + """Verifies that the given action is valid on the target in this context. + + :param action: string representing the action to be checked. + :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}``. + If None, then this default target will be considered: + {'project_id': self.project_id, 'user_id': self.user_id} + :param: target_obj: dictionary representing the object which will be + used to update target. + :param fatal: if False, will return False when an + exception.NotAuthorized occurs. + + :raises cinder.exception.NotAuthorized: if verification fails and fatal + is True. + + :return: returns a non-False value (not necessarily "True") if + authorized and False if not authorized and fatal is False. + """ + if target is None: + target = {'project_id': self.project_id, + 'user_id': self.user_id} + if isinstance(target_obj, objects_base.CinderObject): + # Turn object into dict so target.update can work + target.update( + target_obj.obj_to_primitive()['versioned_object.data'] or {}) + else: + target.update(target_obj or {}) + try: + return policy.authorize(self, action, target) + except exception.NotAuthorized: + if fatal: + raise + return False + def to_policy_values(self): policy = super(RequestContext, self).to_policy_values() diff --git a/cinder/policies/__init__.py b/cinder/policies/__init__.py new file mode 100644 index 00000000000..ebb7430e334 --- /dev/null +++ b/cinder/policies/__init__.py @@ -0,0 +1,26 @@ +# Copyright (c) 2017 Huawei Technologies Co., Ltd. +# 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. + +import itertools + +from cinder.policies import attachments +from cinder.policies import base + + +def list_rules(): + return itertools.chain( + base.list_rules(), + attachments.list_rules() + ) diff --git a/cinder/policies/attachments.py b/cinder/policies/attachments.py new file mode 100644 index 00000000000..64d8284d9c4 --- /dev/null +++ b/cinder/policies/attachments.py @@ -0,0 +1,60 @@ +# Copyright (c) 2017 Huawei Technologies Co., Ltd. +# 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. + +from oslo_policy import policy + +from cinder.policies import base + + +CREATE_POLICY = 'volume:attachment_create' +UPDATE_POLICY = 'volume:attachment_update' +DELETE_POLICY = 'volume:attachment_delete' + +attachments_policies = [ + policy.DocumentedRuleDefault( + name=CREATE_POLICY, + check_str="", + description="""Create attachment.""", + operations=[ + { + 'method': 'POST', + 'path': '/attachments' + } + ]), + policy.DocumentedRuleDefault( + name=UPDATE_POLICY, + check_str=base.RULE_ADMIN_OR_OWNER, + description="""Update attachment.""", + operations=[ + { + 'method': 'PUT', + 'path': '/attachments/{attachment_id}' + } + ]), + policy.DocumentedRuleDefault( + name=DELETE_POLICY, + check_str=base.RULE_ADMIN_OR_OWNER, + description="""Delete attachment.""", + operations=[ + { + 'method': 'DELETE', + 'path': '/attachments/{attachment_id}' + } + ]), +] + + +def list_rules(): + return attachments_policies diff --git a/cinder/policies/base.py b/cinder/policies/base.py new file mode 100644 index 00000000000..26f32f84bf7 --- /dev/null +++ b/cinder/policies/base.py @@ -0,0 +1,35 @@ +# Copyright (c) 2017 Huawei Technologies Co., Ltd. +# 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. + +from oslo_policy import policy + +RULE_ADMIN_OR_OWNER = 'rule:admin_or_owner' +RULE_ADMIN_API = 'rule:admin_api' + +rules = [ + policy.RuleDefault('context_is_admin', 'role:admin'), + policy.RuleDefault('admin_or_owner', + 'is_admin:True or (role:admin and ' + 'is_admin_project:True) or project_id:%(project_id)s'), + policy.RuleDefault('default', + 'rule:admin_or_owner'), + policy.RuleDefault('admin_api', + 'is_admin:True or (role:admin and ' + 'is_admin_project:True)'), +] + + +def list_rules(): + return rules diff --git a/cinder/policy.py b/cinder/policy.py index 9a94d075ca1..13fb02201dd 100644 --- a/cinder/policy.py +++ b/cinder/policy.py @@ -15,23 +15,52 @@ """Policy Engine For Cinder""" +import sys from oslo_config import cfg +from oslo_log import log as logging from oslo_policy import opts as policy_opts from oslo_policy import policy +from oslo_utils import excutils from cinder import exception +from cinder import policies CONF = cfg.CONF +LOG = logging.getLogger(__name__) policy_opts.set_defaults(cfg.CONF, 'policy.json') _ENFORCER = None -def init(): +def reset(): + global _ENFORCER + if _ENFORCER: + _ENFORCER.clear() + _ENFORCER = None + + +def init(policy_file=None, rules=None, default_rule=None, use_conf=True): + """Init an Enforcer class. + + :param policy_file: Custom policy file to use, if none is specified, + `CONF.policy_file` will be used. + :param rules: Default dictionary / Rules to use. It will be + considered just in the first instantiation. + :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 config file. + """ + global _ENFORCER if not _ENFORCER: - _ENFORCER = policy.Enforcer(CONF) + _ENFORCER = policy.Enforcer(CONF, + policy_file=policy_file, + rules=rules, + default_rule=default_rule, + use_conf=use_conf) + register_rules(_ENFORCER) + _ENFORCER.load_rules() def enforce_action(context, action): @@ -72,19 +101,100 @@ def enforce(context, action, target): action=action) -def check_is_admin(roles, context=None): +def set_rules(rules, overwrite=True, use_conf=False): + """Set rules based on the provided dict of rules. + + :param rules: New rules to use. It should be an instance of dict. + :param overwrite: Whether to overwrite current rules or update them + with the new rules. + :param use_conf: Whether to reload rules from config file. + """ + + init(use_conf=False) + _ENFORCER.set_rules(rules, overwrite, use_conf) + + +def get_rules(): + if _ENFORCER: + return _ENFORCER.rules + + +def register_rules(enforcer): + enforcer.register_defaults(policies.list_rules()) + + +def get_enforcer(): + # This method is for use by oslopolicy CLI scripts. Those scripts need the + # 'output-file' and 'namespace' options, but having those in sys.argv means + # loading the Cinder config options will fail as those are not expected to + # be present. So we pass in an arg list with those stripped out. + conf_args = [] + # Start at 1 because cfg.CONF expects the equivalent of sys.argv[1:] + i = 1 + while i < len(sys.argv): + if sys.argv[i].strip('-') in ['namespace', 'output-file']: + i += 2 + continue + conf_args.append(sys.argv[i]) + i += 1 + + cfg.CONF(conf_args, project='cinder') + init() + return _ENFORCER + + +def authorize(context, action, target, do_raise=True, exc=None): + """Verifies that the action is valid on the target in this context. + + :param context: cinder 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 exception to raise if the check fails. + Any remaining arguments passed to :meth:`authorize` (both + positional and keyword arguments) will be passed to + the exception class. If not specified, + :class:`PolicyNotAuthorized` will be used. + + :raises cinder.exception.PolicyNotAuthorized: if verification fails + and do_raise is True. Or if 'exc' is specified it will raise an + exception 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. + """ + init() + credentials = context.to_policy_values() + if not exc: + exc = exception.PolicyNotAuthorized + try: + result = _ENFORCER.authorize(action, target, credentials, + do_raise=do_raise, exc=exc, action=action) + except policy.PolicyNotRegistered: + with excutils.save_and_reraise_exception(): + LOG.exception('Policy not registered') + except Exception: + with excutils.save_and_reraise_exception(): + LOG.error('Policy check for %(action)s failed with credentials ' + '%(credentials)s', + {'action': action, 'credentials': credentials}) + return result + + +def check_is_admin(context): """Whether or not user is admin according to policy setting. """ init() - - # include project_id on target to avoid KeyError if context_is_admin - # policy definition is missing, and default admin_or_owner rule - # attempts to apply. - target = {'project_id': ''} - if context is None: - credentials = {'roles': roles} - else: - credentials = context.to_dict() - - return _ENFORCER.enforce('context_is_admin', target, credentials) + # the target is user-self + credentials = context.to_policy_values() + target = credentials + return _ENFORCER.authorize('context_is_admin', target, credentials) diff --git a/cinder/test.py b/cinder/test.py index a72391a308a..9aa872189d0 100644 --- a/cinder/test.py +++ b/cinder/test.py @@ -313,8 +313,9 @@ class TestCase(testtools.TestCase): def flags(self, **kw): """Override CONF variables for a test.""" + group = kw.pop('group', None) for k, v in kw.items(): - self.override_config(k, v) + CONF.set_override(k, v, group) def start_service(self, name, host=None, **kwargs): host = host if host else uuid.uuid4().hex diff --git a/cinder/tests/unit/policy.json b/cinder/tests/unit/policy.json index 0912de6b2a9..fcb158c64ce 100644 --- a/cinder/tests/unit/policy.json +++ b/cinder/tests/unit/policy.json @@ -1,5 +1,4 @@ { - "context_is_admin": "role:admin", "admin_api": "is_admin:True", "admin_or_owner": "is_admin:True or project_id:%(project_id)s", @@ -113,9 +112,6 @@ "backup:update": "rule:admin_or_owner", "backup:backup_project_attribute": "rule:admin_api", - "volume:attachment_create": "", - "volume:attachment_update": "rule:admin_or_owner", - "volume:attachment_delete": "rule:admin_or_owner", "consistencygroup:create" : "", "consistencygroup:delete": "", diff --git a/cinder/tests/unit/test_policy.py b/cinder/tests/unit/test_policy.py new file mode 100644 index 00000000000..a168ca4fead --- /dev/null +++ b/cinder/tests/unit/test_policy.py @@ -0,0 +1,131 @@ +# Copyright (c) 2017 Huawei Technologies Co., Ltd. +# 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. +import os.path + +from oslo_config import cfg +from oslo_config import fixture as config_fixture +from oslo_policy import policy as oslo_policy + +from cinder import context +from cinder import exception +from cinder import test +from cinder import utils + +from cinder import policy + +CONF = cfg.CONF + + +class PolicyFileTestCase(test.TestCase): + + def setUp(self): + super(PolicyFileTestCase, self).setUp() + self.context = context.get_admin_context() + self.target = {} + self.fixture = self.useFixture(config_fixture.Config(CONF)) + self.addCleanup(policy.reset) + + def test_modified_policy_reloads(self): + with utils.tempdir() as tmpdir: + tmpfilename = os.path.join(tmpdir, 'policy') + self.fixture.config(policy_file=tmpfilename, group='oslo_policy') + policy.reset() + policy.init() + rule = oslo_policy.RuleDefault('example:test', "") + policy._ENFORCER.register_defaults([rule]) + + action = "example:test" + with open(tmpfilename, "w") as policyfile: + policyfile.write('{"example:test": ""}') + policy.authorize(self.context, action, self.target) + with open(tmpfilename, "w") as policyfile: + policyfile.write('{"example:test": "!"}') + policy._ENFORCER.load_rules(True) + self.assertRaises(exception.PolicyNotAuthorized, + policy.authorize, + self.context, action, self.target) + + +class PolicyTestCase(test.TestCase): + + def setUp(self): + super(PolicyTestCase, self).setUp() + rules = [ + oslo_policy.RuleDefault("true", '@'), + oslo_policy.RuleDefault("test:allowed", '@'), + oslo_policy.RuleDefault("test:denied", "!"), + oslo_policy.RuleDefault("test:my_file", + "role:compute_admin or " + "project_id:%(project_id)s"), + oslo_policy.RuleDefault("test:early_and_fail", "! and @"), + oslo_policy.RuleDefault("test:early_or_success", "@ or !"), + oslo_policy.RuleDefault("test:lowercase_admin", + "role:admin"), + oslo_policy.RuleDefault("test:uppercase_admin", + "role:ADMIN"), + ] + policy.reset() + policy.init() + # before a policy rule can be used, its default has to be registered. + policy._ENFORCER.register_defaults(rules) + self.context = context.RequestContext('fake', 'fake', roles=['member']) + self.target = {} + self.addCleanup(policy.reset) + + def test_authorize_nonexistent_action_throws(self): + action = "test:noexist" + self.assertRaises(oslo_policy.PolicyNotRegistered, policy.authorize, + self.context, action, self.target) + + def test_authorize_bad_action_throws(self): + action = "test:denied" + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_authorize_bad_action_noraise(self): + action = "test:denied" + result = policy.authorize(self.context, action, self.target, False) + self.assertFalse(result) + + def test_authorize_good_action(self): + action = "test:allowed" + result = policy.authorize(self.context, action, self.target) + self.assertTrue(result) + + def test_templatized_authorization(self): + target_mine = {'project_id': 'fake'} + target_not_mine = {'project_id': 'another'} + action = "test:my_file" + policy.authorize(self.context, action, target_mine) + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, target_not_mine) + + def test_early_AND_authorization(self): + action = "test:early_and_fail" + self.assertRaises(exception.PolicyNotAuthorized, policy.authorize, + self.context, action, self.target) + + def test_early_OR_authorization(self): + action = "test:early_or_success" + policy.authorize(self.context, action, self.target) + + def test_ignore_case_role_check(self): + lowercase_action = "test:lowercase_admin" + uppercase_action = "test:uppercase_admin" + admin_context = context.RequestContext('admin', + 'fake', + roles=['AdMiN']) + policy.authorize(admin_context, lowercase_action, self.target) + policy.authorize(admin_context, uppercase_action, self.target) diff --git a/cinder/tests/unit/volume/test_policy.py b/cinder/tests/unit/volume/test_policy.py deleted file mode 100644 index b4a931b91f8..00000000000 --- a/cinder/tests/unit/volume/test_policy.py +++ /dev/null @@ -1,55 +0,0 @@ -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# 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. -"""Tests for volume policy.""" -import mock - -from cinder import context -from cinder import test - -import cinder.policy - - -class VolumePolicyTestCase(test.TestCase): - - def setUp(self): - super(VolumePolicyTestCase, self).setUp() - - cinder.policy.init() - - self.context = context.get_admin_context() - - def test_check_policy(self): - target = { - 'project_id': self.context.project_id, - 'user_id': self.context.user_id, - } - with mock.patch.object(cinder.policy, 'enforce') as mock_enforce: - cinder.volume.api.check_policy(self.context, 'attach') - mock_enforce.assert_called_once_with(self.context, - 'volume:attach', - target) - - def test_check_policy_with_target(self): - target = { - 'project_id': self.context.project_id, - 'user_id': self.context.user_id, - 'id': 2, - } - with mock.patch.object(cinder.policy, 'enforce') as mock_enforce: - cinder.volume.api.check_policy(self.context, 'attach', {'id': 2}) - mock_enforce.assert_called_once_with(self.context, - 'volume:attach', - target) diff --git a/cinder/volume/api.py b/cinder/volume/api.py index d8c19f15fa1..3e0d00722eb 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -43,6 +43,7 @@ from cinder import keymgr as key_manager from cinder import objects from cinder.objects import base as objects_base from cinder.objects import fields +from cinder.policies import attachments as attachment_policy import cinder.policy from cinder import quota from cinder import quota_utils @@ -1986,13 +1987,13 @@ class API(base.Base): db_ref = self.db.volume_attach(ctxt.elevated(), values) return objects.VolumeAttachment.get_by_id(ctxt, db_ref['id']) - @wrap_check_policy def attachment_create(self, ctxt, volume_ref, instance_uuid, connector=None): """Create an attachment record for the specified volume.""" + ctxt.authorize(attachment_policy.CREATE_POLICY, target_obj=volume_ref) connection_info = {} attachment_ref = self._attachment_reserve(ctxt, volume_ref, @@ -2007,7 +2008,6 @@ class API(base.Base): attachment_ref.save() return attachment_ref - @wrap_check_policy def attachment_update(self, ctxt, attachment_ref, connector): """Update an existing attachment record.""" # Valid items to update (connector includes mode and mountpoint): @@ -2019,6 +2019,8 @@ class API(base.Base): # We fetch the volume object and pass it to the rpc call because we # need to direct this to the correct host/backend + ctxt.authorize(attachment_policy.UPDATE_POLICY, + target_obj=attachment_ref) volume_ref = objects.Volume.get_by_id(ctxt, attachment_ref.volume_id) connection_info = ( self.volume_rpcapi.attachment_update(ctxt, @@ -2029,8 +2031,9 @@ class API(base.Base): attachment_ref.save() return attachment_ref - @wrap_check_policy def attachment_delete(self, ctxt, attachment): + ctxt.authorize(attachment_policy.DELETE_POLICY, + target_obj=attachment) volume = objects.Volume.get_by_id(ctxt, attachment.volume_id) if attachment.attach_status == 'reserved': self.db.volume_detached(ctxt.elevated(), attachment.volume_id, diff --git a/etc/cinder/README-policy.generate.md b/etc/cinder/README-policy.generate.md new file mode 100644 index 00000000000..cdb1013dcdb --- /dev/null +++ b/etc/cinder/README-policy.generate.md @@ -0,0 +1,13 @@ +# Generate policy file +To generate the sample policy yaml file, run the following command from the top +level of the cinder directory: + + tox -egenpolicy + +# Use generated policy file +Cinder recognizes ``/etc/cinder/policy.yaml`` as the default policy file. +To specify your own policy file in order to overwrite the default policy value, +add this in Cinder config file: + + [oslo_policy] + policy_file = path/to/policy/file diff --git a/etc/cinder/cinder-policy-generator.conf b/etc/cinder/cinder-policy-generator.conf new file mode 100644 index 00000000000..290c0b278dd --- /dev/null +++ b/etc/cinder/cinder-policy-generator.conf @@ -0,0 +1,3 @@ +[DEFAULT] +output_file = etc/cinder/policy.yaml.sample +namespace = cinder diff --git a/etc/cinder/policy.json b/etc/cinder/policy.json index c51f564af13..69cc46906b0 100644 --- a/etc/cinder/policy.json +++ b/etc/cinder/policy.json @@ -1,9 +1,4 @@ { - "admin_or_owner": "is_admin:True or (role:admin and is_admin_project:True) or project_id:%(project_id)s", - "default": "rule:admin_or_owner", - - "admin_api": "is_admin:True or (role:admin and is_admin_project:True)", - "volume:create": "", "volume:create_from_image": "", "volume:delete": "rule:admin_or_owner", @@ -105,10 +100,6 @@ "backup:update": "rule:admin_or_owner", "backup:backup_project_attribute": "rule:admin_api", - "volume:attachment_create": "", - "volume:attachment_update": "rule:admin_or_owner", - "volume:attachment_delete": "rule:admin_or_owner", - "snapshot_extension:snapshot_actions:update_snapshot_status": "", "snapshot_extension:snapshot_manage": "rule:admin_api", "snapshot_extension:snapshot_unmanage": "rule:admin_api", diff --git a/setup.cfg b/setup.cfg index 23c21d49ffc..62941d960e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,6 +51,14 @@ oslo.config.opts = cinder = cinder.opts:list_opts oslo.config.opts.defaults = cinder = cinder.common.config:set_middleware_defaults +oslo.policy.enforcer = + cinder = cinder.policy:get_enforcer +oslo.policy.policies = + # The sample policies will be ordered by entry point and then by list + # returned from that entry point. If more control is desired split out each + # list_rules method into a separate entry point rather than using the + # aggregate method. + cinder = cinder.policies:list_rules console_scripts = cinder-api = cinder.cmd.api:main cinder-backup = cinder.cmd.backup:main diff --git a/tox.ini b/tox.ini index 252cfd0eda5..23691d4773d 100644 --- a/tox.ini +++ b/tox.ini @@ -83,6 +83,10 @@ sitepackages = False envdir = {toxworkdir}/pep8 commands = oslo-config-generator --config-file=tools/config/cinder-config-generator.conf + +[testenv:genpolicy] +commands = oslopolicy-sample-generator --config-file=etc/cinder/cinder-policy-generator.conf + [testenv:genopts] sitepackages = False envdir = {toxworkdir}/pep8