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
This commit is contained in:
Kevin L. Mitchell 2012-06-14 09:39:57 -05:00
parent cd5061d40a
commit 78dd35dd8c
6 changed files with 427 additions and 15 deletions

19
etc/policy.json Normal file
View File

@ -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"]]
}

View File

@ -1,7 +1,7 @@
[DEFAULT] [DEFAULT]
# The list of modules to copy from openstack-common # 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 # The base module to hold the copy of openstack.common
base=quantum base=quantum

View File

@ -17,10 +17,11 @@ import logging
import webob.exc import webob.exc
from quantum.common import exceptions
from quantum.api.v2 import resource as wsgi_resource from quantum.api.v2 import resource as wsgi_resource
from quantum.common import utils
from quantum.api.v2 import views from quantum.api.v2 import views
from quantum.common import exceptions
from quantum.common import utils
from quantum import policy
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
XML_NS_V20 = 'http://openstack.org/quantum/api/v2.0' XML_NS_V20 = 'http://openstack.org/quantum/api/v2.0'
@ -100,7 +101,7 @@ class Controller(object):
self._attr_info = attr_info self._attr_info = attr_info
self._view = getattr(views, self._resource) 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""" """Retrieves and formats a list of elements of the requested entity"""
kwargs = {'filters': filters(request), kwargs = {'filters': filters(request),
'verbose': verbose(request), 'verbose': verbose(request),
@ -108,47 +109,100 @@ class Controller(object):
obj_getter = getattr(self._plugin, "get_%s" % self._collection) obj_getter = getattr(self._plugin, "get_%s" % self._collection)
obj_list = obj_getter(request.context, **kwargs) 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]} 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""" """Retrieves and formats a single element of the requested entity"""
kwargs = {'verbose': verbose(request), kwargs = {'verbose': verbose(request),
'fields': fields(request)} 'fields': fields(request)}
obj_getter = getattr(self._plugin, action = "get_%s" % self._resource
"get_%s" % self._resource) obj_getter = getattr(self._plugin, action)
obj = obj_getter(request.context, id, **kwargs) obj = obj_getter(request.context, id, **kwargs)
# Check authz
if do_authz:
policy.enforce(request.context, action, obj)
return {self._resource: self._view(obj)} return {self._resource: self._view(obj)}
def index(self, request): def index(self, request):
"""Returns a list of the requested entity""" """Returns a list of the requested entity"""
return self._items(request) return self._items(request, True)
def show(self, request, id): def show(self, request, id):
"""Returns detailed information about the requested entity""" """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): def create(self, request, body=None):
"""Creates a new instance of the requested entity""" """Creates a new instance of the requested entity"""
body = self._prepare_request_body(request.context, body, True, body = self._prepare_request_body(request.context, body, True,
allow_bulk=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} kwargs = {self._resource: body}
obj = obj_creator(request.context, **kwargs) obj = obj_creator(request.context, **kwargs)
return {self._resource: self._view(obj)} return {self._resource: self._view(obj)}
def delete(self, request, id): def delete(self, request, id):
"""Deletes the specified entity""" """Deletes the specified entity"""
obj_deleter = getattr(self._plugin, action = "delete_%s" % self._resource
"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) obj_deleter(request.context, id)
def update(self, request, id, body=None): def update(self, request, id, body=None):
"""Updates the specified entity's attributes""" """Updates the specified entity's attributes"""
body = self._prepare_request_body(request.context, body, False) body = self._prepare_request_body(request.context, body, False)
obj_updater = getattr(self._plugin, action = "update_%s" % self._resource
"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} kwargs = {self._resource: body}
obj = obj_updater(request.context, id, **kwargs) obj = obj_updater(request.context, id, **kwargs)
return {self._resource: self._view(obj)} return {self._resource: self._view(obj)}

View File

@ -46,6 +46,10 @@ class AdminRequired(NotAuthorized):
message = _("User does not have admin privileges: %(reason)s") 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): class ClassNotFound(NotFound):
message = _("Class %(class_name)s could not be found") message = _("Class %(class_name)s could not be found")
@ -63,6 +67,10 @@ class PortNotFound(NotFound):
"on network %(net_id)s") "on network %(net_id)s")
class PolicyNotFound(NotFound):
message = _("Policy configuration policy.json could not be found")
class StateInvalid(QuantumException): class StateInvalid(QuantumException):
message = _("Unsupported port state: %(port_state)s") message = _("Unsupported port state: %(port_state)s")

View File

@ -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"

93
quantum/policy.py Normal file
View File

@ -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)