]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
Add initial code to support policy.json implementation
authorSteven Hardy <shardy@redhat.com>
Tue, 5 Feb 2013 19:21:00 +0000 (19:21 +0000)
committerSteven Hardy <shardy@redhat.com>
Tue, 5 Feb 2013 19:38:49 +0000 (19:38 +0000)
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 <shardy@redhat.com>
heat/common/policy.py [new file with mode: 0644]
heat/tests/policy/deny_stack_user.json [new file with mode: 0644]
heat/tests/policy/notallowed.json [new file with mode: 0644]
heat/tests/test_common_policy.py [new file with mode: 0644]

diff --git a/heat/common/policy.py b/heat/common/policy.py
new file mode 100644 (file)
index 0000000..34aa051
--- /dev/null
@@ -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 (file)
index 0000000..9ad32c9
--- /dev/null
@@ -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 (file)
index 0000000..5346307
--- /dev/null
@@ -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 (file)
index 0000000..b34af14
--- /dev/null
@@ -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()