]> review.fuel-infra Code Review - openstack-build/neutron-build.git/commitdiff
Add authZ through incorporation of policy checks.
authorKevin L. Mitchell <kevin.mitchell@rackspace.com>
Thu, 14 Jun 2012 14:39:57 +0000 (09:39 -0500)
committerKevin L. Mitchell <kevin.mitchell@rackspace.com>
Mon, 18 Jun 2012 19:21:57 +0000 (14:21 -0500)
Adds the policy openstack-common module and implements policy checks
for the v2 API.  Note that this cut only addresses whole objects (i.e.,
a subnet or a network or a port), not specific fields within objects.
(This means that attributes are not filtered out based on policies.)
Implements blueprint authorization-support-for-quantum.

Change-Id: I1b52b1791a1f14f0af6508a63a40a38e440f15fe

etc/policy.json [new file with mode: 0644]
openstack-common.conf
quantum/api/v2/base.py
quantum/common/exceptions.py
quantum/openstack/common/policy.py [new file with mode: 0644]
quantum/policy.py [new file with mode: 0644]

diff --git a/etc/policy.json b/etc/policy.json
new file mode 100644 (file)
index 0000000..41a5caf
--- /dev/null
@@ -0,0 +1,19 @@
+{
+    "admin_or_owner": [["role:admin"], ["tenant_id:%(tenant_id)s"]],
+    "default": [["rule:admin_or_owner"]],
+
+    "create_subnet": [],
+    "get_subnet": [["rule:admin_or_owner"]],
+    "update_subnet": [["rule:admin_or_owner"]],
+    "delete_subnet": [["rule:admin_or_owner"]],
+
+    "create_network": [],
+    "get_network": [["rule:admin_or_owner"]],
+    "update_network": [["rule:admin_or_owner"]],
+    "delete_network": [["rule:admin_or_owner"]],
+
+    "create_port": [],
+    "get_port": [["rule:admin_or_owner"]],
+    "update_port": [["rule:admin_or_owner"]],
+    "delete_port": [["rule:admin_or_owner"]]
+}
index 85566c365cdc11d67c930f15c9644e5e27b7e06d..3c82ab1c65ee8c3cfbf6456e00155db9891b6130 100644 (file)
@@ -1,7 +1,7 @@
 [DEFAULT]
 
 # The list of modules to copy from openstack-common
-modules=cfg,exception,importutils,iniparser,jsonutils,setup
+modules=cfg,exception,importutils,iniparser,jsonutils,policy,setup
 
 # The base module to hold the copy of openstack.common
 base=quantum
index ffc10603a79581112418b6cc562ce341a0465200..82ee90df47aa1c51142cea3f37e1ef812e77f131 100644 (file)
@@ -17,10 +17,11 @@ import logging
 
 import webob.exc
 
-from quantum.common import exceptions
 from quantum.api.v2 import resource as wsgi_resource
-from quantum.common import utils
 from quantum.api.v2 import views
+from quantum.common import exceptions
+from quantum.common import utils
+from quantum import policy
 
 LOG = logging.getLogger(__name__)
 XML_NS_V20 = 'http://openstack.org/quantum/api/v2.0'
@@ -100,7 +101,7 @@ class Controller(object):
         self._attr_info = attr_info
         self._view = getattr(views, self._resource)
 
