From b3a970a5e4d0cf48752e5ad68341163368085c4f Mon Sep 17 00:00:00 2001 From: "Kevin L. Mitchell" Date: Thu, 14 Jun 2012 09:39:57 -0500 Subject: [PATCH] Add authZ through incorporation of policy checks. 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 | 19 +++ openstack-common.conf | 2 +- quantum/api/v2/base.py | 82 ++++++++-- quantum/common/exceptions.py | 8 + quantum/openstack/common/policy.py | 238 +++++++++++++++++++++++++++++ quantum/policy.py | 93 +++++++++++ 6 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 etc/policy.json create mode 100644 quantum/openstack/common/policy.py create mode 100644 quantum/policy.py diff --git a/etc/policy.json b/etc/policy.json new file mode 100644 index 0000000000..41a5cafbe5 --- /dev/null +++ b/etc/policy.json @@ -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"]] +} diff --git a/openstack-common.conf b/openstack-common.conf index 85566c365c..3c82ab1c65 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -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 diff --git a/quantum/api/v2/base.py b/quantum/api/v2/base.py index ffc10603a7..82ee90df47 100644 --- a/quantum/api/v2/base.py +++ b/quantum/api/v2/base.py @@ -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)} diff --git a/quantum/common/exceptions.py b/quantum/common/exceptions.py index bcad16e96e..b726b49e85 100644 --- a/quantum/common/exceptions.py +++ b/quantum/common/exceptions.py @@ -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 index 0000000000..203995a3d2 --- /dev/null +++ b/quantum/openstack/common/policy.py @@ -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 index 0000000000..cc656456f0 --- /dev/null +++ b/quantum/policy.py @@ -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)