Adds simple policy engine support
* First step of blueprint interim-nova-authz-service * Common policy engine that can be used by other projects * Nova specific policy engine that uses common policy * Policy file can be generated by external tool (dashboard) * Ultimately, policies will be requested from a service Change-Id: Icaf934037e790bde6ce2346746ce38b11134423b
This commit is contained in:
parent
c27e1ccd3f
commit
01e6f7575a
|
@ -0,0 +1,202 @@
|
|||
# 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 urllib
|
||||
import urllib2
|
||||
|
||||
|
||||
class NotAllowed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
_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):
|
||||
"""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
|
||||
('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.
|
||||
|
||||
:raises NotAllowed if the check fails
|
||||
|
||||
"""
|
||||
global _BRAIN
|
||||
if not _BRAIN:
|
||||
_BRAIN = Brain()
|
||||
if not _BRAIN.check(match_list, target_dict, credentials_dict):
|
||||
raise NotAllowed()
|
||||
|
||||
|
||||
class Brain(object):
|
||||
"""Implements policy checking."""
|
||||
@classmethod
|
||||
def load_json(cls, data):
|
||||
"""Init a brain using json instead of a rules dictionary."""
|
||||
rules_dict = json.loads(data)
|
||||
return cls(rules=rules_dict)
|
||||
|
||||
def __init__(self, rules=None):
|
||||
self.rules = rules or {}
|
||||
|
||||
def add_rule(self, key, match):
|
||||
self.rules[key] = match
|
||||
|
||||
def _check(self, match, target_dict, cred_dict):
|
||||
match_kind, match_value = match.split(':', 1)
|
||||
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:
|
||||
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 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"
|
|
@ -207,6 +207,10 @@ class AdminRequired(NotAuthorized):
|
|||
message = _("User does not have admin privileges")
|
||||
|
||||
|
||||
class PolicyNotAllowed(NotAuthorized):
|
||||
message = _("Policy Doesn't allow %(action)s to be performed.")
|
||||
|
||||
|
||||
class InstanceBusy(NovaException):
|
||||
message = _("Instance %(instance_id)s is busy. (%(task_state)s)")
|
||||
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
# 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.
|
||||
|
||||
"""Policy Engine For Nova"""
|
||||
|
||||
from nova import exception
|
||||
from nova import flags
|
||||
from nova import utils
|
||||
from nova.common import policy
|
||||
|
||||
FLAGS = flags.FLAGS
|
||||
flags.DEFINE_string('policy_file', 'policy.json',
|
||||
_('JSON file representing policy'))
|
||||
|
||||
_POLICY_PATH = None
|
||||
_POLICY_CACHE = {}
|
||||
|
||||
|
||||
def reset():
|
||||
global _POLICY_PATH
|
||||
global _POLICY_CACHE
|
||||
_POLICY_PATH = None
|
||||
_POLICY_CACHE = {}
|
||||
policy.reset()
|
||||
|
||||
|
||||
def init():
|
||||
global _POLICY_PATH
|
||||
global _POLICY_CACHE
|
||||
if not _POLICY_PATH:
|
||||
_POLICY_PATH = utils.find_config(FLAGS.policy_file)
|
||||
data = utils.read_cached_file(_POLICY_PATH, _POLICY_CACHE,
|
||||
reload_func=_set_brain)
|
||||
|
||||
|
||||
def _set_brain(data):
|
||||
policy.set_brain(policy.HttpBrain.load_json(data))
|
||||
|
||||
|
||||
def enforce(context, action, target):
|
||||
"""Verifies that the action is valid on the target in this context.
|
||||
|
||||
:param context: nova context
|
||||
:param action: string representing the action to be checked
|
||||
this should be colon separated for clarity.
|
||||
i.e. compute:create_instance
|
||||
compute:attach_volume
|
||||
volume:attach_volume
|
||||
|
||||
: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: `nova.exception.PolicyNotAllowed` if verification fails.
|
||||
|
||||
"""
|
||||
init()
|
||||
match_list = ('rule:%s' % action,)
|
||||
target_dict = target
|
||||
credentials_dict = context.to_dict()
|
||||
try:
|
||||
policy.enforce(match_list, target_dict, credentials_dict)
|
||||
except policy.NotAllowed:
|
||||
raise exception.PolicyNotAllowed(action=action)
|
|
@ -41,3 +41,5 @@ FLAGS['sqlite_db'].SetDefault("tests.sqlite")
|
|||
FLAGS['use_ipv6'].SetDefault(True)
|
||||
FLAGS['flat_network_bridge'].SetDefault('br100')
|
||||
FLAGS['sqlite_synchronous'].SetDefault(False)
|
||||
flags.DECLARE('policy_file', 'nova.policy')
|
||||
FLAGS['policy_file'].SetDefault('nova/tests/policy.json')
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"true" : [],
|
||||
"compute:create_instance" : [],
|
||||
"compute:attach_network" : [],
|
||||
"compute:attach_volume" : [],
|
||||
"compute:list_instances": [],
|
||||
"compute:get_instance": [],
|
||||
"network:attach_network" : [],
|
||||
"volume:create_volume": [],
|
||||
"volume:attach_volume": []
|
||||
}
|
|
@ -0,0 +1,139 @@
|
|||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2011 Piston Cloud Computing, Inc.
|
||||
# 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.
|
||||
|
||||
"""Test of Policy Engine For Nova"""
|
||||
|
||||
import StringIO
|
||||
import tempfile
|
||||
import urllib2
|
||||
|
||||
from nova.common import policy as common_policy
|
||||
from nova import context
|
||||
from nova import exception
|
||||
from nova import flags
|
||||
from nova import policy
|
||||
from nova import test
|
||||
|
||||
FLAGS = flags.FLAGS
|
||||
|
||||
|
||||
class PolicyFileTestCase(test.TestCase):
|
||||
def setUp(self):
|
||||
super(PolicyFileTestCase, self).setUp()
|
||||
policy.reset()
|
||||
_, self.tmpfilename = tempfile.mkstemp()
|
||||
self.flags(policy_file=self.tmpfilename)
|
||||
self.context = context.RequestContext('fake', 'fake')
|
||||
self.target = {}
|
||||
|
||||
def tearDown(self):
|
||||
super(PolicyFileTestCase, self).tearDown()
|
||||
policy.reset()
|
||||
|
||||
def test_modified_policy_reloads(self):
|
||||
action = "example:test"
|
||||
with open(self.tmpfilename, "w") as policyfile:
|
||||
policyfile.write("""{"example:test": []}""")
|
||||
policy.enforce(self.context, action, self.target)
|
||||
with open(self.tmpfilename, "w") as policyfile:
|
||||
policyfile.write("""{"example:test": ["false:false"]}""")
|
||||
# NOTE(vish): reset stored policy cache so we don't have to sleep(1)
|
||||
policy._POLICY_CACHE = {}
|
||||
self.assertRaises(exception.PolicyNotAllowed, policy.enforce,
|
||||
self.context, action, self.target)
|
||||
|
||||
|
||||
class PolicyTestCase(test.TestCase):
|
||||
def setUp(self):
|
||||
super(PolicyTestCase, self).setUp()
|
||||
policy.reset()
|
||||
# NOTE(vish): preload rules to circumvent reloading from file
|
||||
policy.init()
|
||||
rules = {
|
||||
"true": [],
|
||||
"example:allowed": [],
|
||||
"example:denied": [["false:false"]],
|
||||
"example:get_http": [["http:http://www.example.com"]],
|
||||
"example:my_file": [["role:compute_admin"],
|
||||
["project_id:%(project_id)s"]],
|
||||
"example:early_and_fail": [["false:false", "rule:true"]],
|
||||
"example:early_or_success": [["rule:true"], ["false:false"]],
|
||||
"example:sysadmin_allowed": [["role:admin"], ["role:sysadmin"]],
|
||||
}
|
||||
# NOTE(vish): then overload underlying brain
|
||||
common_policy.set_brain(common_policy.HttpBrain(rules))
|
||||
self.context = context.RequestContext('fake', 'fake', roles=['member'])
|
||||
self.admin_context = context.RequestContext('admin',
|
||||
'fake',
|
||||
roles=['admin'],
|
||||
is_admin=True)
|
||||
self.target = {}
|
||||
|
||||
def tearDown(self):
|
||||
policy.reset()
|
||||
super(PolicyTestCase, self).tearDown()
|
||||
|
||||
def test_enforce_nonexistent_action_throws(self):
|
||||
action = "example:noexist"
|
||||
self.assertRaises(exception.PolicyNotAllowed, policy.enforce,
|
||||
self.context, action, self.target)
|
||||
|
||||
def test_enforce_bad_action_throws(self):
|
||||
action = "example:denied"
|
||||
self.assertRaises(exception.PolicyNotAllowed, policy.enforce,
|
||||
self.context, action, self.target)
|
||||
|
||||
def test_enforce_good_action(self):
|
||||
action = "example:allowed"
|
||||
policy.enforce(self.context, action, self.target)
|
||||
|
||||
def test_enforce_http_true(self):
|
||||
|
||||
def fakeurlopen(url, post_data):
|
||||
return StringIO.StringIO("True")
|
||||
self.stubs.Set(urllib2, 'urlopen', fakeurlopen)
|
||||
action = "example:get_http"
|
||||
target = {}
|
||||
result = policy.enforce(self.context, action, target)
|
||||
self.assertEqual(result, None)
|
||||
|
||||
def test_enforce_http_false(self):
|
||||
|
||||
def fakeurlopen(url, post_data):
|
||||
return StringIO.StringIO("False")
|
||||
self.stubs.Set(urllib2, 'urlopen', fakeurlopen)
|
||||
action = "example:get_http"
|
||||
target = {}
|
||||
self.assertRaises(exception.PolicyNotAllowed, policy.enforce,
|
||||
self.context, action, target)
|
||||
|
||||
def test_templatized_enforcement(self):
|
||||
target_mine = {'project_id': 'fake'}
|
||||
target_not_mine = {'project_id': 'another'}
|
||||
action = "example:my_file"
|
||||
policy.enforce(self.context, action, target_mine)
|
||||
self.assertRaises(exception.PolicyNotAllowed, policy.enforce,
|
||||
self.context, action, target_not_mine)
|
||||
|
||||
def test_early_AND_enforcement(self):
|
||||
action = "example:early_and_fail"
|
||||
self.assertRaises(exception.PolicyNotAllowed, policy.enforce,
|
||||
self.context, action, self.target)
|
||||
|
||||
def test_early_OR_enforcement(self):
|
||||
action = "example:early_or_success"
|
||||
policy.enforce(self.context, action, self.target)
|
Loading…
Reference in New Issue