-    def _items(self, request):
+    def _items(self, request, do_authz=False):
         """Retrieves and formats a list of elements of the requested entity"""
         kwargs = {'filters': filters(request),
                   'verbose': verbose(request),
@@ -108,47 +109,100 @@ class Controller(object):
 
         obj_getter = getattr(self._plugin, "get_%s" % self._collection)
         obj_list = obj_getter(request.context, **kwargs)
+
+        # Check authz
+        if do_authz:
+            # Omit items from list that should not be visible
+            obj_list = [obj for obj in obj_list
+                        if policy.check(request.context,
+                                        "get_%s" % self._resource,
+                                        obj)]
+
         return {self._collection: [self._view(obj) for obj in obj_list]}
 
-    def _item(self, request, id):
+    def _item(self, request, id, do_authz=False):
         """Retrieves and formats a single element of the requested entity"""
         kwargs = {'verbose': verbose(request),
                   'fields': fields(request)}
-        obj_getter = getattr(self._plugin,
-                             "get_%s" % self._resource)
+        action = "get_%s" % self._resource
+        obj_getter = getattr(self._plugin, action)
         obj = obj_getter(request.context, id, **kwargs)
+
+        # Check authz
+        if do_authz:
+            policy.enforce(request.context, action, obj)
+
         return {self._resource: self._view(obj)}
 
     def index(self, request):
         """Returns a list of the requested entity"""
-        return self._items(request)
+        return self._items(request, True)
 
     def show(self, request, id):
         """Returns detailed information about the requested entity"""
-        return self._item(request, id)
+        try:
+            return self._item(request, id, True)
+        except exceptions.PolicyNotAuthorized:
+            # To avoid giving away information, pretend that it
+            # doesn't exist
+            raise webob.exc.HTTPNotFound()
 
     def create(self, request, body=None):
         """Creates a new instance of the requested entity"""
 
         body = self._prepare_request_body(request.context, body, True,
                                           allow_bulk=True)
-        obj_creator = getattr(self._plugin,
-                              "create_%s" % self._resource)
+
+        action = "create_%s" % self._resource
+
+        # Check authz
+        try:
+            if self._collection in body:
+                # Have to account for bulk create
+                for item in body[self._collection]:
+                    policy.enforce(request.context, action,
+                                   item[self._resource])
+            else:
+                policy.enforce(request.context, action, body[self._resource])
+        except exceptions.PolicyNotAuthorized:
+            raise webob.exc.HTTPForbidden()
+
+        obj_creator = getattr(self._plugin, action)
         kwargs = {self._resource: body}
         obj = obj_creator(request.context, **kwargs)
         return {self._resource: self._view(obj)}
 
     def delete(self, request, id):
         """Deletes the specified entity"""
-        obj_deleter = getattr(self._plugin,
-                              "delete_%s" % self._resource)
+        action = "delete_%s" % self._resource
+
+        # Check authz
+        obj = self._item(request, id)
+        try:
+            policy.enforce(request.context, action, obj)
+        except exceptions.PolicyNotAuthorized:
+            # To avoid giving away information, pretend that it
+            # doesn't exist
+            raise webob.exc.HTTPNotFound()
+
+        obj_deleter = getattr(self._plugin, action)
         obj_deleter(request.context, id)
 
     def update(self, request, id, body=None):
         """Updates the specified entity's attributes"""
         body = self._prepare_request_body(request.context, body, False)
-        obj_updater = getattr(self._plugin,
-                              "update_%s" % self._resource)
+        action = "update_%s" % self._resource
+
+        # Check authz
+        orig_obj = self._item(request, id)
+        try:
+            policy.enforce(request.context, action, orig_obj)
+        except exceptions.PolicyNotAuthorized:
+            # To avoid giving away information, pretend that it
+            # doesn't exist
+            raise webob.exc.HTTPNotFound()
+
+        obj_updater = getattr(self._plugin, action)
         kwargs = {self._resource: body}
         obj = obj_updater(request.context, id, **kwargs)
         return {self._resource: self._view(obj)}
index bcad16e96e88cc7c3af6605e11388dfb3bf18eb3..b726b49e85009927457327c7295daa0f1da99f53 100644 (file)
@@ -46,6 +46,10 @@ class AdminRequired(NotAuthorized):
     message = _("User does not have admin privileges: %(reason)s")
 
 
+class PolicyNotAuthorized(NotAuthorized):
+    message = _("Policy doesn't allow %(action)s to be performed.")
+
+
 class ClassNotFound(NotFound):
     message = _("Class %(class_name)s could not be found")
 
@@ -63,6 +67,10 @@ class PortNotFound(NotFound):
                 "on network %(net_id)s")
 
 
+class PolicyNotFound(NotFound):
+    message = _("Policy configuration policy.json could not be found")
+
+
 class StateInvalid(QuantumException):
     message = _("Unsupported port state: %(port_state)s")
 
