Merge "Add policy support"

This commit is contained in:
Jenkins 2016-10-08 07:39:29 +00:00 committed by Gerrit Code Review
commit 103621479d
12 changed files with 275 additions and 119 deletions

53
distil/api/acl.py Normal file
View File

@ -0,0 +1,53 @@
# Copyright (c) 2016 Catalyst IT Ltd.
#
# 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 enforcer of Distil"""
import flask
import functools
from oslo_config import cfg
from oslo_policy import policy
from distil import context
from distil import exceptions
ENFORCER = None
def setup_policy():
global ENFORCER
ENFORCER = policy.Enforcer(cfg.CONF)
def check_is_admin(ctx):
credentials = ctx.to_dict()
target = credentials
return ENFORCER.enforce('context_is_admin', target, credentials)
def enforce(rule):
def decorator(func):
@functools.wraps(func)
def handler(*args, **kwargs):
ctx = context.ctx()
ctx.is_admin = check_is_admin(ctx)
ENFORCER.enforce(rule, {}, ctx.to_dict(), do_raise=True,
exc=exceptions.Forbidden)
return func(*args, **kwargs)
return handler
return decorator

View File

@ -16,6 +16,7 @@ import flask
from oslo_config import cfg
from distil.api import auth
from distil.api import acl
from distil.api import v2 as api_v2
from distil import config
from distil.utils import api
@ -37,4 +38,5 @@ def make_app(args=None):
app.register_blueprint(api_v2.rest, url_prefix="/v2")
app.wsgi_app = auth.wrap(app.wsgi_app, CONF)
acl.setup_policy()
return app

View File

@ -1,4 +1,4 @@
# Copyright (c) 2014 Catalyst IT Ltd
# Copyright (c) 2016 Catalyst IT Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@ -18,6 +18,7 @@ from dateutil import parser
from oslo_log import log
from distil import exceptions
from distil.api import acl
from distil.service.api.v2 import costs
from distil.service.api.v2 import health
from distil.service.api.v2 import prices
@ -28,6 +29,11 @@ LOG = log.getLogger(__name__)
rest = api.Rest('v2', __name__)
@rest.get('/health')
def health_get():
return api.render(health=health.get_health())
@rest.get('/prices')
def prices_get():
format = api.get_request_args().get('format', None)
@ -44,6 +50,7 @@ def _get_usage_args():
@rest.get('/costs')
@acl.enforce("rating:costs:get")
def costs_get():
project_id, start, end = _get_usage_args()
try:
@ -54,15 +61,11 @@ def costs_get():
return api.render(status=400, error=str(e))
@rest.get('/usage')
@rest.get('/usages')
@acl.enforce("rating:usages:get")
def usage_get():
project_id, start, end = _get_usage_args()
try:
return api.render(usage=costs.get_usage(project_id, start, end))
except (exceptions.DateTimeException, exceptions.NotFoundException) as e:
return api.render(status=400, error=str(e))
@rest.get('/health')
def health_get():
return api.render(health=health.get_health())

View File

@ -13,87 +13,53 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import eventlet
from eventlet.green import threading
from eventlet.green import time
from eventlet import greenpool
from eventlet import semaphore
from oslo_config import cfg
from distil.api import acl
from distil import exceptions as ex
from distil.i18n import _
from distil.i18n import _LE
from distil.i18n import _LW
from oslo_context import context
from oslo_log import log as logging
from distil import exceptions
CONF = cfg.CONF
LOG = logging.getLogger(__name__)
class RequestContext(context.RequestContext):
def __init__(self, project_id=None, overwrite=True,
auth_token=None, user=None, tenant=None, domain=None,
user_domain=None, project_domain=None, is_admin=False,
read_only=False, show_deleted=False, request_id=None,
instance_uuid=None, roles=None, **kwargs):
super(RequestContext, self).__init__(auth_token=auth_token,
user=user,
tenant=tenant,
domain=domain,
user_domain=user_domain,
project_domain=project_domain,
is_admin=is_admin,
read_only=read_only,
show_deleted=False,
request_id=request_id,
roles=roles)
self.project_id = project_id or self.tenant
if overwrite or not hasattr(context._request_store, 'context'):
self.update_store()
def update_store(self):
context._request_store.context = self
class Context(context.RequestContext):
def __init__(self,
user_id=None,
tenant_id=None,
token=None,
service_catalog=None,
username=None,
tenant_name=None,
roles=None,
is_admin=None,
remote_semaphore=None,
auth_uri=None,
**kwargs):
if kwargs:
LOG.warn(_LW('Arguments dropped when creating context: %s'),
kwargs)
self.user_id = user_id
self.tenant_id = tenant_id
self.token = token
self.service_catalog = service_catalog
self.username = username
self.tenant_name = tenant_name
self.is_admin = is_admin
self.remote_semaphore = remote_semaphore or semaphore.Semaphore(
CONF.cluster_remote_threshold)
self.roles = roles
self.auth_uri = auth_uri
def clone(self):
return Context(
self.user_id,
self.tenant_id,
self.token,
self.service_catalog,
self.username,
self.tenant_name,
self.roles,
self.is_admin,
self.remote_semaphore,
self.auth_uri)
def to_dict(self):
return {
'user_id': self.user_id,
'tenant_id': self.tenant_id,
'token': self.token,
'service_catalog': self.service_catalog,
'username': self.username,
'tenant_name': self.tenant_name,
'is_admin': self.is_admin,
'roles': self.roles,
'auth_uri': self.auth_uri,
}
def is_auth_capable(self):
return (self.service_catalog and self.token and self.tenant_id and
self.user_id)
def make_context(*args, **kwargs):
return RequestContext(*args, **kwargs)
def get_admin_context():
return Context(is_admin=True)
def make_admin_context(show_deleted=False, all_tenants=False):
"""Create an administrator context.
:param show_deleted: if True, will show deleted items when query db
"""
context = RequestContext(user_id=None,
project=None,
is_admin=True,
show_deleted=show_deleted,
all_tenants=all_tenants)
return context
_CTX_STORE = threading.local()
@ -106,7 +72,7 @@ def has_ctx():
def ctx():
if not has_ctx():
raise ex.IncorrectStateError(_("Context isn't available here"))
raise exceptions.IncorrectStateError(_("Context isn't available here"))
return getattr(_CTX_STORE, _CTX_KEY)
@ -117,6 +83,9 @@ def current():
def set_ctx(new_ctx):
if not new_ctx and has_ctx():
delattr(_CTX_STORE, _CTX_KEY)
if hasattr(context._request_store, 'context'):
delattr(context._request_store, 'context')
if new_ctx:
setattr(_CTX_STORE, _CTX_KEY, new_ctx)
setattr(context._request_store, 'context', new_ctx)

