diff options
| -rw-r--r-- | etc/keystone.conf | 2 | ||||
| -rw-r--r-- | etc/policy.json | 3 | ||||
| -rw-r--r-- | keystone/common/policy.py | 207 | ||||
| -rw-r--r-- | keystone/common/utils.py | 43 | ||||
| -rw-r--r-- | keystone/common/wsgi.py | 7 | ||||
| -rw-r--r-- | keystone/policy/backends/rules.py | 104 | ||||
| -rw-r--r-- | keystone/policy/backends/simple.py | 22 | ||||
| -rw-r--r-- | keystone/policy/core.py | 10 | ||||
| -rw-r--r-- | keystone/test.py | 22 | ||||
| -rw-r--r-- | tests/policy.json | 3 | ||||
| -rw-r--r-- | tests/test_policy.py | 180 |
11 files changed, 575 insertions, 28 deletions
diff --git a/etc/keystone.conf b/etc/keystone.conf index 8665e620..4e207619 100644 --- a/etc/keystone.conf +++ b/etc/keystone.conf @@ -47,7 +47,7 @@ driver = keystone.token.backends.kvs.Token expiration = 86400 [policy] -driver = keystone.policy.backends.simple.SimpleMatch +driver = keystone.policy.backends.rules.Policy [ec2] driver = keystone.contrib.ec2.backends.kvs.Ec2 diff --git a/etc/policy.json b/etc/policy.json new file mode 100644 index 00000000..4a1482f8 --- /dev/null +++ b/etc/policy.json @@ -0,0 +1,3 @@ +{ + "admin_required": [["role:admin"], ["is_admin:1"]] +} diff --git a/keystone/common/policy.py b/keystone/common/policy.py new file mode 100644 index 00000000..34492f73 --- /dev/null +++ b/keystone/common/policy.py @@ -0,0 +1,207 @@ +# 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 NotAuthorized(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 NotAuthorized if the check fails + + """ + global _BRAIN + if not _BRAIN: + _BRAIN = Brain() + if not _BRAIN.check(match_list, target_dict, credentials_dict): + raise NotAuthorized() + + +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): + 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: + 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/keystone/common/utils.py b/keystone/common/utils.py index b7d4b9c0..74b490ee 100644 --- a/keystone/common/utils.py +++ b/keystone/common/utils.py @@ -22,6 +22,7 @@ import base64 import hashlib import hmac import json +import os import subprocess import sys import time @@ -61,6 +62,48 @@ def import_object(import_str, *args, **kw): return cls(*args, **kw) +def find_config(config_path): + """Find a configuration file using the given hint. + + :param config_path: Full or relative path to the config. + :returns: Full path of the config, if it exists. + + """ + possible_locations = [ + config_path, + os.path.join('etc', 'keystone', config_path), + os.path.join('etc', config_path), + os.path.join(config_path), + '/etc/keystone/%s' % config_path, + ] + + for path in possible_locations: + if os.path.exists(path): + return os.path.abspath(path) + + raise Exception('Config not found: %s', os.path.abspath(config_path)) + + +def read_cached_file(filename, cache_info, reload_func=None): + """Read from a file if it has been modified. + + :param cache_info: dictionary to hold opaque cache. + :param reload_func: optional function to be called with data when + file is reloaded due to a modification. + + :returns: data from file + + """ + mtime = os.path.getmtime(filename) + if not cache_info or mtime != cache_info.get('mtime'): + with open(filename) as fap: + cache_info['data'] = fap.read() + cache_info['mtime'] = mtime + if reload_func: + reload_func(cache_info['data']) + return cache_info['data'] + + class SmarterEncoder(json.JSONEncoder): """Help for JSON encoding dict-like objects.""" def default(self, obj): diff --git a/keystone/common/wsgi.py b/keystone/common/wsgi.py index f8a7e2f1..73cc3ad9 100644 --- a/keystone/common/wsgi.py +++ b/keystone/common/wsgi.py @@ -214,9 +214,10 @@ class Application(BaseApplication): creds['roles'] = [self.identity_api.get_role(context, role)['name'] for role in creds.get('roles', [])] # Accept either is_admin or the admin role - assert self.policy_api.can_haz(context, - ('is_admin:1', 'roles:admin'), - creds) + self.policy_api.enforce(context, + creds, + 'admin_required', + {}) class Middleware(Application): diff --git a/keystone/policy/backends/rules.py b/keystone/policy/backends/rules.py new file mode 100644 index 00000000..1d12a999 --- /dev/null +++ b/keystone/policy/backends/rules.py @@ -0,0 +1,104 @@ +# 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. + +"""Rules-based Policy Engine.""" + +from keystone import config +from keystone import exception +from keystone import policy +from keystone.common import logging +from keystone.common import policy as common_policy +from keystone.common import utils +from keystone.openstack.common import cfg + + +policy_opts = [ + cfg.StrOpt('policy_file', + default='policy.json', + help=_('JSON file representing policy')), + cfg.StrOpt('policy_default_rule', + default='default', + help=_('Rule checked when requested rule is not found')), + ] + + +CONF = config.CONF +CONF.register_opts(policy_opts) + + +LOG = logging.getLogger('keystone.policy.backends.rules') + + +_POLICY_PATH = None +_POLICY_CACHE = {} + + +def reset(): + global _POLICY_PATH + global _POLICY_CACHE + _POLICY_PATH = None + _POLICY_CACHE = {} + common_policy.reset() + + +def init(): + global _POLICY_PATH + global _POLICY_CACHE + if not _POLICY_PATH: + _POLICY_PATH = utils.find_config(CONF.policy_file) + utils.read_cached_file(_POLICY_PATH, + _POLICY_CACHE, + reload_func=_set_brain) + + +def _set_brain(data): + default_rule = CONF.policy_default_rule + common_policy.set_brain( + common_policy.HttpBrain.load_json(data, default_rule)) + + +def enforce(credentials, action, target): + """Verifies that the action is valid on the target in this context. + + :param credentials: user credentials + :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. {'tenant_id': object.tenant_id} + + :raises: `exception.Forbidden` if verification fails. + + """ + init() + + match_list = ('rule:%s' % action,) + + try: + common_policy.enforce(match_list, target, credentials) + except common_policy.NotAuthorized: + raise exception.Forbidden(action=action) + + +class Policy(policy.Driver): + def enforce(self, credentials, action, target): + LOG.debug('enforce %s: %s', action, credentials) + enforce(credentials, action, target) diff --git a/keystone/policy/backends/simple.py b/keystone/policy/backends/simple.py index ed357425..9d490f6c 100644 --- a/keystone/policy/backends/simple.py +++ b/keystone/policy/backends/simple.py @@ -14,24 +14,10 @@ # License for the specific language governing permissions and limitations # under the License. +# This file exists as a shim to get devstack testing to pass. +# It will be removed once devstack has been updated. -from keystone.common import logging +from keystone.policy.backends import rules -class TrivialTrue(object): - def can_haz(self, target, credentials): - return True - - -class SimpleMatch(object): - def can_haz(self, target, credentials): - """Check whether key-values in target are present in credentials.""" - # TODO(termie): handle ANDs, probably by providing a tuple instead of a - # string - for requirement in target: - key, match = requirement.split(':', 1) - check = credentials.get(key) - if check is None or isinstance(check, basestring): - check = [check] - if match in check: - return True +SimpleMatch = rules.Policy diff --git a/keystone/policy/core.py b/keystone/policy/core.py index fea1ef81..a89c6083 100644 --- a/keystone/policy/core.py +++ b/keystone/policy/core.py @@ -33,3 +33,13 @@ class Manager(manager.Manager): def __init__(self): super(Manager, self).__init__(CONF.policy.driver) + + +class Driver(object): + def enforce(context, credentials, action, target): + """Verify that a user is authorized to perform action. + + For more information on a full implementation of this see: + `keystone.common.policy.enforce`. + """ + raise NotImplementedError() diff --git a/keystone/test.py b/keystone/test.py index 556b6a25..29d41711 100644 --- a/keystone/test.py +++ b/keystone/test.py @@ -20,7 +20,9 @@ import subprocess import sys import time +import mox from paste import deploy +import stubout from keystone import config from keystone.common import kvs @@ -123,18 +125,26 @@ class TestCase(unittest.TestCase): def setUp(self): super(TestCase, self).setUp() self.config() + self.mox = mox.Mox() + self.stubs = stubout.StubOutForTesting() def config(self): CONF(config_files=[etcdir('keystone.conf'), testsdir('test_overrides.conf')]) def tearDown(self): - for path in self._paths: - if path in sys.path: - sys.path.remove(path) - kvs.INMEMDB.clear() - self.reset_opts() - super(TestCase, self).tearDown() + try: + self.mox.UnsetStubs() + self.stubs.UnsetAll() + self.stubs.SmartUnsetAll() + self.mox.VerifyAll() + super(TestCase, self).tearDown() + finally: + for path in self._paths: + if path in sys.path: + sys.path.remove(path) + kvs.INMEMDB.clear() + self.reset_opts() def opt(self, **kw): for k, v in kw.iteritems(): diff --git a/tests/policy.json b/tests/policy.json new file mode 100644 index 00000000..b006c23c --- /dev/null +++ b/tests/policy.json @@ -0,0 +1,3 @@ +{ + "admin_required": [["role:Keystadasd"], ["is_admin:1"]] +} diff --git a/tests/test_policy.py b/tests/test_policy.py new file mode 100644 index 00000000..b437c6a4 --- /dev/null +++ b/tests/test_policy.py @@ -0,0 +1,180 @@ +# 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. + +import StringIO +import tempfile +import urllib2 + +from keystone import config +from keystone import exception +from keystone import test +from keystone.common import policy as common_policy +from keystone.policy.backends import rules + + +CONF = config.CONF + + +class PolicyFileTestCase(test.TestCase): + def setUp(self): + super(PolicyFileTestCase, self).setUp() + rules.reset() + _unused, self.tmpfilename = tempfile.mkstemp() + self.opt(policy_file=self.tmpfilename) + self.target = {} + + def tearDown(self): + super(PolicyFileTestCase, self).tearDown() + rules.reset() + + def test_modified_policy_reloads(self): + action = "example:test" + empty_credentials = {} + with open(self.tmpfilename, "w") as policyfile: + policyfile.write("""{"example:test": []}""") + rules.enforce(empty_credentials, 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) + rules._POLICY_CACHE = {} + self.assertRaises(exception.Forbidden, rules.enforce, + empty_credentials, action, self.target) + + +class PolicyTestCase(test.TestCase): + def setUp(self): + super(PolicyTestCase, self).setUp() + rules.reset() + # NOTE(vish): preload rules to circumvent reloading from file + rules.init() + brain = { + "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:lowercase_admin": [["role:admin"], ["role:sysadmin"]], + "example:uppercase_admin": [["role:ADMIN"], ["role:sysadmin"]], + } + # NOTE(vish): then overload underlying brain + common_policy.set_brain(common_policy.HttpBrain(brain)) + self.credentials = {} + self.target = {} + + def tearDown(self): + rules.reset() + super(PolicyTestCase, self).tearDown() + + def test_enforce_nonexistent_action_throws(self): + action = "example:noexist" + self.assertRaises(exception.Forbidden, rules.enforce, + self.credentials, action, self.target) + + def test_enforce_bad_action_throws(self): + action = "example:denied" + self.assertRaises(exception.Forbidden, rules.enforce, + self.credentials, action, self.target) + + def test_enforce_good_action(self): + action = "example:allowed" + rules.enforce(self.credentials, 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 = rules.enforce(self.credentials, 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.Forbidden, rules.enforce, + self.credentials, action, target) + + def test_templatized_enforcement(self): + target_mine = {'project_id': 'fake'} + target_not_mine = {'project_id': 'another'} + credentials = {'project_id': 'fake', 'roles': []} + action = "example:my_file" + rules.enforce(credentials, action, target_mine) + self.assertRaises(exception.Forbidden, rules.enforce, + credentials, action, target_not_mine) + + def test_early_AND_enforcement(self): + action = "example:early_and_fail" + self.assertRaises(exception.Forbidden, rules.enforce, + self.credentials, action, self.target) + + def test_early_OR_enforcement(self): + action = "example:early_or_success" + rules.enforce(self.credentials, action, self.target) + + def test_ignore_case_role_check(self): + lowercase_action = "example:lowercase_admin" + uppercase_action = "example:uppercase_admin" + # NOTE(dprince) we mix case in the Admin role here to ensure + # case is ignored + admin_credentials = {'roles': ['AdMiN']} + rules.enforce(admin_credentials, lowercase_action, self.target) + rules.enforce(admin_credentials, uppercase_action, self.target) + + +class DefaultPolicyTestCase(test.TestCase): + def setUp(self): + super(DefaultPolicyTestCase, self).setUp() + rules.reset() + rules.init() + + self.brain = { + "default": [], + "example:exist": [["false:false"]] + } + + self._set_brain('default') + self.credentials = {} + + def _set_brain(self, default_rule): + brain = common_policy.HttpBrain(self.brain, default_rule) + common_policy.set_brain(brain) + + def tearDown(self): + super(DefaultPolicyTestCase, self).setUp() + rules.reset() + + def test_policy_called(self): + self.assertRaises(exception.Forbidden, rules.enforce, + self.credentials, "example:exist", {}) + + def test_not_found_policy_calls_default(self): + rules.enforce(self.credentials, "example:noexist", {}) + + def test_default_not_found(self): + self._set_brain("default_noexist") + self.assertRaises(exception.Forbidden, rules.enforce, + self.credentials, "example:noexist", {}) |