diff --git a/quantum/openstack/common/policy.py b/quantum/openstack/common/policy.py
new file mode 100644 (file)
index 0000000..203995a
--- /dev/null
@@ -0,0 +1,238 @@
+# 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.
+
+"""Common Policy Engine Implementation"""
+
+import json
+import logging
+import urllib
+import urllib2
+
+
+LOG = logging.getLogger(__name__)
+
+
+_BRAIN = None
+
+
+def set_brain(brain):
+    """Set the brain used by enforce().
+
+    Defaults use Brain() if not set.
+
+    """
+    global _BRAIN
+    _BRAIN = brain
+
+
+def reset():
+    """Clear the brain used by enforce()."""
+    global _BRAIN
+    _BRAIN = None
+
+
+def enforce(match_list, target_dict, credentials_dict, exc=None,
+            *args, **kwargs):
+    """Enforces authorization of some rules against credentials.
+
+    :param match_list: nested tuples of data to match against
+
+        The basic brain supports three types of match lists:
+
+            1) rules
+
+                looks like: ``('rule:compute:get_instance',)``
+
+                Retrieves the named rule from the rules dict and recursively
+                checks against the contents of the rule.
+
+            2) roles
+
+                looks like: ``('role:compute:admin',)``
+
+                Matches if the specified role is in credentials_dict['roles'].
+
+            3) generic
+
+                looks like: ``('tenant_id:%(tenant_id)s',)``
+
+                Substitutes values from the target dict into the match using
+                the % operator and matches them against the creds dict.
+
+        Combining rules:
+
+            The brain returns True if any of the outer tuple of rules
+            match and also True if all of the inner tuples match. You
+            can use this to perform simple boolean logic.  For
+            example, the following rule would return True if the creds
+            contain the role 'admin' OR the if the tenant_id matches
+            the target dict AND the the creds contains the role
+            'compute_sysadmin':
+
+            ::
+
+                {
+                    "rule:combined": (
+                        'role:admin',
+                        ('tenant_id:%(tenant_id)s', 'role:compute_sysadmin')
+                    )
+                }
+
+        Note that rule and role are reserved words in the credentials match, so
+        you can't match against properties with those names. Custom brains may
+        also add new reserved words. For example, the HttpBrain adds http as a
+        reserved word.
+
+    :param target_dict: dict of object properties
+
+      Target dicts contain as much information as we can about the object being
+      operated on.
+
+    :param credentials_dict: dict of actor properties
+
+      Credentials dicts contain as much information as we can about the user
+      performing the action.
+
+    :param exc: exception to raise
+
+      Class of the exception to raise if the check fails.  Any remaining
+      arguments passed to enforce() (both positional and keyword arguments)
+      will be passed to the exception class.  If exc is not provided, returns
+      False.
+
+    :return: True if the policy allows the action
+    :return: False if the policy does not allow the action and exc is not set
+    """
+    global _BRAIN
+    if not _BRAIN:
+        _BRAIN = Brain()
+    if not _BRAIN.check(match_list, target_dict, credentials_dict):
+        if exc:
+            raise exc(*args, **kwargs)
+        return False
+    return True
+
+
+class Brain(object):
+    """Implements policy checking."""
+    @classmethod
+    def load_json(cls, data, default_rule=None):
+        """Init a brain using json instead of a rules dictionary."""
+        rules_dict = json.loads(data)
+        return cls(rules=rules_dict, default_rule=default_rule)
+
+    def __init__(self, rules=None, default_rule=None):
+        self.rules = rules or {}
+        self.default_rule = default_rule
+
+    def add_rule(self, key, match):
+        self.rules[key] = match
+
+    def _check(self, match, target_dict, cred_dict):
+        try:
+            match_kind, match_value = match.split(':', 1)
+        except Exception:
+            LOG.exception(_("Failed to understand rule %(match)r") % locals())
+            # If the rule is invalid, fail closed
+            return False
+        try:
+            f = getattr(self, '_check_%s' % match_kind)
+        except AttributeError:
+            if not self._check_generic(match, target_dict, cred_dict):
+                return False
+        else:
+            if not f(match_value, target_dict, cred_dict):
+                return False
+        return True
+
+    def check(self, match_list, target_dict, cred_dict):
+        """Checks authorization of some rules against credentials.
+
+        Detailed description of the check with examples in policy.enforce().
+
+        :param match_list: nested tuples of data to match against
+        :param target_dict: dict of object properties
+        :param credentials_dict: dict of actor properties
+
+        :returns: True if the check passes
+
+        """
+        if not match_list:
+            return True
+        for and_list in match_list:
+            if isinstance(and_list, basestring):
+                and_list = (and_list,)
+            if all([self._check(item, target_dict, cred_dict)
+                    for item in and_list]):
+                return True
+        return False
+
+    def _check_rule(self, match, target_dict, cred_dict):
+        """Recursively checks credentials based on the brains rules."""
+        try:
+            new_match_list = self.rules[match]
+        except KeyError:
+            if self.default_rule and match != self.default_rule:
+                new_match_list = ('rule:%s' % self.default_rule,)
+            else:
+                return False
+
+        return self.check(new_match_list, target_dict, cred_dict)
+
+    def _check_role(self, match, target_dict, cred_dict):
+        """Check that there is a matching role in the cred dict."""
+        return match.lower() in [x.lower() for x in cred_dict['roles']]
+
+    def _check_generic(self, match, target_dict, cred_dict):
+        """Check an individual match.
+
+        Matches look like:
+
+            tenant:%(tenant_id)s
+            role:compute:admin
+
+        """
+
+        # TODO(termie): do dict inspection via dot syntax
+        match = match % target_dict
+        key, value = match.split(':', 1)
+        if key in cred_dict:
+            return value == cred_dict[key]
+        return False
+
+
+class HttpBrain(Brain):
+    """A brain that can check external urls for policy.
+
+    Posts json blobs for target and credentials.
+
+    """
+
+    def _check_http(self, match, target_dict, cred_dict):
+        """Check http: rules by calling to a remote server.
+
+        This example implementation simply verifies that the response is
+        exactly 'True'. A custom brain using response codes could easily
+        be implemented.
+
+        """
+        url = match % target_dict
+        data = {'target': json.dumps(target_dict),
+                'credentials': json.dumps(cred_dict)}
+        post_data = urllib.urlencode(data)
+        f = urllib2.urlopen(url, post_data)
+        return f.read() == "True"
diff --git a/quantum/policy.py b/quantum/policy.py
new file mode 100644 (file)
index 0000000..cc65645
--- /dev/null
@@ -0,0 +1,93 @@
+# vim: tabstop=4 shiftwidth=4 softtabstop=4
+
+# Copyright (c) 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.
+
+"""
+Policy engine for quantum.  Largely copied from nova.
+"""
+
+import os.path
+
+from quantum.common import config
+from quantum.common import exceptions
+from quantum.openstack.common import policy
+
+
+_POLICY_PATH = None
+
+
+def reset():
+    global _POLICY_PATH
+    _POLICY_PATH = None
+    policy.reset()
+
+
+def init():
+    global _POLICY_PATH
+    if not _POLICY_PATH:
+        _POLICY_PATH = config.find_config_file({}, [], 'policy.json')
+        if not _POLICY_PATH:
+            raise exceptions.PolicyNotFound(path=FLAGS.policy_file)
+    with open(_POLICY_PATH) as f:
+        _set_brain(f.read())
+
+
+def _set_brain(data):
+    default_rule = 'default'
+    policy.set_brain(policy.HttpBrain.load_json(data, default_rule))
+
+
+def check(context, action, target):
+    """Verifies that the action is valid on the target in this context.
+
+    :param context: quantum context
+    :param action: string representing the action to be checked
+        this should be colon separated for clarity.
+    :param object: 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}``
+
+    :return: Returns True if access is permitted else False.
+    """
+
+    init()
+
+    match_list = ('rule:%s' % action,)
+    credentials = context.to_dict()
+
+    return policy.enforce(match_list, target, credentials)
+
+
+def enforce(context, action, target):
+    """Verifies that the action is valid on the target in this context.
+
+    :param context: quantum context
+    :param action: string representing the action to be checked
+        this should be colon separated for clarity.
+    :param object: 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}``
+
+    :raises quantum.exceptions.PolicyNotAllowed: if verification fails.
+    """
+
+    init()
+
+    match_list = ('rule:%s' % action,)
+    credentials = context.to_dict()
+
+    policy.enforce(match_list, target, credentials,
+                   exceptions.PolicyNotAuthorized, action=action)