Add support for a "sudo" like mechanism in the API.
Adding an X-Moniker-Tenant-ID request header to API calls will allow admins to `sudo` to another tenant. Fixes bug #1083597 Change-Id: I95f07cc7f017b0ecf9989bf0c4bae8962c039523
This commit is contained in:
parent
49e9691d47
commit
86202458ca
20
doc/source/rest/general.rst
Normal file
20
doc/source/rest/general.rst
Normal file
@ -0,0 +1,20 @@
|
||||
General
|
||||
=======
|
||||
|
||||
Administrative Access
|
||||
---------------------
|
||||
|
||||
Administrative users can "sudo" into another tenant by providing an additional HTTP header: 'X-Moniker-Tenant-ID'
|
||||
|
||||
.. http:get:: /url
|
||||
|
||||
Example HTTP Request using the X-Moniker-Tenant-ID header
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /domains/09494b72b65b42979efb187f65a0553e HTTP/1.1
|
||||
Host: example.com
|
||||
Accept: application/json
|
||||
X-Moniker-Tenant-ID: 12345
|
@ -4,14 +4,14 @@ use = egg:Paste#urlmap
|
||||
|
||||
[composite:osapi_dns_api_v1]
|
||||
use = call:moniker.api.auth:pipeline_factory
|
||||
noauth = noauth osapi_dns_app_v1
|
||||
noauth = noauthcontext osapi_dns_app_v1
|
||||
keystone = authtoken keystonecontext osapi_dns_app_v1
|
||||
|
||||
[app:osapi_dns_app_v1]
|
||||
paste.app_factory = moniker.api.v1:factory
|
||||
|
||||
[filter:noauth]
|
||||
paste.filter_factory = moniker.api.auth:NoAuthMiddleware.factory
|
||||
[filter:noauthcontext]
|
||||
paste.filter_factory = moniker.api.auth:NoAuthContextMiddleware.factory
|
||||
|
||||
[filter:keystonecontext]
|
||||
paste.filter_factory = moniker.api.auth:KeystoneContextMiddleware.factory
|
||||
|
@ -1,6 +1,7 @@
|
||||
{
|
||||
"admin": "role:admin or is_admin:True",
|
||||
"admin_or_owner": "rule:admin or tenant_id:%(tenant_id)s",
|
||||
"owner": "tenant_id:%(tenant_id)s or tenant_id:%(effective_tenant_id)s",
|
||||
"admin_or_owner": "rule:admin or rule:owner",
|
||||
|
||||
"default": "rule:admin_or_owner",
|
||||
|
||||
@ -22,5 +23,6 @@
|
||||
"update_record": "rule:admin_or_owner",
|
||||
"delete_record": "rule:admin_or_owner",
|
||||
|
||||
"use_sudo": "rule:admin",
|
||||
"use_reserved_domain_suffix": "rule:admin"
|
||||
}
|
||||
|
@ -47,10 +47,17 @@ class KeystoneContextMiddleware(wsgi.Middleware):
|
||||
user=headers.get('X-User-ID'),
|
||||
tenant=headers.get('X-Tenant-ID'),
|
||||
roles=roles)
|
||||
|
||||
# Attempt to sudo, if requested.
|
||||
sudo_tenant_id = headers.get('X-Moniker-Tenant-ID', None)
|
||||
|
||||
if sudo_tenant_id:
|
||||
context.sudo(sudo_tenant_id)
|
||||
|
||||
request.environ['context'] = context
|
||||
|
||||
|
||||
class NoAuthMiddleware(wsgi.Middleware):
|
||||
class NoAuthContextMiddleware(wsgi.Middleware):
|
||||
def process_request(self, request):
|
||||
# NOTE(kiall): This makes the assumption that disabling authentication
|
||||
# means you wish to allow full access to everyone.
|
||||
|
@ -19,6 +19,7 @@ from moniker.openstack.common import wsgi
|
||||
from moniker.openstack.common import cfg
|
||||
from moniker import exceptions
|
||||
from moniker import utils
|
||||
from moniker import policy
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -36,6 +37,8 @@ class Service(wsgi.Service):
|
||||
|
||||
LOG.info('Using api-paste-config found at: %s' % config_paths[0])
|
||||
|
||||
policy.init_policy()
|
||||
|
||||
application = deploy.loadapp("config:%s" % config_paths[0],
|
||||
name='osapi_dns')
|
||||
|
||||
|
@ -44,7 +44,6 @@ def create_domain():
|
||||
|
||||
try:
|
||||
domain_schema.validate(values)
|
||||
values['tenant_id'] = context.tenant_id
|
||||
domain = central_api.create_domain(context, values)
|
||||
except exceptions.Forbidden:
|
||||
return flask.Response(status=401)
|
||||
|
@ -195,8 +195,7 @@ class Service(rpc_service.Service):
|
||||
|
||||
# Domain Methods
|
||||
def create_domain(self, context, values):
|
||||
if 'tenant_id' not in values:
|
||||
values['tenant_id'] = None
|
||||
values['tenant_id'] = context.effective_tenant_id
|
||||
|
||||
target = {'tenant_id': values['tenant_id']}
|
||||
policy.check('create_domain', context, target)
|
||||
@ -212,13 +211,13 @@ class Service(rpc_service.Service):
|
||||
return domain
|
||||
|
||||
def get_domains(self, context, criterion=None):
|
||||
policy.check('get_domains', context, {'tenant_id': context.tenant_id})
|
||||
target = {'tenant_id': context.effective_tenant_id}
|
||||
policy.check('get_domains', context, target)
|
||||
|
||||
if criterion is None:
|
||||
criterion = {}
|
||||
|
||||
if context.tenant_id is not None:
|
||||
criterion['tenant_id'] = context.tenant_id
|
||||
criterion['tenant_id'] = context.effective_tenant_id
|
||||
|
||||
return self.storage_conn.get_domains(context, criterion)
|
||||
|
||||
@ -236,6 +235,10 @@ class Service(rpc_service.Service):
|
||||
target = {'domain_id': domain_id, 'tenant_id': domain['tenant_id']}
|
||||
policy.check('update_domain', context, target)
|
||||
|
||||
if 'tenant_id' in values:
|
||||
target = {'domain_id': domain_id, 'tenant_id': values['tenant_id']}
|
||||
policy.check('create_domain', context, target)
|
||||
|
||||
if 'name' in values:
|
||||
# Ensure the domain does not end with a reserved suffix.
|
||||
self._check_reserved_domain_suffixes(context, values['name'])
|
||||
|
@ -15,6 +15,10 @@
|
||||
# under the License.
|
||||
import itertools
|
||||
from moniker.openstack.common import context
|
||||
from moniker.openstack.common import log as logging
|
||||
from moniker import policy
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MonikerContext(context.RequestContext):
|
||||
@ -32,14 +36,31 @@ class MonikerContext(context.RequestContext):
|
||||
|
||||
self.user_id = user
|
||||
self.tenant_id = tenant
|
||||
self.effective_tenant_id = self.tenant_id
|
||||
self.roles = roles
|
||||
|
||||
def sudo(self, tenant_id):
|
||||
# We use exc=None here since the context is built early in the request
|
||||
# lifecycle, outside of our ordinary error handling.
|
||||
# For now, we silently ignore failed sudo requests.
|
||||
allowed_sudo = policy.check('use_sudo', self, {'tenant_id': tenant_id},
|
||||
exc=None)
|
||||
|
||||
if allowed_sudo:
|
||||
LOG.warn('Accepted sudo from user_id %s for tenant_id %s'
|
||||
% (self.user_id, tenant_id))
|
||||
self.effective_tenant_id = tenant_id
|
||||
else:
|
||||
LOG.warn('Rejected sudo from user_id %s for tenant_id %s'
|
||||
% (self.user_id, tenant_id))
|
||||
|
||||
def to_dict(self):
|
||||
d = super(MonikerContext, self).to_dict()
|
||||
|
||||
d.update({
|
||||
'user_id': self.user_id,
|
||||
'tenant_id': self.tenant_id,
|
||||
'effective_tenant_id': self.effective_tenant_id,
|
||||
'roles': self.roles,
|
||||
})
|
||||
|
||||
@ -47,7 +68,7 @@ class MonikerContext(context.RequestContext):
|
||||
|
||||
@classmethod
|
||||
def get_admin_context(cls):
|
||||
return cls(None, tenant=None, is_admin=True)
|
||||
return cls(None, tenant=None, is_admin=True, roles=['admin'])
|
||||
|
||||
@classmethod
|
||||
def get_context_from_function_and_args(cls, function, args, kwargs):
|
||||
|
@ -4,14 +4,14 @@ use = egg:Paste#urlmap
|
||||
|
||||
[composite:osapi_dns_api_v1]
|
||||
use = call:moniker.api.auth:pipeline_factory
|
||||
noauth = noauth osapi_dns_app_v1
|
||||
noauth = noauthcontext osapi_dns_app_v1
|
||||
keystone = authtoken keystonecontext osapi_dns_app_v1
|
||||
|
||||
[app:osapi_dns_app_v1]
|
||||
paste.app_factory = moniker.api.v1:factory
|
||||
|
||||
[filter:noauth]
|
||||
paste.filter_factory = moniker.api.auth:NoAuthMiddleware.factory
|
||||
[filter:noauthcontext]
|
||||
paste.filter_factory = moniker.api.auth:NoAuthContextMiddleware.factory
|
||||
|
||||
[filter:keystonecontext]
|
||||
paste.filter_factory = moniker.api.auth:KeystoneContextMiddleware.factory
|
||||
|
@ -17,14 +17,15 @@ from moniker.tests.test_api import ApiTestCase
|
||||
from moniker.api import auth
|
||||
|
||||
|
||||
class FakeRequest(object):
|
||||
headers = {}
|
||||
environ = {}
|
||||
|
||||
|
||||
class KeystoneContextMiddlewareTest(ApiTestCase):
|
||||
__test__ = True
|
||||
|
||||
def test_process_request(self):
|
||||
class FakeRequest(object):
|
||||
headers = {}
|
||||
environ = {}
|
||||
|
||||
app = {}
|
||||
middleware = auth.KeystoneContextMiddleware(app)
|
||||
|
||||
@ -50,17 +51,44 @@ class KeystoneContextMiddlewareTest(ApiTestCase):
|
||||
self.assertEqual('TenantID', context.tenant_id)
|
||||
self.assertEqual(['admin', 'Member'], context.roles)
|
||||
|
||||
def test_process_request_sudo(self):
|
||||
# Set the policy to accept the authz
|
||||
self.policy({'use_sudo': '@'})
|
||||
|
||||
class NoAuthMiddlewareTest(ApiTestCase):
|
||||
app = {}
|
||||
middleware = auth.KeystoneContextMiddleware(app)
|
||||
|
||||
request = FakeRequest()
|
||||
|
||||
request.headers = {
|
||||
'X-Auth-Token': 'AuthToken',
|
||||
'X-User-ID': 'UserID',
|
||||
'X-Tenant-ID': 'TenantID',
|
||||
'X-Roles': 'admin,Member',
|
||||
'X-Moniker-Tenant-ID': 'SudoTenantID'
|
||||
}
|
||||
|
||||
# Process the request
|
||||
middleware.process_request(request)
|
||||
|
||||
self.assertIn('context', request.environ)
|
||||
|
||||
context = request.environ['context']
|
||||
|
||||
self.assertFalse(context.is_admin)
|
||||
self.assertEqual('AuthToken', context.auth_tok)
|
||||
self.assertEqual('UserID', context.user_id)
|
||||
self.assertEqual('TenantID', context.tenant_id)
|
||||
self.assertEqual('SudoTenantID', context.effective_tenant_id)
|
||||
self.assertEqual(['admin', 'Member'], context.roles)
|
||||
|
||||
|
||||
class NoAuthContextMiddlewareTest(ApiTestCase):
|
||||
__test__ = True
|
||||
|
||||
def test_process_request(self):
|
||||
class FakeRequest(object):
|
||||
headers = {}
|
||||
environ = {}
|
||||
|
||||
app = {}
|
||||
middleware = auth.NoAuthMiddleware(app)
|
||||
middleware = auth.NoAuthContextMiddleware(app)
|
||||
|
||||
request = FakeRequest()
|
||||
|
||||
|
@ -15,7 +15,7 @@
|
||||
# under the License.
|
||||
from moniker.openstack.common import log as logging
|
||||
from moniker.api.v1 import factory
|
||||
from moniker.api.auth import NoAuthMiddleware
|
||||
from moniker.api.auth import NoAuthContextMiddleware
|
||||
from moniker.tests.test_api import ApiTestCase
|
||||
|
||||
|
||||
@ -32,7 +32,7 @@ class ApiV1Test(ApiTestCase):
|
||||
self.app = factory({})
|
||||
|
||||
# Inject the NoAuth middleware
|
||||
self.app.wsgi_app = NoAuthMiddleware(self.app.wsgi_app)
|
||||
self.app.wsgi_app = NoAuthContextMiddleware(self.app.wsgi_app)
|
||||
|
||||
# Obtain a test client
|
||||
self.client = self.app.test_client()
|
||||
|
39
moniker/tests/test_context.py
Normal file
39
moniker/tests/test_context.py
Normal file
@ -0,0 +1,39 @@
|
||||
# Copyright 2012 Managed I.T.
|
||||
#
|
||||
# Author: Kiall Mac Innes <kiall@managedit.ie>
|
||||
#
|
||||
# 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 moniker.tests import TestCase
|
||||
from moniker import context
|
||||
|
||||
|
||||
class TestMonikerContext(TestCase):
|
||||
def test_sudo(self):
|
||||
# Set the policy to accept the authz
|
||||
self.policy({'use_sudo': '@'})
|
||||
|
||||
ctxt = context.MonikerContext(tenant='original')
|
||||
ctxt.sudo('effective')
|
||||
|
||||
self.assertEqual('original', ctxt.tenant_id)
|
||||
self.assertEqual('effective', ctxt.effective_tenant_id)
|
||||
|
||||
def test_sudo_fail(self):
|
||||
# Set the policy to deny the authz
|
||||
self.policy({'use_sudo': '!'})
|
||||
|
||||
ctxt = context.MonikerContext(tenant='original')
|
||||
ctxt.sudo('effective')
|
||||
|
||||
self.assertEqual('original', ctxt.tenant_id)
|
||||
self.assertEqual('original', ctxt.effective_tenant_id)
|
Loading…
Reference in New Issue
Block a user