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 oslo_config import cfg
from distil.api import auth from distil.api import auth
from distil.api import acl
from distil.api import v2 as api_v2 from distil.api import v2 as api_v2
from distil import config from distil import config
from distil.utils import api from distil.utils import api
@ -37,4 +38,5 @@ def make_app(args=None):
app.register_blueprint(api_v2.rest, url_prefix="/v2") app.register_blueprint(api_v2.rest, url_prefix="/v2")
app.wsgi_app = auth.wrap(app.wsgi_app, CONF) app.wsgi_app = auth.wrap(app.wsgi_app, CONF)
acl.setup_policy()
return app 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"); # Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with 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 oslo_log import log
from distil import exceptions from distil import exceptions
from distil.api import acl
from distil.service.api.v2 import costs from distil.service.api.v2 import costs
from distil.service.api.v2 import health from distil.service.api.v2 import health
from distil.service.api.v2 import prices from distil.service.api.v2 import prices
@ -28,6 +29,11 @@ LOG = log.getLogger(__name__)
rest = api.Rest('v2', __name__) rest = api.Rest('v2', __name__)
@rest.get('/health')
def health_get():
return api.render(health=health.get_health())
@rest.get('/prices') @rest.get('/prices')
def prices_get(): def prices_get():
format = api.get_request_args().get('format', None) format = api.get_request_args().get('format', None)
@ -44,6 +50,7 @@ def _get_usage_args():
@rest.get('/costs') @rest.get('/costs')
@acl.enforce("rating:costs:get")
def costs_get(): def costs_get():
project_id, start, end = _get_usage_args() project_id, start, end = _get_usage_args()
try: try:
@ -54,15 +61,11 @@ def costs_get():
return api.render(status=400, error=str(e)) return api.render(status=400, error=str(e))
@rest.get('/usage') @rest.get('/usages')
@acl.enforce("rating:usages:get")
def usage_get(): def usage_get():
project_id, start, end = _get_usage_args() project_id, start, end = _get_usage_args()
try: try:
return api.render(usage=costs.get_usage(project_id, start, end)) return api.render(usage=costs.get_usage(project_id, start, end))
except (exceptions.DateTimeException, exceptions.NotFoundException) as e: except (exceptions.DateTimeException, exceptions.NotFoundException) as e:
return api.render(status=400, error=str(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 # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import eventlet
from eventlet.green import threading 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_context import context
from oslo_log import log as logging
from distil import exceptions
CONF = cfg.CONF class RequestContext(context.RequestContext):
LOG = logging.getLogger(__name__)
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 make_context(*args, **kwargs):
def __init__(self, return RequestContext(*args, **kwargs)
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 get_admin_context(): def make_admin_context(show_deleted=False, all_tenants=False):
return Context(is_admin=True) """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() _CTX_STORE = threading.local()
@ -106,7 +72,7 @@ def has_ctx():
def ctx(): def ctx():
if not has_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) return getattr(_CTX_STORE, _CTX_KEY)
@ -117,6 +83,9 @@ def current():
def set_ctx(new_ctx): def set_ctx(new_ctx):
if not new_ctx and has_ctx(): if not new_ctx and has_ctx():
delattr(_CTX_STORE, _CTX_KEY) delattr(_CTX_STORE, _CTX_KEY)
if hasattr(context._request_store, 'context'):
delattr(context._request_store, 'context')
if new_ctx: if new_ctx:
setattr(_CTX_STORE, _CTX_KEY, 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 # License for the specific language governing permissions and limitations
# under the License. # under the License.
from oslo_utils import uuidutils
import sys import sys
from distil.i18n import _ from distil.i18n import _
@ -21,41 +22,23 @@ _FATAL_EXCEPTION_FORMAT_ERRORS = False
class DistilException(Exception): class DistilException(Exception):
"""Base Distil Exception """Base Exception for the project
To correctly use this class, inherit from it and define To correctly use this class, inherit from it and define
a 'message' property. That message will get printf'd a 'message' and 'code' properties.
with the keyword arguments provided to the constructor.
""" """
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): def __init__(self):
self.kwargs = kwargs super(DistilException, self).__init__(
'%s: %s' % (self.code, self.message))
if 'code' not in self.kwargs: self.uuid = uuidutils.generate_uuid()
try: self.message = (_('%(message)s\nError ID: %(id)s')
self.kwargs['code'] = self.code % {'message': self.message, 'id': self.uuid})
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)
class IncorrectStateError(DistilException): class IncorrectStateError(DistilException):
@ -97,5 +80,13 @@ class MalformedRequestBody(DistilException):
class DateTimeException(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.") 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) start = datetime.strptime(start, constants.iso_time)
else: else:
raise exceptions.DateTimeException( raise exceptions.DateTimeException(
code=400,
message=( message=(
"missing parameter:" + "Missing parameter:" +
"'start' in format: y-m-d or y-m-dTH:M:S")) "'start' in format: y-m-d or y-m-dTH:M:S"))
if not end: if not end:
end = datetime.utcnow() end = datetime.utcnow()
@ -52,15 +51,17 @@ def _validate_project_and_range(project_id, start, end):
end = datetime.strptime(end, constants.iso_time) end = datetime.strptime(end, constants.iso_time)
except ValueError: except ValueError:
raise exceptions.DateTimeException( raise exceptions.DateTimeException(
code=400,
message=( message=(
"missing parameter: " + "Missing parameter: " +
"'end' in format: y-m-d or y-m-dTH:M:S")) "'end' in format: y-m-d or y-m-dTH:M:S"))
if end <= start: if end <= start:
raise exceptions.DateTimeException( 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) valid_project = db_api.project_get(project_id)
return valid_project, start, end 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 import exceptions as ex
from distil.i18n import _ from distil.i18n import _
from distil.i18n import _LE from distil.i18n import _LE
from distil import context
from oslo_log import log as logging from oslo_log import log as logging
from distil.utils import wsgi from distil.utils import wsgi
@ -60,11 +61,23 @@ class Rest(flask.Blueprint):
if status: if status:
flask.request.status_code = 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']: if flask.request.method in ['POST', 'PUT']:
kwargs['data'] = request_data() kwargs['data'] = request_data()
try: try:
return func(**kwargs) return func(**kwargs)
except ex.Forbidden as e:
return access_denied(e)
except ex.DistilException as e: except ex.DistilException as e:
return bad_request(e) return bad_request(e)
except Exception as e: except Exception as e:
@ -73,7 +86,7 @@ class Rest(flask.Blueprint):
f_rule = rule f_rule = rule
self.add_url_rule(f_rule, endpoint, handler, **options) 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 + '.json', endpoint, handler, **options)
self.add_url_rule(f_rule + '.xml', endpoint, handler, **options)
return func return func
return decorator return decorator
@ -220,6 +233,18 @@ def bad_request(error):
return render_error_message(error_code, error.message, error.code) 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): def not_found(error):
error_code = 404 error_code = 404
@ -227,4 +252,4 @@ def not_found(error):
"error_code=%s, error_message=%s, error_name=%s", "error_code=%s, error_message=%s, error_name=%s",
error_code, error.message, error.code) 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.db>=4.1.0 # Apache-2.0
oslo.i18n>=2.1.0 # Apache-2.0 oslo.i18n>=2.1.0 # Apache-2.0
oslo.log>=1.14.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.serialization>=1.10.0 # Apache-2.0
oslo.service>=1.0.0 # Apache-2.0 oslo.service>=1.0.0 # Apache-2.0
oslo.utils>=3.5.0 # Apache-2.0 oslo.utils>=3.5.0 # Apache-2.0