diff options
| author | termie <github@anarkystic.com> | 2012-02-28 16:50:48 -0800 |
|---|---|---|
| committer | termie <github@anarkystic.com> | 2012-03-08 14:06:32 -0800 |
| commit | a2f2274c69df2ca5b040a69173f3eb7eb030c561 (patch) | |
| tree | c57294737b6fe1bda4706d95e08863d4f81c958e /keystone | |
| parent | e5254d48b133f3ec9798cc8eb48a03cb69ff2d97 (diff) | |
| download | keystone-a2f2274c69df2ca5b040a69173f3eb7eb030c561.tar.gz keystone-a2f2274c69df2ca5b040a69173f3eb7eb030c561.tar.xz keystone-a2f2274c69df2ca5b040a69173f3eb7eb030c561.zip | |
port common policy code to keystone
keystone.common.policy is copied from nova
leave simple backend in as a shim until devstack stops referencing it
Change-Id: Ibd579cfeb99465706d525b6565818a2d8f5f3b7c
Diffstat (limited to 'keystone')
| -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 |
7 files changed, 388 insertions, 27 deletions
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(): |
