diff --git a/climate/api/oshosts/service.py b/climate/api/oshosts/service.py index 3ff94906..50f6beb9 100644 --- a/climate/api/oshosts/service.py +++ b/climate/api/oshosts/service.py @@ -15,20 +15,19 @@ from climate import exceptions from climate.manager.oshosts import rpcapi as manager_rpcapi -from climate.openstack.common import log as logging - - -LOG = logging.getLogger(__name__) +from climate import policy class API(object): def __init__(self): self.manager_rpcapi = manager_rpcapi.ManagerRPCAPI() + @policy.authorize('oshosts', 'get') def get_computehosts(self): """List all existing computehosts.""" return self.manager_rpcapi.list_computehosts() + @policy.authorize('oshosts', 'create') def create_computehost(self, data): """Create new computehost. @@ -41,6 +40,7 @@ class API(object): return self.manager_rpcapi.create_computehost(data) + @policy.authorize('oshosts', 'get') def get_computehost(self, host_id): """Get computehost by its ID. @@ -49,6 +49,7 @@ class API(object): """ return self.manager_rpcapi.get_computehost(host_id) + @policy.authorize('oshosts', 'update') def update_computehost(self, host_id, data): """Update computehost. Only name changing may be proceeded. @@ -66,6 +67,7 @@ class API(object): data['name'] = new_name return self.manager_rpcapi.update_computehost(host_id, data) + @policy.authorize('oshosts', 'delete') def delete_computehost(self, host_id): """Delete specified computehost. diff --git a/climate/api/service.py b/climate/api/service.py index 7a1bb5f1..cbceb99e 100644 --- a/climate/api/service.py +++ b/climate/api/service.py @@ -16,6 +16,7 @@ from climate import exceptions from climate.manager import rpcapi as manager_rpcapi from climate.openstack.common import log as logging +from climate import policy LOG = logging.getLogger(__name__) @@ -28,10 +29,12 @@ class API(object): ## Leases operations + @policy.authorize('leases', 'get') def get_leases(self): """List all existing leases.""" return self.manager_rpcapi.list_leases() + @policy.authorize('leases', 'create') def create_lease(self, data): """Create new lease. @@ -43,6 +46,7 @@ class API(object): data.update({'trust': trust}) return self.manager_rpcapi.create_lease(data) + @policy.authorize('leases', 'get') def get_lease(self, lease_id): """Get lease by its ID. @@ -51,6 +55,7 @@ class API(object): """ return self.manager_rpcapi.get_lease(lease_id) + @policy.authorize('leases', 'update') def update_lease(self, lease_id, data): """Update lease. Only name changing and prolonging may be proceeded. @@ -76,6 +81,7 @@ class API(object): data['start_date'] = start_date return self.manager_rpcapi.update_lease(lease_id, data) + @policy.authorize('leases', 'delete') def delete_lease(self, lease_id): """Delete specified lease. @@ -86,6 +92,7 @@ class API(object): ## Plugins operations + @policy.authorize('plugins', 'get') def get_plugins(self): """List all possible plugins.""" pass diff --git a/climate/policy.py b/climate/policy.py new file mode 100644 index 00000000..e8e32dc0 --- /dev/null +++ b/climate/policy.py @@ -0,0 +1,111 @@ +# Copyright (c) 2013 Bull. +# +# 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 Climate.""" + +import functools +from oslo.config import cfg + +from climate import context +from climate import exceptions +from climate.openstack.common import log as logging +from climate.openstack.common import policy + +CONF = cfg.CONF + +LOG = logging.getLogger(__name__) + +_ENFORCER = None + + +def reset(): + global _ENFORCER + + if _ENFORCER: + _ENFORCER.clear() + _ENFORCER = None + + +def init(): + global _ENFORCER + if not _ENFORCER: + LOG.debug("Enforcer not present, recreating at init stage.") + _ENFORCER = policy.Enforcer() + + +def set_rules(data, default_rule=None): + default_rule = default_rule or CONF.policy_default_rule + if not _ENFORCER: + LOG.debug("Enforcer not present, recreating at rules stage.") + init() + if default_rule: + _ENFORCER.default_rule = default_rule + _ENFORCER.set_rules(policy.Rules.load_json(data, default_rule)) + + +def enforce(context, action, target, do_raise=True): + """Verifies that the action is valid on the target in this context. + + :param context: climate 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. ``{'tenant_id': context.tenant_id}`` + :param do_raise: if True (the default), raises PolicyNotAuthorized; + if False, returns False + + :raises climate.exceptions.PolicyNotAuthorized: if verification fails + and do_raise is True. + + :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_dict() + + # Add the exceptions arguments if asked to do a raise + extra = {} + if do_raise: + extra.update(exc=exceptions.PolicyNotAuthorized, action=action) + + return _ENFORCER.enforce(action, target, credentials, do_raise=do_raise, + **extra) + + +def authorize(extension, action=None, api='climate', ctx=None, + target=None): + def decorator(func): + + @functools.wraps(func) + def wrapped(self, *args, **kwargs): + cur_ctx = ctx or context.current() + tgt = target or {'tenant_id': cur_ctx.tenant_id, + 'user_id': cur_ctx.user_id} + if action is None: + act = '%s:%s' % (api, extension) + else: + act = '%s:%s:%s' % (api, extension, action) + enforce(cur_ctx, act, tgt) + return func(self, *args, **kwargs) + + return wrapped + return decorator diff --git a/climate/tests/__init__.py b/climate/tests/__init__.py index caa1eac8..75698e1b 100644 --- a/climate/tests/__init__.py +++ b/climate/tests/__init__.py @@ -22,11 +22,13 @@ from oslo.config import cfg from climate import context from climate.db.sqlalchemy import api as db_api from climate.openstack.common.db.sqlalchemy import session as db_session +from climate.openstack.common import fileutils from climate.openstack.common.fixture import config from climate.openstack.common.fixture import mockpatch from climate.openstack.common import log as logging +from climate.openstack.common import policy as common_policy from climate.openstack.common import test - +from climate.tests import fake_policy CONF = cfg.CONF CONF.set_override('use_stderr', False) @@ -66,6 +68,13 @@ class TestCase(test.BaseTestCase): self.useFixture(config.Config()) self.context_mock = None + self.fileutils = fileutils + self.read_cached_file = self.patch(self.fileutils, 'read_cached_file') + self.read_cached_file.return_value = (True, fake_policy.policy_data) + self.common_policy = common_policy + self.patch(self.common_policy.Enforcer, '_get_policy_path') + CONF.set_override('policy_file', 'fake') + def patch(self, obj, attr): """Returns a Mocked object on the patched attribute.""" mockfixture = self.useFixture(mockpatch.PatchObject(obj, attr)) diff --git a/climate/tests/fake_policy.py b/climate/tests/fake_policy.py new file mode 100644 index 00000000..cbd96773 --- /dev/null +++ b/climate/tests/fake_policy.py @@ -0,0 +1,28 @@ +# Copyright (c) 2013 Bull. +# +# 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_data = """ +{ + + "admin": "is_admin:True or role:admin or role:masterofuniverse", + "admin_or_owner": "rule:admin or tenant_id:%(tenant_id)s", + "default": "!", + + "admin_api": "rule:admin", + "climate:leases": "rule:admin_or_owner", + "climate:leases:get": "rule:admin_or_owner", + "climate:os-hosts": "rule:admin_api" +} +""" diff --git a/climate/tests/test_policy.py b/climate/tests/test_policy.py new file mode 100644 index 00000000..e3daa060 --- /dev/null +++ b/climate/tests/test_policy.py @@ -0,0 +1,137 @@ +# Copyright (c) 2013 Bull. +# +# 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 Climate.""" + +from oslo.config import cfg + +from climate import context +from climate import exceptions +from climate import policy +from climate import tests + +CONF = cfg.CONF + + +class DefaultPolicyTestCase(tests.TestCase): + + def setUp(self): + super(DefaultPolicyTestCase, self).setUp() + + self.rules = """ + { + "default": "", + "example:exist": "!", + "example:allowed": "@", + "example:my_file": "role:admin or \ + tenant_id:%(tenant_id)s" + } + """ + + self.default_rule = None + policy.reset() + self.read_cached_file.return_value = (True, self.rules) + self.context = context.ClimateContext(user_id='fake', tenant_id='fake', + roles=['member']) + + def _set_rules(self, default_rule): + self.default_rule = default_rule + policy.set_rules(self.rules, default_rule) + + def test_policy_called(self): + self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, + self.context, "example:exist", {}) + + def test_not_found_policy_calls_default(self): + result = policy.enforce(self.context, "example:noexist", {}, False) + self.assertEqual(result, True) + + def test_default_not_found(self): + self._set_rules("default_noexist") + self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, + self.context, "example:noexist", {}) + + def test_enforce_good_action(self): + action = "example:allowed" + result = policy.enforce(self.context, action, {}, False) + self.assertEqual(result, True) + + def test_templatized_enforcement(self): + target_mine = {'tenant_id': 'fake'} + target_not_mine = {'tenant_id': 'another'} + action = "example:my_file" + policy.enforce(self.context, action, target_mine) + self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, + self.context, action, target_not_mine) + + +class ClimatePolicyTestCase(tests.TestCase): + + def setUp(self): + super(ClimatePolicyTestCase, self).setUp() + + self.context = context.ClimateContext(user_id='fake', tenant_id='fake', + roles=['member']) + + def test_standardpolicy(self): + target_good = {'user_id': self.context.user_id, + 'tenant_id': self.context.tenant_id} + target_wrong = {'user_id': self.context.user_id, + 'tenant_id': 'bad_tenant'} + action = "climate:leases" + self.assertEqual(True, policy.enforce(self.context, action, + target_good)) + self.assertEqual(False, policy.enforce(self.context, action, + target_wrong, False)) + + def test_adminpolicy(self): + target = {'user_id': self.context.user_id, + 'tenant_id': self.context.tenant_id} + action = "climate:os-hosts" + self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, + self.context, action, target) + + def test_elevatedpolicy(self): + target = {'user_id': self.context.user_id, + 'tenant_id': self.context.tenant_id} + action = "climate:os-hosts" + self.assertRaises(exceptions.PolicyNotAuthorized, policy.enforce, + self.context, action, target) + elevated_context = self.context.elevated() + self.assertEqual(True, + policy.enforce(elevated_context, action, target)) + + def test_authorize(self): + + @policy.authorize('leases', ctx=self.context) + def user_method(self): + return True + + @policy.authorize('leases', 'get', ctx=self.context) + def user_method_with_action(self): + return True + + @policy.authorize('oshosts', ctx=self.context) + def adminonly_method(self): + return True + + self.assertEqual(True, user_method(self)) + self.assertEqual(True, user_method_with_action(self)) + try: + adminonly_method(self) + self.assertTrue(False) + except exceptions.PolicyNotAuthorized: + # We are expecting this exception + self.assertTrue(True) diff --git a/etc/policy.json b/etc/policy.json new file mode 100644 index 00000000..588abba4 --- /dev/null +++ b/etc/policy.json @@ -0,0 +1,20 @@ +{ + "admin": "is_admin:True or role:admin or role:masterofuniverse", + + "admin_or_owner": "rule:admin or tenant_id:%(tenant_id)s", + + "default": "!", + "admin_api": "rule:admin", + + "climate:leases:get": "rule:admin_or_owner", + "climate:leases:create": "rule:admin_or_owner", + "climate:leases:delete": "rule:admin_or_owner", + "climate:leases:update": "rule:admin_or_owner", + + "climate:plugins:get": "@", + + "climate:oshosts:get": "rule:admin_or_owner", + "climate:oshosts:create": "rule:admin_api", + "climate:oshosts:delete": "rule:admin_api", + "climate:oshosts:update": "rule:admin_api" +}