Add policy support
Using oslo.policy to support access control for Distil API access, which will follow the community way. Change-Id: I670d9fde4f5c368e82c26b512b7c1e46c2e380ec
This commit is contained in:
parent
6c69d161e9
commit
09b03931eb
53
distil/api/acl.py
Normal file
53
distil/api/acl.py
Normal 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
|
@ -17,6 +17,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
|
||||
@ -36,4 +37,5 @@ def make_app():
|
||||
|
||||
app.register_blueprint(api_v2.rest, url_prefix="/v2")
|
||||
app.wsgi_app = auth.wrap(app.wsgi_app, CONF)
|
||||
acl.setup_policy()
|
||||
return app
|
||||
|
@ -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())
|
||||
|
@ -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)
|
||||
|
@ -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")
|
||||
|
@ -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
|
||||
|
0
distil/tests/unit/api/__init__.py
Normal file
0
distil/tests/unit/api/__init__.py
Normal file
57
distil/tests/unit/api/test_acl.py
Normal file
57
distil/tests/unit/api/test_acl.py
Normal 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
46
distil/tests/unit/base.py
Normal 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)
|
@ -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
8
etc/policy.json.sample
Normal 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",
|
||||
}
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user