RBAC support based on oslo.policy

This commit adds support for RBAC using oslo.policy. This allows Zaqar
for having a fine-grained access control to the resources it exposes.
As of this patch, the implementation allows to have access control in
a per-operation basis rather than specific resources.

Co-Authored-by: Thomas Herve <therve@redhat.com>
Co-Authored-by: Flavio Percoco <flaper87@gmail.com>

blueprint: fine-grained-permissions

Change-Id: I90374a11815ac2bd9d31768588719d2d4c4e7f5d
This commit is contained in:
Fei Long Wang 2015-05-04 16:17:37 +12:00
parent e061305356
commit d08f4913ca
20 changed files with 273 additions and 6 deletions

View File

@ -37,6 +37,7 @@ ZAQAR_DIR=$DEST/zaqar
ZAQARCLIENT_DIR=$DEST/python-zaqarclient
ZAQAR_CONF_DIR=/etc/zaqar
ZAQAR_CONF=$ZAQAR_CONF_DIR/zaqar.conf
ZAQAR_POLICY_CONF=$ZAQAR_CONF_DIR/policy.json
ZAQAR_UWSGI_CONF=$ZAQAR_CONF_DIR/uwsgi.conf
ZAQAR_API_LOG_DIR=/var/log/zaqar
ZAQAR_API_LOG_FILE=$ZAQAR_API_LOG_DIR/queues.log
@ -112,6 +113,10 @@ function configure_zaqar {
[ ! -d $ZAQAR_CONF_DIR ] && sudo mkdir -m 755 -p $ZAQAR_CONF_DIR
sudo chown $USER $ZAQAR_CONF_DIR
if [[ -f $ZAQAR_DIR/etc/policy.json.sample ]]; then
cp -p $ZAQAR_DIR/etc/policy.json.sample $ZAQAR_POLICY_CONF
fi
[ ! -d $ZAQAR_API_LOG_DIR ] && sudo mkdir -m 755 -p $ZAQAR_API_LOG_DIR
sudo chown $USER $ZAQAR_API_LOG_DIR

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

@ -0,0 +1,45 @@
{
"context_is_admin": "role:admin",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s",
"default": "rule:admin_or_owner",
"queues:get_all": "",
"queues:create": "",
"queues:get": "",
"queues:delete": "",
"queues:update": "",
"queues:stats": "",
"messages:get_all": "",
"messages:create": "",
"messages:get": "",
"messages:delete": "",
"messages:delete_all": "",
"claims:get_all": "",
"claims:create": "",
"claims:get": "",
"claims:delete": "",
"claims:update": "",
"subscription:get_all": "",
"subscription:create": "",
"subscription:get": "",
"subscription:delete": "",
"subscription:update": "",
"pools:get_all": "rule:context_is_admin",
"pools:create": "rule:context_is_admin",
"pools:get": "rule:context_is_admin",
"pools:delete": "rule:context_is_admin",
"pools:update": "rule:context_is_admin",
"flavors:get_all": "",
"flavors:create": "rule:context_is_admin",
"flavors:get": "",
"flavors:delete": "rule:context_is_admin",
"flavors:update": "rule:context_is_admin",
"ping:get": "",
"health:get": "rule:context_is_admin"
}

View File

@ -21,6 +21,7 @@ oslo.i18n>=1.5.0 # Apache-2.0
oslo.log>=1.8.0 # Apache-2.0
oslo.serialization>=1.4.0 # Apache-2.0
oslo.utils>=2.0.0 # Apache-2.0
oslo.policy>=0.5.0 # Apache-2.0
SQLAlchemy<1.1.0,>=0.9.9
enum34;python_version=='2.7' or python_version=='2.6'
trollius>=1.0

View File

@ -200,7 +200,19 @@ def inject_context(req, resp, params):
"""
client_id = req.get_header('Client-ID')
project_id = params.get('project_id', None)
request_id = req.headers.get('X-Openstack-Request-ID'),
auth_token = req.headers.get('X-AUTH-TOKEN')
user = req.headers.get('X-USER-ID')
tenant = req.headers.get('X-TENANT-ID')
roles = req.headers.get('X-ROLES')
roles = roles and roles.split(',') or []
ctxt = context.RequestContext(project_id=project_id,
client_id=client_id)
client_id=client_id,
request_id=request_id,
auth_token=auth_token,
user=user,
tenant=tenant,
roles=roles)
req.env['zaqar.context'] = ctxt

View File

@ -26,7 +26,7 @@ class RequestContext(context.RequestContext):
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, **kwargs):
instance_uuid=None, roles=None, **kwargs):
super(RequestContext, self).__init__(auth_token=auth_token,
user=user,
tenant=tenant,
@ -39,6 +39,7 @@ class RequestContext(context.RequestContext):
request_id=request_id)
self.project_id = project_id
self.client_id = client_id
self.roles = roles
if overwrite or not hasattr(context._request_store, 'context'):
self.update_store()
@ -49,6 +50,8 @@ class RequestContext(context.RequestContext):
ctx = super(RequestContext, self).to_dict()
ctx.update({
'project_id': self.project_id,
'client_id': self.client_id
'client_id': self.client_id,
'tenant': self.tenant,
'roles': self.roles
})
return ctx

View File

@ -0,0 +1,45 @@
{
"context_is_admin": "role:admin",
"admin_or_owner": "is_admin:True or project_id:%(project_id)s",
"default": "rule:admin_or_owner",
"queues:get_all": "",
"queues:create": "",
"queues:get": "",
"queues:delete": "",
"queues:update": "",
"queues:stats": "",
"messages:get_all": "",
"messages:create": "",
"messages:get": "",
"messages:delete": "",
"messages:delete_all": "",
"claims:get_all": "",
"claims:create": "",
"claims:get": "",
"claims:delete": "",
"claims:update": "",
"subscription:get_all": "",
"subscription:create": "",
"subscription:get": "",
"subscription:delete": "",
"subscription:update": "",
"pools:get_all": "rule:context_is_admin",
"pools:create": "rule:context_is_admin",
"pools:get": "rule:context_is_admin",
"pools:delete": "rule:context_is_admin",
"pools:update": "rule:context_is_admin",
"flavors:get_all": "",
"flavors:create": "rule:context_is_admin",
"flavors:get": "",
"flavors:delete": "rule:context_is_admin",
"flavors:update": "rule:context_is_admin",
"ping:get": "",
"health:get": "rule:context_is_admin"
}

View File

@ -0,0 +1,57 @@
# Copyright (c) 2015 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.
from collections import namedtuple
from oslo_policy import policy
from zaqar import context
from zaqar.tests import base
from zaqar.transport import acl
from zaqar.transport.wsgi import errors
class TestAcl(base.TestBase):
def setUp(self):
super(TestAcl, self).setUp()
ctx = context.RequestContext()
request_class = namedtuple("Request", ("env",))
self.request = request_class({"zaqar.context": ctx})
def _set_policy(self, json):
acl.setup_policy(self.conf)
rules = policy.Rules.load_json(json)
acl.ENFORCER.set_rules(rules, use_conf=False)
def test_policy_allow(self):
@acl.enforce("queues:get_all")
def test(ign, request):
pass
json = '{"queues:get_all": ""}'
self._set_policy(json)
test(None, self.request)
def test_policy_deny(self):
@acl.enforce("queues:get_all")
def test(ign, request):
pass
json = '{"queues:get_all": "!"}'
self._set_policy(json)
self.assertRaises(errors.HTTPForbidden, test, None, self.request)

View File

@ -53,6 +53,9 @@ class TestBase(testing.TestBase):
self.headers = {
'Client-ID': str(uuid.uuid4()),
'X-ROLES': 'admin',
'X-USER-ID': 'a12d157c7d0d41999096639078fd11fc',
'X-TENANT-ID': 'abb69142168841fcaa2785791b92467f',
}
def tearDown(self):

View File

@ -52,10 +52,10 @@ class TestMessagesMongoDB(base.V2Base):
self.assertEqual(self.srmock.status, falcon.HTTP_201)
self.project_id = '7e55e1a7e'
self.headers = {
self.headers.update({
'Client-ID': str(uuid.uuid4()),
'X-Project-ID': self.project_id
}
})
# TODO(kgriffs): Add support in self.simulate_* for a "base path"
# so that we don't have to concatenate against self.url_prefix

44
zaqar/transport/acl.py Normal file
View File

@ -0,0 +1,44 @@
# Copyright (c) 2015 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 Zaqar"""
import functools
from oslo_policy import policy
ENFORCER = None
def setup_policy(conf):
global ENFORCER
ENFORCER = policy.Enforcer(conf)
def enforce(rule):
# Late import to prevent cycles
from zaqar.transport.wsgi import errors
def decorator(func):
@functools.wraps(func)
def handler(*args, **kwargs):
ctx = args[1].env['zaqar.context']
ENFORCER.enforce(rule, {}, ctx.to_dict(), do_raise=True,
exc=errors.HTTPForbidden)
return func(*args, **kwargs)
return handler
return decorator

View File

@ -25,6 +25,7 @@ from zaqar.common import decorators
from zaqar.common.transport.wsgi import helpers
from zaqar.i18n import _
from zaqar import transport
from zaqar.transport import acl
from zaqar.transport import auth
from zaqar.transport import validation
from zaqar.transport.wsgi import v1_0
@ -117,6 +118,8 @@ class Driver(transport.DriverBase):
self.app = auth.SignedHeadersAuth(self.app, auth_app)
acl.setup_policy(self._conf)
def _error_handler(self, exc, request, response, params):
if isinstance(exc, falcon.HTTPError):
raise exc

View File

@ -55,3 +55,13 @@ class HTTPDocumentTypeNotSupported(HTTPBadRequestBody):
def __init__(self):
super(HTTPDocumentTypeNotSupported, self).__init__(self.DESCRIPTION)
class HTTPForbidden(falcon.HTTPForbidden):
"""Wraps falcon.HTTPForbidden with a contextual title."""
TITLE = _(u'Not authorized')
DESCRIPTION = _(u'You are not authorized to complete this action.')
def __init__(self):
super(HTTPForbidden, self).__init__(self.TITLE, self.DESCRIPTION)

View File

@ -19,6 +19,7 @@ import six
from zaqar.i18n import _
from zaqar.storage import errors as storage_errors
from zaqar.transport import acl
from zaqar.transport import utils
from zaqar.transport import validation
from zaqar.transport.wsgi import errors as wsgi_errors
@ -53,6 +54,7 @@ class CollectionResource(object):
'grace': default_grace_ttl,
}
@acl.enforce("claims:create")
def on_post(self, req, resp, project_id, queue_name):
LOG.debug(u'Claims collection POST - queue: %(queue)s, '
u'project: %(project)s',
@ -125,6 +127,7 @@ class ItemResource(object):
('grace', int, default_grace_ttl),
)
@acl.enforce("claims:get")
def on_get(self, req, resp, project_id, queue_name, claim_id):
LOG.debug(u'Claim item GET - claim: %(claim_id)s, '
u'queue: %(queue_name)s, project: %(project_id)s',
@ -162,6 +165,7 @@ class ItemResource(object):
resp.body = utils.to_json(meta)
# status defaults to 200
@acl.enforce("claims:update")
def on_patch(self, req, resp, project_id, queue_name, claim_id):
LOG.debug(u'Claim Item PATCH - claim: %(claim_id)s, '
u'queue: %(queue_name)s, project:%(project_id)s' %
@ -196,6 +200,7 @@ class ItemResource(object):
description = _(u'Claim could not be updated.')
raise wsgi_errors.HTTPServiceUnavailable(description)
@acl.enforce("claims:delete")
def on_delete(self, req, resp, project_id, queue_name, claim_id):
LOG.debug(u'Claim item DELETE - claim: %(claim_id)s, '
u'queue: %(queue_name)s, project: %(project_id)s' %

View File

@ -21,6 +21,7 @@ from zaqar.common.api.schemas import flavors as schema
from zaqar.common import utils as common_utils
from zaqar.i18n import _
from zaqar.storage import errors
from zaqar.transport import acl
from zaqar.transport import utils as transport_utils
from zaqar.transport.wsgi import errors as wsgi_errors
from zaqar.transport.wsgi import utils as wsgi_utils
@ -38,6 +39,7 @@ class Listing(object):
self._ctrl = flavors_controller
self._pools_ctrl = pools_controller
@acl.enforce("flavors:get_all")
def on_get(self, request, response, project_id):
"""Returns a flavor listing as objects embedded in an object:
@ -113,6 +115,7 @@ class Resource(object):
'capabilities': validator_type(schema.patch_capabilities),
}
@acl.enforce("flavors:get")
def on_get(self, request, response, project_id, flavor):
"""Returns a JSON object for a single flavor entry:
@ -140,6 +143,7 @@ class Resource(object):
response.body = transport_utils.to_json(data)
@acl.enforce("flavors:create")
def on_put(self, request, response, project_id, flavor):
"""Registers a new flavor. Expects the following input:
@ -170,6 +174,7 @@ class Resource(object):
dict(flavor=flavor, pool=data['pool']))
raise falcon.HTTPBadRequest(_('Unable to create'), description)
@acl.enforce("flavors:delete")
def on_delete(self, request, response, project_id, flavor):
"""Deregisters a flavor.
@ -180,6 +185,7 @@ class Resource(object):
self._ctrl.delete(flavor, project=project_id)
response.status = falcon.HTTP_204
@acl.enforce("flavors:update")
def on_patch(self, request, response, project_id, flavor):
"""Allows one to update a flavors's pool and/or capabilities.

View File

@ -16,6 +16,7 @@
from oslo_log import log as logging
from zaqar.i18n import _
from zaqar.transport import acl
from zaqar.transport import utils
from zaqar.transport.wsgi import errors as wsgi_errors
@ -29,6 +30,7 @@ class Resource(object):
def __init__(self, driver):
self._driver = driver
@acl.enforce("health:get")
def on_get(self, req, resp, **kwargs):
try:
resp_dict = self._driver.health()

View File

@ -20,6 +20,7 @@ import six
from zaqar.common.transport.wsgi import helpers as wsgi_helpers
from zaqar.i18n import _
from zaqar.storage import errors as storage_errors
from zaqar.transport import acl
from zaqar.transport import utils
from zaqar.transport import validation
from zaqar.transport.wsgi import errors as wsgi_errors
@ -149,6 +150,7 @@ class CollectionResource(object):
# Interface
# ----------------------------------------------------------------------
@acl.enforce("messages:create")
def on_post(self, req, resp, project_id, queue_name):
LOG.debug(u'Messages collection POST - queue: %(queue)s, '
u'project: %(project)s',
@ -213,6 +215,7 @@ class CollectionResource(object):
resp.body = utils.to_json(body)
resp.status = falcon.HTTP_201
@acl.enforce("messages:get_all")
def on_get(self, req, resp, project_id, queue_name):
LOG.debug(u'Messages collection GET - queue: %(queue)s, '
u'project: %(project)s',
@ -237,6 +240,7 @@ class CollectionResource(object):
resp.body = utils.to_json(response)
# status defaults to 200
@acl.enforce("messages:delete_all")
def on_delete(self, req, resp, project_id, queue_name):
LOG.debug(u'Messages collection DELETE - queue: %(queue)s, '
u'project: %(project)s',
@ -306,6 +310,7 @@ class ItemResource(object):
def __init__(self, message_controller):
self._message_controller = message_controller
@acl.enforce("messages:get")
def on_get(self, req, resp, project_id, queue_name, message_id):
LOG.debug(u'Messages item GET - message: %(message)s, '
u'queue: %(queue)s, project: %(project)s',
@ -336,6 +341,7 @@ class ItemResource(object):
resp.body = utils.to_json(message)
# status defaults to 200
@acl.enforce("messages:delete")
def on_delete(self, req, resp, project_id, queue_name, message_id):
LOG.debug(u'Messages item DELETE - message: %(message)s, '

View File

@ -14,6 +14,8 @@
import falcon
from zaqar.transport import acl
class Resource(object):
@ -22,9 +24,11 @@ class Resource(object):
def __init__(self, driver):
self._driver = driver
@acl.enforce("ping:get")
def on_get(self, req, resp, **kwargs):
resp.status = (falcon.HTTP_204 if self._driver.is_alive()
else falcon.HTTP_503)
@acl.enforce("ping:get")
def on_head(self, req, resp, **kwargs):
resp.status = falcon.HTTP_204

View File

@ -45,6 +45,7 @@ from zaqar.common import utils as common_utils
from zaqar.i18n import _
from zaqar.storage import errors
from zaqar.storage import utils as storage_utils
from zaqar.transport import acl
from zaqar.transport import utils as transport_utils
from zaqar.transport.wsgi import errors as wsgi_errors
from zaqar.transport.wsgi import utils as wsgi_utils
@ -61,6 +62,7 @@ class Listing(object):
def __init__(self, pools_controller):
self._ctrl = pools_controller
@acl.enforce("pools:get_all")
def on_get(self, request, response, project_id):
"""Returns a pool listing as objects embedded in an object:
@ -128,6 +130,7 @@ class Resource(object):
'create': validator_type(schema.create)
}
@acl.enforce("pools:get")
def on_get(self, request, response, project_id, pool):
"""Returns a JSON object for a single pool entry:
@ -153,6 +156,7 @@ class Resource(object):
response.body = transport_utils.to_json(data)
@acl.enforce("pools:create")
def on_put(self, request, response, project_id, pool):
"""Registers a new pool. Expects the following input:
@ -181,6 +185,7 @@ class Resource(object):
response.status = falcon.HTTP_201
response.location = request.path
@acl.enforce("pools:delete")
def on_delete(self, request, response, project_id, pool):
"""Deregisters a pool.
@ -201,6 +206,7 @@ class Resource(object):
response.status = falcon.HTTP_204
@acl.enforce("pools:update")
def on_patch(self, request, response, project_id, pool):
"""Allows one to update a pool's weight, uri, and/or options.

View File

@ -19,12 +19,12 @@ import six
from zaqar.i18n import _
from zaqar.storage import errors as storage_errors
from zaqar.transport import acl
from zaqar.transport import utils
from zaqar.transport import validation
from zaqar.transport.wsgi import errors as wsgi_errors
from zaqar.transport.wsgi import utils as wsgi_utils
LOG = logging.getLogger(__name__)
@ -37,6 +37,7 @@ class ItemResource(object):
self._queue_controller = queue_controller
self._message_controller = message_controller
@acl.enforce("queues:get")
def on_get(self, req, resp, project_id, queue_name):
LOG.debug(u'Queue metadata GET - queue: %(queue)s, '
u'project: %(project)s',
@ -58,6 +59,7 @@ class ItemResource(object):
resp.body = utils.to_json(resp_dict)
# status defaults to 200
@acl.enforce("queues:create")
def on_put(self, req, resp, project_id, queue_name):
LOG.debug(u'Queue item PUT - queue: %(queue)s, '
u'project: %(project)s',
@ -93,6 +95,7 @@ class ItemResource(object):
resp.status = falcon.HTTP_201 if created else falcon.HTTP_204
resp.location = req.path
@acl.enforce("queues:delete")
def on_delete(self, req, resp, project_id, queue_name):
LOG.debug(u'Queue item DELETE - queue: %(queue)s, '
u'project: %(project)s',
@ -116,6 +119,7 @@ class CollectionResource(object):
self._queue_controller = queue_controller
self._validate = validate
@acl.enforce("queues:get_all")
def on_get(self, req, resp, project_id):
LOG.debug(u'Queue collection GET - project: %(project)s',
{'project': project_id})

View File

@ -19,6 +19,7 @@ import six
from zaqar.i18n import _
from zaqar.storage import errors as storage_errors
from zaqar.transport import acl
from zaqar.transport import utils
from zaqar.transport import validation
from zaqar.transport.wsgi import errors as wsgi_errors
@ -36,6 +37,7 @@ class ItemResource(object):
self._validate = validate
self._subscription_controller = subscription_controller
@acl.enforce("subscription:get")
def on_get(self, req, resp, project_id, queue_name, subscription_id):
LOG.debug(u'Subscription GET - subscription id: %(subscription_id)s,'
u' project: %(project)s, queue: %(queue)s',
@ -58,6 +60,7 @@ class ItemResource(object):
resp.body = utils.to_json(resp_dict)
# status defaults to 200
@acl.enforce("subscription:delete")
def on_delete(self, req, resp, project_id, queue_name, subscription_id):
LOG.debug(u'Subscription DELETE - '
u'subscription id: %(subscription_id)s,'
@ -76,6 +79,7 @@ class ItemResource(object):
resp.status = falcon.HTTP_204
@acl.enforce("subscription:update")
def on_patch(self, req, resp, project_id, queue_name, subscription_id):
LOG.debug(u'Subscription PATCH - subscription id: %(subscription_id)s,'
u' project: %(project)s, queue: %(queue)s',
@ -117,6 +121,7 @@ class CollectionResource(object):
self._subscription_controller = subscription_controller
self._validate = validate
@acl.enforce("subscription:get_all")
def on_get(self, req, resp, project_id, queue_name):
LOG.debug(u'Subscription collection GET - project: %(project)s, '
u'queue: %(queue)s',
@ -162,6 +167,7 @@ class CollectionResource(object):
resp.body = utils.to_json(response_body)
# status defaults to 200
@acl.enforce("subscription:create")
def on_post(self, req, resp, project_id, queue_name):
LOG.debug(u'Subscription item POST - project: %(project)s, '
u'queue: %(queue)s',