View File

@ -12,6 +12,7 @@
# License for the specific language governing permissions and limitations
# under the License.
from oslo_utils import uuidutils
import sys
from distil.i18n import _
@ -21,41 +22,23 @@ _FATAL_EXCEPTION_FORMAT_ERRORS = False
class DistilException(Exception):
"""Base Distil Exception
"""Base Exception for the project
To correctly use this class, inherit from it and define
a 'message' property. That message will get printf'd
with the keyword arguments provided to the constructor.
a 'message' and 'code' properties.
"""
message = _("An unknown exception occurred")
code = "UNKNOWN_EXCEPTION"
msg_fmt = _("An unknown exception occurred.")
def __str__(self):
return self.message
def __init__(self, message=None, **kwargs):
self.kwargs = kwargs
if 'code' not in self.kwargs:
try:
self.kwargs['code'] = self.code
except AttributeError:
pass
if not message:
try:
message = self.msg_fmt % kwargs
except KeyError:
exc_info = sys.exc_info()
if _FATAL_EXCEPTION_FORMAT_ERRORS:
raise exc_info[0], exc_info[1], exc_info[2]
else:
message = self.msg_fmt
super(DistilException, self).__init__(message)
def format_message(self):
if self.__class__.__name__.endswith('_Remote'):
return self.args[0]
else:
return unicode(self)
def __init__(self):
super(DistilException, self).__init__(
'%s: %s' % (self.code, self.message))
self.uuid = uuidutils.generate_uuid()
self.message = (_('%(message)s\nError ID: %(id)s')
% {'message': self.message, 'id': self.uuid})
class IncorrectStateError(DistilException):
@ -97,5 +80,13 @@ class MalformedRequestBody(DistilException):
class DateTimeException(DistilException):
# This message should be replaced when thrown to be more specific:
message = _("An unexpected date, date format, or date range was given.")
def __init__(self, message=None):
self.code = 400
self.message = message
class Forbidden(DistilException):
code = "FORBIDDEN"
message = _("You are not authorized to complete this action")

View File

@ -39,9 +39,8 @@ def _validate_project_and_range(project_id, start, end):
start = datetime.strptime(start, constants.iso_time)
else:
raise exceptions.DateTimeException(
code=400,
message=(
"missing parameter:" +
"Missing parameter:" +
"'start' in format: y-m-d or y-m-dTH:M:S"))
if not end:
end = datetime.utcnow()
@ -52,15 +51,17 @@ def _validate_project_and_range(project_id, start, end):
end = datetime.strptime(end, constants.iso_time)
except ValueError:
raise exceptions.DateTimeException(
code=400,
message=(
"missing parameter: " +
"Missing parameter: " +
"'end' in format: y-m-d or y-m-dTH:M:S"))
if end <= start:
raise exceptions.DateTimeException(
code=400, message="End date must be greater than start.")
message="End date must be greater than start.")
if not project_id:
raise exceptions.NotFoundException(value='project_id',
message="Missing parameter: %s")
valid_project = db_api.project_get(project_id)
return valid_project, start, end

View File

View File

@ -0,0 +1,57 @@
# Copyright (c) 2014 Mirantis Inc.
#
# 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 flask
import mock
from oslo_policy import policy as cpolicy
from distil.api import acl
from distil import exceptions as ex
from distil.tests.unit import base
from distil import context
class TestAcl(base.DistilTestCase):
def _set_policy(self, json):
acl.setup_policy()
rules = cpolicy.Rules.load_json(json)
acl.ENFORCER.set_rules(rules, use_conf=False)
def test_policy_allow(self):
@acl.enforce("rating:get_all")
def test():
pass
json = '{"rating:get_all": ""}'
self._set_policy(json)
test()
def test_policy_deny(self):
@acl.enforce("rating:get_all")
def test(context):
pass
json = '{"rating:get_all": "!"}'
self._set_policy(json)
self.assertRaises(ex.Forbidden, test, context.RequestContext())
@mock.patch('flask.Request')
def test_route_post(self, get_deta_mock):
get_deta_mock.return_value = '{"foo": "bar"}'
pass

46
distil/tests/unit/base.py Normal file
View File

@ -0,0 +1,46 @@
# Copyright (c) 2013 Mirantis Inc.
#
# 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 flask
import mock
from oslotest import base
from distil import context
class DistilTestCase(base.BaseTestCase):
def setUp(self):
super(DistilTestCase, self).setUp()
self.setup_context()
def setup_context(self, username="test_user", tenant_id="tenant_1",
auth_token="test_auth_token", tenant_name='test_tenant',
service_catalog=None, **kwargs):
self.addCleanup(context.set_ctx,
context.ctx() if context.has_ctx() else None)
context.set_ctx(context.RequestContext(
username=username, tenant_id=tenant_id,
auth_token=auth_token, service_catalog=service_catalog or {},
tenant_name=tenant_name, **kwargs))
class DistilWithDbTestCase(DistilTestCase):
def setUp(self):
super(DistilWithDbTestCase, self).setUp()
self.override_config('connection', "sqlite://", group='database')
db_api.setup_db()
self.addCleanup(db_api.drop_db)

View File

@ -21,6 +21,7 @@ from werkzeug import datastructures
from distil import exceptions as ex
from distil.i18n import _
from distil.i18n import _LE
from distil import context
from oslo_log import log as logging
from distil.utils import wsgi
@ -60,11 +61,23 @@ class Rest(flask.Blueprint):
if status:
flask.request.status_code = status
req_id = flask.request.headers.get('X-Openstack-Request-ID')
ctx = context.RequestContext(
user=flask.request.headers.get('X-User-Id'),
tenant=flask.request.headers.get('X-Tenant-Id'),
auth_token=flask.request.headers.get('X-Auth-Token'),
request_id=req_id,
roles=flask.request.headers.get('X-Roles', '').split(','))
context.set_ctx(ctx)
if flask.request.method in ['POST', 'PUT']:
kwargs['data'] = request_data()
try:
return func(**kwargs)
except ex.Forbidden as e:
return access_denied(e)
except ex.DistilException as e:
return bad_request(e)
except Exception as e:
@ -73,7 +86,7 @@ class Rest(flask.Blueprint):
f_rule = rule
self.add_url_rule(f_rule, endpoint, handler, **options)
self.add_url_rule(f_rule + '.json', endpoint, handler, **options)
self.add_url_rule(f_rule + '.xml', endpoint, handler, **options)
return func
return decorator
@ -220,6 +233,18 @@ def bad_request(error):
return render_error_message(error_code, error.message, error.code)
def access_denied(error):
error_code = 403
LOG.error(_LE("Access Denied: "
"error_code={code}, error_message={message}, "
"error_name={name}").format(code=error_code,
message=error.message,
name=error.code))
return render_error_message(error_code, error.message, error.code)
def not_found(error):
error_code = 404
@ -227,4 +252,4 @@ def not_found(error):
"error_code=%s, error_message=%s, error_name=%s",
error_code, error.message, error.code)
return render_error_message(error_code, error.message, error.code)
return render_error_message(error_code, error.message, error.code)

8
etc/policy.json.sample Normal file
View File

@ -0,0 +1,8 @@
{
"context_is_admin": "role:admin",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s",
"default": "rule:admin_or_owner",
"rating:costs:get": "rule:context_is_admin",
"rating:usages:get": "rule:context_is_admin",
}

View File

@ -24,6 +24,7 @@ oslo.context>=2.2.0 # Apache-2.0
oslo.db>=4.1.0 # Apache-2.0
oslo.i18n>=2.1.0 # Apache-2.0
oslo.log>=1.14.0 # Apache-2.0
oslo.policy>=1.14.0 # Apache-2.0
oslo.serialization>=1.10.0 # Apache-2.0
oslo.service>=1.0.0 # Apache-2.0
oslo.utils>=3.5.0 # Apache-2.0