API: add Keystone ACL and policy support

This fixes bug #1060919

Change-Id: I5257acc5eeace7f3ff38785223b1eaa7a3711d17
Signed-off-by: Julien Danjou <julien@danjou.info>
This commit is contained in:
Julien Danjou 2012-10-03 18:03:01 +02:00
parent 55e13884ed
commit 1f2f5dea5f
10 changed files with 531 additions and 1 deletions

45
ceilometer/api/acl.py Normal file
View File

@ -0,0 +1,45 @@
# -*- encoding: utf-8 -*-
#
# Copyright © 2012 New Dream Network, LLC (DreamHost)
#
# Author: Julien Danjou <julien@danjou.info>
#
# 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

View File

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

View File

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

68
ceilometer/policy.py Normal file
View File

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

44
ceilometer/utils.py Normal file
View File

@ -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']

View File

@ -0,0 +1,3 @@
{
"context_is_admin": [["role:admin"]]
}

View File

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

60
tests/api/test_acl.py Normal file
View File

@ -0,0 +1,60 @@
# -*- encoding: utf-8 -*-
#
# Copyright © 2012 New Dream Network, LLC (DreamHost)
#
# Author: Julien Danjou <julien@danjou.info>
#
# 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)

4
tests/policy.json Normal file
View File

@ -0,0 +1,4 @@
{
"context_is_admin": [["role:admin"]],
"admin_api": [["is_admin:True"]]
}

View File

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