From ffaad1ffba2e1cb9d06ead8c34df3223c20a51fd Mon Sep 17 00:00:00 2001 From: Steven Hardy Date: Tue, 5 Feb 2013 19:21:00 +0000 Subject: [PATCH] Add initial code to support policy.json implementation We don't currently support a policy.json file like other openstack services, so this code (mostly copied from glance, then modified a bit) will allow us to add policy-based authorization to out APIs fairly easily Change-Id: I5ad9f55b3d0979e2526953bdce8b8227852e4b72 Signed-off-by: Steven Hardy --- heat/common/policy.py | 142 +++++++++++++++++++++++++ heat/tests/policy/deny_stack_user.json | 15 +++ heat/tests/policy/notallowed.json | 14 +++ heat/tests/test_common_policy.py | 107 +++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 heat/common/policy.py create mode 100644 heat/tests/policy/deny_stack_user.json create mode 100644 heat/tests/policy/notallowed.json create mode 100644 heat/tests/test_common_policy.py diff --git a/heat/common/policy.py b/heat/common/policy.py new file mode 100644 index 00000000..34aa0515 --- /dev/null +++ b/heat/common/policy.py @@ -0,0 +1,142 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 OpenStack, LLC. +# 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. + +# Based on glance/api/policy.py +"""Policy Engine For Heat""" + +import json +import os.path + +from heat.common import exception +from heat.openstack.common import cfg +import heat.openstack.common.log as logging +from heat.openstack.common import policy + +LOG = logging.getLogger(__name__) + +policy_opts = [ + cfg.StrOpt('policy_file', default='policy.json'), + cfg.StrOpt('policy_default_rule', default='default'), +] + +CONF = cfg.CONF +CONF.register_opts(policy_opts) + + +DEFAULT_RULES = { + 'default': policy.TrueCheck(), +} + + +class Enforcer(object): + """Responsible for loading and enforcing rules""" + + def __init__(self, scope='heat', exc=exception.Forbidden): + self.scope = scope + self.exc = exc + self.default_rule = CONF.policy_default_rule + self.policy_path = self._find_policy_file() + self.policy_file_mtime = None + self.policy_file_contents = None + + def set_rules(self, rules): + """Create a new Rules object based on the provided dict of rules""" + rules_obj = policy.Rules(rules, self.default_rule) + policy.set_rules(rules_obj) + + def load_rules(self): + """Set the rules found in the json file on disk""" + if self.policy_path: + rules = self._read_policy_file() + rule_type = "" + else: + rules = DEFAULT_RULES + rule_type = "default " + + text_rules = dict((k, str(v)) for k, v in rules.items()) + LOG.debug(_('Loaded %(rule_type)spolicy rules: %(text_rules)s') % + locals()) + + self.set_rules(rules) + + @staticmethod + def _find_policy_file(): + """Locate the policy json data file""" + policy_file = CONF.find_file(CONF.policy_file) + if policy_file: + return policy_file + else: + LOG.warn(_('Unable to find policy file')) + return None + + def _read_policy_file(self): + """Read contents of the policy file + + This re-caches policy data if the file has been changed. + """ + mtime = os.path.getmtime(self.policy_path) + if not self.policy_file_contents or mtime != self.policy_file_mtime: + LOG.debug(_("Loading policy from %s") % self.policy_path) + with open(self.policy_path) as fap: + raw_contents = fap.read() + rules_dict = json.loads(raw_contents) + self.policy_file_contents = dict( + (k, policy.parse_rule(v)) + for k, v in rules_dict.items()) + self.policy_file_mtime = mtime + return self.policy_file_contents + + def _check(self, context, rule, target, *args, **kwargs): + """Verifies that the action is valid on the target in this context. + + :param context: Heat request context + :param rule: String representing the action to be checked + :param object: Dictionary representing the object of the action. + :raises: self.exc (defaults to heat.common.exception.Forbidden) + :returns: A non-False value if access is allowed. + """ + self.load_rules() + + credentials = { + 'roles': context.roles, + 'user': context.username, + 'tenant': context.tenant, + } + + return policy.check(rule, target, credentials, *args, **kwargs) + + def enforce(self, context, action, target): + """Verifies that the action is valid on the target in this context. + + :param context: Heat request context + :param action: String representing the action to be checked + :param object: Dictionary representing the object of the action. + :raises: self.exc (defaults to heat.common.exception.Forbidden) + :returns: A non-False value if access is allowed. + """ + _action = '%s:%s' % (self.scope, action) + return self._check(context, _action, target, self.exc, action=action) + + def check(self, context, action, target): + """Verifies that the action is valid on the target in this context. + + :param context: Heat request context + :param action: String representing the action to be checked + :param object: Dictionary representing the object of the action. + :returns: A non-False value if access is allowed. + """ + return self._check(context, action, target) diff --git a/heat/tests/policy/deny_stack_user.json b/heat/tests/policy/deny_stack_user.json new file mode 100644 index 00000000..9ad32c9c --- /dev/null +++ b/heat/tests/policy/deny_stack_user.json @@ -0,0 +1,15 @@ +{ + "deny_stack_user": "not role:heat_stack_user", + "cloudformation:ListStacks": "rule:deny_stack_user", + "cloudformation:CreateStack": "rule:deny_stack_user", + "cloudformation:DescribeStacks": "rule:deny_stack_user", + "cloudformation:DeleteStack": "rule:deny_stack_user", + "cloudformation:UpdateStack": "rule:deny_stack_user", + "cloudformation:DescribeStackEvents": "rule:deny_stack_user", + "cloudformation:ValidateTemplate": "rule:deny_stack_user", + "cloudformation:GetTemplate": "rule:deny_stack_user", + "cloudformation:EstimateTemplateCost": "rule:deny_stack_user", + "cloudformation:DescribeStackResource": "", + "cloudformation:DescribeStackResources": "rule:deny_stack_user", + "cloudformation:ListStackResources": "rule:deny_stack_user" +} diff --git a/heat/tests/policy/notallowed.json b/heat/tests/policy/notallowed.json new file mode 100644 index 00000000..5346307e --- /dev/null +++ b/heat/tests/policy/notallowed.json @@ -0,0 +1,14 @@ +{ + "cloudformation:ListStacks": "!", + "cloudformation:CreateStack": "!", + "cloudformation:DescribeStacks": "!", + "cloudformation:DeleteStack": "!", + "cloudformation:UpdateStack": "!", + "cloudformation:DescribeStackEvents": "!", + "cloudformation:ValidateTemplate": "!", + "cloudformation:GetTemplate": "!", + "cloudformation:EstimateTemplateCost": "!", + "cloudformation:DescribeStackResource": "!", + "cloudformation:DescribeStackResources": "!", + "cloudformation:ListStackResources": "!" +} diff --git a/heat/tests/test_common_policy.py b/heat/tests/test_common_policy.py new file mode 100644 index 00000000..b34af14f --- /dev/null +++ b/heat/tests/test_common_policy.py @@ -0,0 +1,107 @@ +# Copyright 2012 OpenStack, LLC +# 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 mox +import json +import unittest +from nose.plugins.attrib import attr + +import os.path + +import heat.api +from heat.common import context +from heat.common import policy +from heat.common import exception +from heat.openstack.common import cfg + + +@attr(tag=['unit', 'common-policy', 'Enforcer']) +@attr(speed='fast') +class TestPolicyEnforcer(unittest.TestCase): + cfn_actions = ("ListStacks", "CreateStack", "DescribeStacks", + "DeleteStack", "UpdateStack", "DescribeStackEvents", + "ValidateTemplate", "GetTemplate", + "EstimateTemplateCost", "DescribeStackResource", + "DescribeStackResources") + + def setUp(self): + self.path = os.path.dirname(os.path.realpath(__file__)) + "/policy/" + self.m = mox.Mox() + opts = [ + cfg.StrOpt('config_dir', default=self.path), + cfg.StrOpt('config_file', default='foo'), + cfg.StrOpt('project', default='heat'), + ] + cfg.CONF.register_opts(opts) + print "setup complete" + + def tearDown(self): + self.m.UnsetStubs() + print "teardown complete" + + def test_policy_cfn_default(self): + enforcer = policy.Enforcer(scope='cloudformation') + + ctx = context.RequestContext(roles=[]) + for action in self.cfn_actions: + # Everything should be allowed + enforcer.enforce(ctx, action, {}) + + def test_policy_cfn_notallowed(self): + pf = self.path + 'notallowed.json' + self.m.StubOutWithMock(policy.Enforcer, '_find_policy_file') + policy.Enforcer._find_policy_file().MultipleTimes().AndReturn(pf) + self.m.ReplayAll() + + enforcer = policy.Enforcer(scope='cloudformation') + + ctx = context.RequestContext(roles=[]) + for action in self.cfn_actions: + # Everything should raise the default exception.Forbidden + self.assertRaises(exception.Forbidden, enforcer.enforce, ctx, + action, {}) + self.m.VerifyAll() + + def test_policy_cfn_deny_stack_user(self): + pf = self.path + 'deny_stack_user.json' + self.m.StubOutWithMock(policy.Enforcer, '_find_policy_file') + policy.Enforcer._find_policy_file().MultipleTimes().AndReturn(pf) + self.m.ReplayAll() + + enforcer = policy.Enforcer(scope='cloudformation') + + ctx = context.RequestContext(roles=['heat_stack_user']) + for action in self.cfn_actions: + # Everything apart from DescribeStackResource should be Forbidden + if action == "DescribeStackResource": + enforcer.enforce(ctx, action, {}) + else: + self.assertRaises(exception.Forbidden, enforcer.enforce, ctx, + action, {}) + self.m.VerifyAll() + + def test_policy_cfn_allow_non_stack_user(self): + pf = self.path + 'deny_stack_user.json' + self.m.StubOutWithMock(policy.Enforcer, '_find_policy_file') + policy.Enforcer._find_policy_file().MultipleTimes().AndReturn(pf) + self.m.ReplayAll() + + enforcer = policy.Enforcer(scope='cloudformation') + + ctx = context.RequestContext(roles=['not_a_stack_user']) + for action in self.cfn_actions: + # Everything should be allowed + enforcer.enforce(ctx, action, {}) + self.m.VerifyAll() -- 2.45.2