diff --git a/distil/api/acl.py b/distil/api/acl.py new file mode 100644 index 0000000..a53e78c --- /dev/null +++ b/distil/api/acl.py @@ -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 diff --git a/distil/api/app.py b/distil/api/app.py index c62d5bb..114481f 100644 --- a/distil/api/app.py +++ b/distil/api/app.py @@ -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 diff --git a/distil/api/v2.py b/distil/api/v2.py index 83a606c..6ff9e87 100644 --- a/distil/api/v2.py +++ b/distil/api/v2.py @@ -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()) diff --git a/distil/context.py b/distil/context.py index fa6e8f9..35ca49e 100644 --- a/distil/context.py +++ b/distil/context.py @@ -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) diff --git a/distil/exceptions.py b/distil/exceptions.py index cb9aabc..314aa61 100644 --- a/distil/exceptions.py +++ b/distil/exceptions.py @@ -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") diff --git a/distil/service/api/v2/costs.py b/distil/service/api/v2/costs.py index 3816e8d..216865e 100644 --- a/distil/service/api/v2/costs.py +++ b/distil/service/api/v2/costs.py @@ -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 diff --git a/distil/tests/unit/api/__init__.py b/distil/tests/unit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/distil/tests/unit/api/test_acl.py b/distil/tests/unit/api/test_acl.py new file mode 100644 index 0000000..746d8c0 --- /dev/null +++ b/distil/tests/unit/api/test_acl.py @@ -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 diff --git a/distil/tests/unit/base.py b/distil/tests/unit/base.py new file mode 100644 index 0000000..e7256ef --- /dev/null +++ b/distil/tests/unit/base.py @@ -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) diff --git a/distil/utils/api.py b/distil/utils/api.py index 9832773..96b897d 100644 --- a/distil/utils/api.py +++ b/distil/utils/api.py @@ -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) \ No newline at end of file + return render_error_message(error_code, error.message, error.code) diff --git a/etc/policy.json.sample b/etc/policy.json.sample new file mode 100644 index 0000000..12d1a82 --- /dev/null +++ b/etc/policy.json.sample @@ -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", +} diff --git a/requirements.txt b/requirements.txt index a7aea9d..545a610 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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