From 1f2f5dea5fef70fe688fab6bcd04f52eac89ca4c Mon Sep 17 00:00:00 2001 From: Julien Danjou Date: Wed, 3 Oct 2012 18:03:01 +0200 Subject: [PATCH] API: add Keystone ACL and policy support This fixes bug #1060919 Change-Id: I5257acc5eeace7f3ff38785223b1eaa7a3711d17 Signed-off-by: Julien Danjou --- ceilometer/api/acl.py | 45 ++++ ceilometer/api/app.py | 4 + ceilometer/openstack/common/policy.py | 301 ++++++++++++++++++++++++++ ceilometer/policy.py | 68 ++++++ ceilometer/utils.py | 44 ++++ etc/ceilometer/policy.json | 3 + openstack-common.conf | 2 +- tests/api/test_acl.py | 60 +++++ tests/policy.json | 4 + tools/test-requires | 1 + 10 files changed, 531 insertions(+), 1 deletion(-) create mode 100644 ceilometer/api/acl.py create mode 100644 ceilometer/openstack/common/policy.py create mode 100644 ceilometer/policy.py create mode 100644 ceilometer/utils.py create mode 100644 etc/ceilometer/policy.json create mode 100644 tests/api/test_acl.py create mode 100644 tests/policy.json diff --git a/ceilometer/api/acl.py b/ceilometer/api/acl.py new file mode 100644 index 00000000..7756162a --- /dev/null +++ b/ceilometer/api/acl.py @@ -0,0 +1,45 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Julien Danjou +# +# 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. +"""Set up the ACL to acces the API server.""" + +import flask +from ceilometer.openstack.common import cfg +from ceilometer import policy + +import keystone.middleware.auth_token + +# Register keystone middleware option +cfg.CONF.register_opts(keystone.middleware.auth_token.opts, + group='keystone_authtoken') +keystone.middleware.auth_token.CONF = cfg.CONF + + +def install(app): + """Install ACL check on application.""" + app.wsgi_app = keystone.middleware.auth_token.AuthProtocol(app.wsgi_app, + {}) + app.before_request(check) + + +def check(): + """Check application access.""" + headers = flask.request.headers + if not policy.check_is_admin(headers.get('X-Roles', "").split(","), + headers.get('X-Tenant-Id'), + headers.get('X-Tenant-Name')): + return "Access denied", 401 diff --git a/ceilometer/api/app.py b/ceilometer/api/app.py index ebe5bdae..4ea522d7 100644 --- a/ceilometer/api/app.py +++ b/ceilometer/api/app.py @@ -23,6 +23,8 @@ import flask from ceilometer.openstack.common import cfg from ceilometer import storage from ceilometer.api import v1 +from ceilometer.api import acl + app = flask.Flask('ceilometer.api') app.register_blueprint(v1.blueprint, url_prefix='/v1') @@ -36,3 +38,5 @@ def attach_config(): storage_engine = storage.get_engine(cfg.CONF) flask.request.storage_engine = storage_engine flask.request.storage_conn = storage_engine.get_connection(cfg.CONF) + +acl.install(app) diff --git a/ceilometer/openstack/common/policy.py b/ceilometer/openstack/common/policy.py new file mode 100644 index 00000000..831dac25 --- /dev/null +++ b/ceilometer/openstack/common/policy.py @@ -0,0 +1,301 @@ +# 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 logging +import urllib +import urllib2 + +from ceilometer.openstack.common.gettextutils import _ +from ceilometer.openstack.common import jsonutils + + +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.""" + + _checks = {} + + @classmethod + def _register(cls, name, func): + cls._checks[name] = func + + @classmethod + def load_json(cls, data, default_rule=None): + """Init a brain using json instead of a rules dictionary.""" + rules_dict = jsonutils.loads(data) + return cls(rules=rules_dict, default_rule=default_rule) + + def __init__(self, rules=None, default_rule=None): + if self.__class__ != Brain: + LOG.warning(_("Inheritance-based rules are deprecated; use " + "the default brain instead of %s.") % + self.__class__.__name__) + + 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 + + func = None + try: + old_func = getattr(self, '_check_%s' % match_kind) + except AttributeError: + func = self._checks.get(match_kind, self._checks.get(None, None)) + else: + LOG.warning(_("Inheritance-based rules are deprecated; update " + "_check_%s") % match_kind) + func = lambda brain, kind, value, target, cred: old_func(value, + target, + cred) + + if not func: + LOG.error(_("No handler for matches of kind %s") % match_kind) + # Fail closed + return False + + return func(self, match_kind, match_value, target_dict, cred_dict) + + 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 + + +class HttpBrain(Brain): + """A brain that can check external urls for policy. + + Posts json blobs for target and credentials. + + Note that this brain is deprecated; the http check is registered + by default. + """ + + pass + + +def register(name, func=None): + """ + Register a function as a policy check. + + :param name: Gives the name of the check type, e.g., 'rule', + 'role', etc. If name is None, a default function + will be registered. + :param func: If given, provides the function to register. If not + given, returns a function taking one argument to + specify the function to register, allowing use as a + decorator. + """ + + # Perform the actual decoration by registering the function. + # Returns the function for compliance with the decorator + # interface. + def decorator(func): + # Register the function + Brain._register(name, func) + return func + + # If the function is given, do the registration + if func: + return decorator(func) + + return decorator + + +@register("rule") +def _check_rule(brain, match_kind, match, target_dict, cred_dict): + """Recursively checks credentials based on the brains rules.""" + try: + new_match_list = brain.rules[match] + except KeyError: + if brain.default_rule and match != brain.default_rule: + new_match_list = ('rule:%s' % brain.default_rule,) + else: + return False + + return brain.check(new_match_list, target_dict, cred_dict) + + +@register("role") +def _check_role(brain, match_kind, 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']] + + +@register('http') +def _check_http(brain, match_kind, 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 = 'http:' + (match % target_dict) + data = {'target': jsonutils.dumps(target_dict), + 'credentials': jsonutils.dumps(cred_dict)} + post_data = urllib.urlencode(data) + f = urllib2.urlopen(url, post_data) + return f.read() == "True" + + +@register(None) +def _check_generic(brain, match_kind, 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 + if match_kind in cred_dict: + return match == unicode(cred_dict[match_kind]) + return False diff --git a/ceilometer/policy.py b/ceilometer/policy.py new file mode 100644 index 00000000..46ad54ad --- /dev/null +++ b/ceilometer/policy.py @@ -0,0 +1,68 @@ +# 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 Ceilometer""" + +from ceilometer import utils +from ceilometer.openstack.common import cfg +from ceilometer.openstack.common import 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'), + ] + +cfg.CONF.register_opts(OPTS) + +_POLICY_PATH = None +_POLICY_CACHE = {} + + +def init(): + global _POLICY_PATH + global _POLICY_CACHE + if not _POLICY_PATH: + _POLICY_PATH = cfg.CONF.policy_file + utils.read_cached_file(_POLICY_PATH, _POLICY_CACHE, + reload_func=_set_brain) + + +def _set_brain(data): + default_rule = cfg.CONF.policy_default_rule + policy.set_brain(policy.Brain.load_json(data, default_rule)) + + +def check_is_admin(roles, project_id, project_name): + """Whether or not roles contains 'admin' role according to policy setting. + + """ + init() + + match_list = ('rule:context_is_admin',) + target = {} + credentials = { + 'roles': roles, + 'project_id': project_id, + 'project_name': project_name, + } + + return policy.enforce(match_list, target, credentials) diff --git a/ceilometer/utils.py b/ceilometer/utils.py new file mode 100644 index 00000000..11bcdfe8 --- /dev/null +++ b/ceilometer/utils.py @@ -0,0 +1,44 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# Copyright 2011 Justin Santa Barbara +# 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. + +"""Utilities and helper functions.""" + + +import os + + +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'] diff --git a/etc/ceilometer/policy.json b/etc/ceilometer/policy.json new file mode 100644 index 00000000..373c5688 --- /dev/null +++ b/etc/ceilometer/policy.json @@ -0,0 +1,3 @@ +{ + "context_is_admin": [["role:admin"]] +} diff --git a/openstack-common.conf b/openstack-common.conf index b81947fc..a1985311 100644 --- a/openstack-common.conf +++ b/openstack-common.conf @@ -1,3 +1,3 @@ [DEFAULT] -modules=cfg,iniparser,rpc,importutils,excutils,local,jsonutils,gettextutils,timeutils,notifier,context,log,network_utils,setup +modules=cfg,iniparser,rpc,importutils,excutils,local,jsonutils,gettextutils,timeutils,notifier,context,log,network_utils,setup,policy base=ceilometer diff --git a/tests/api/test_acl.py b/tests/api/test_acl.py new file mode 100644 index 00000000..cf7f58a2 --- /dev/null +++ b/tests/api/test_acl.py @@ -0,0 +1,60 @@ +# -*- encoding: utf-8 -*- +# +# Copyright © 2012 New Dream Network, LLC (DreamHost) +# +# Author: Julien Danjou +# +# 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 ACL.""" + +from ceilometer.tests import api as tests_api +from ceilometer.api import acl +from ceilometer.openstack.common import cfg + + +class TestAPIACL(tests_api.TestBase): + + def setUp(self): + super(TestAPIACL, self).setUp() + acl.install(self.app) + + def test_non_authenticated(self): + with self.app.test_request_context('/'): + self.app.preprocess_request() + self.assertEqual(self.test_app.get().status_code, 401) + + def test_authenticated_wrong_role(self): + with self.app.test_request_context('/', headers={ + "X-Roles": "Member", + "X-Tenant-Name": "foobar", + "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + }): + self.app.preprocess_request() + self.assertEqual(self.test_app.get().status_code, 401) + + def test_authenticated_wrong_tenant(self): + with self.app.test_request_context('/', headers={ + "X-Roles": "admin", + "X-Tenant-Name": "foobar", + "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + }): + self.app.preprocess_request() + self.assertEqual(self.test_app.get().status_code, 401) + + def test_authenticated(self): + with self.app.test_request_context('/', headers={ + "X-Roles": "admin", + "X-Tenant-Name": "admin", + "X-Tenant-Id": "bc23a9d531064583ace8f67dad60f6bb", + }): + self.assertEqual(self.app.preprocess_request(), None) diff --git a/tests/policy.json b/tests/policy.json new file mode 100644 index 00000000..4f119b48 --- /dev/null +++ b/tests/policy.json @@ -0,0 +1,4 @@ +{ + "context_is_admin": [["role:admin"]], + "admin_api": [["is_admin:True"]] +} diff --git a/tools/test-requires b/tools/test-requires index c0e0106f..36074a12 100644 --- a/tools/test-requires +++ b/tools/test-requires @@ -14,4 +14,5 @@ python-glanceclient https://github.com/dreamhost/Ming/zipball/master#egg=Ming http://tarballs.openstack.org/nova/nova-master.tar.gz http://tarballs.openstack.org/glance/glance-master.tar.gz +http://tarballs.openstack.org/keystone/keystone-master.tar.gz setuptools-git>=0.4