Add user and project domains to ironic context

This change also removes most of the logic from ironic's
RequestContext to reuse the oslo_context as much as possible.
Usage of domain_id and domain_name in policy files is deprecated
and their support will be removed in the Pike release. domain_id
field was removed from the context class completely, domain_name
value now mathces the oslo_context expectations.

ContextHook is changed too so as not to duplicate from_environ
functional from oslo_context.

to_dict method left as is, so that we don't break an older service
receiving the context over RPC. It will be changed in Pike release
to reuse the base oslo_context class' to_dict.

Closes-Bug: #1602081
Closes-Bug: #1627173
Closes-Bug: #1641972
Co-Authored-By: Jamie Lennox <jamielennox@gmail.com>
Co-Authored-By: Devananda van der Veen <devananda.vdv@gmail.com>
Change-Id: I9afe89bc6aee282ee4b7579d661e3fa83cc0ce84
This commit is contained in:
Vladyslav Drok 2016-03-21 17:46:55 +02:00
parent 292b4295d9
commit 3eba764be3
10 changed files with 221 additions and 235 deletions

View File

@ -2,12 +2,12 @@
"admin_api": "role:admin or role:administrator"
# Internal flag for public API routes
"public_api": "is_public_api:True"
# Show or mask secrets within driver_info in API responses
# Show or mask secrets within node driver information in API responses
"show_password": "!"
# Show or mask secrets within instance_info in API responses
# Show or mask secrets within instance information in API responses
"show_instance_secrets": "!"
# May be used to restrict access to specific tenants
"is_member": "tenant:demo or tenant:baremetal"
# May be used to restrict access to specific projects
"is_member": "(project_domain_id:default or project_domain_id:None) and (project_name:demo or project_name:baremetal)"
# Read-only API access
"is_observer": "rule:is_member and (role:observer or role:baremetal_observer)"
# Full read/write API access

View File

@ -14,15 +14,55 @@
# License for the specific language governing permissions and limitations
# under the License.
import re
from oslo_config import cfg
from oslo_log import log
from pecan import hooks
import six
from six.moves import http_client
from ironic.common import context
from ironic.common.i18n import _LW
from ironic.common import policy
from ironic.conductor import rpcapi
from ironic.db import api as dbapi
LOG = log.getLogger(__name__)
CHECKED_DEPRECATED_POLICY_ARGS = False
def policy_deprecation_check():
global CHECKED_DEPRECATED_POLICY_ARGS
if not CHECKED_DEPRECATED_POLICY_ARGS:
enforcer = policy.get_enforcer()
substitution_dict = {
'user': 'user_id',
'domain_id': 'user_domain_id',
'domain_name': 'user_domain_id',
'tenant': 'project_name',
}
policy_rules = enforcer.file_rules.values()
for rule in policy_rules:
str_rule = six.text_type(rule)
for deprecated, replacement in substitution_dict.items():
if re.search(r'\b%s\b' % deprecated, str_rule):
LOG.warning(_LW(
"Deprecated argument %(deprecated)s is used in policy "
"file rule (%(rule)s), please use %(replacement)s "
"argument instead. The possibility to use deprecated "
"arguments will be removed in the Pike release."),
{'deprecated': deprecated, 'replacement': replacement,
'rule': str_rule})
if deprecated == 'domain_name':
LOG.warning(_LW(
"Please note that user_domain_id is an ID of the "
"user domain, while the deprecated domain_name is "
"its name. The policy rule has to be updated "
"accordingly."))
CHECKED_DEPRECATED_POLICY_ARGS = True
class ConfigHook(hooks.PecanHook):
"""Attach the config object to the request so controllers can get to it."""
@ -39,52 +79,25 @@ class DBHook(hooks.PecanHook):
class ContextHook(hooks.PecanHook):
"""Configures a request context and attaches it to the request.
The following HTTP request headers are used:
X-User-Id or X-User:
Used for context.user_id.
X-Tenant-Id or X-Tenant:
Used for context.tenant.
X-Auth-Token:
Used for context.auth_token.
X-Roles:
Used for setting context.is_admin flag to either True or False.
The flag is set to True, if X-Roles contains either an administrator
or admin substring. Otherwise it is set to False.
"""
"""Configures a request context and attaches it to the request."""
def __init__(self, public_api_routes):
self.public_api_routes = public_api_routes
super(ContextHook, self).__init__()
def before(self, state):
headers = state.request.headers
# Do not pass any token with context for noauth mode
auth_token = (None if cfg.CONF.auth_strategy == 'noauth' else
headers.get('X-Auth-Token'))
is_public_api = state.request.environ.get('is_public_api', False)
ctx = context.RequestContext.from_environ(state.request.environ,
is_public_api=is_public_api)
# Do not pass any token with context for noauth mode
if cfg.CONF.auth_strategy == 'noauth':
ctx.auth_token = None
creds = {
'user': headers.get('X-User') or headers.get('X-User-Id'),
'tenant': headers.get('X-Tenant') or headers.get('X-Tenant-Id'),
'domain_id': headers.get('X-User-Domain-Id'),
'domain_name': headers.get('X-User-Domain-Name'),
'auth_token': auth_token,
'roles': headers.get('X-Roles', '').split(','),
'is_public_api': is_public_api,
}
creds = ctx.to_policy_values()
is_admin = policy.check('is_admin', creds, creds)
ctx.is_admin = is_admin
policy_deprecation_check()
state.request.context = context.RequestContext(
is_admin=is_admin,
**creds)
state.request.context = ctx
def after(self, state):
if state.request.context == {}:

View File

@ -13,49 +13,40 @@
# under the License.
from oslo_context import context
from oslo_log import log
LOG = log.getLogger(__name__)
class RequestContext(context.RequestContext):
"""Extends security contexts from the oslo.context library."""
def __init__(self, auth_token=None, domain_id=None, domain_name=None,
user=None, tenant=None, is_admin=False, is_public_api=False,
read_only=False, show_deleted=False, request_id=None,
roles=None, overwrite=True):
def __init__(self, is_public_api=False, **kwargs):
"""Initialize the RequestContext
:param auth_token: The authentication token of the current request.
:param domain_id: The ID of the domain.
:param domain_name: The name of the domain.
:param user: The name of the user.
:param tenant: The name of the tenant.
:param is_admin: Indicates if the request context is an administrator
context.
:param is_public_api: Specifies whether the request should be processed
without authentication.
:param read_only: unused flag for Ironic.
:param show_deleted: unused flag for Ironic.
:param request_id: The UUID of the request.
:param roles: List of user's roles if any.
:param overwrite: Set to False to ensure that the greenthread local
copy of the index is not overwritten.
without authentication.
:param kwargs: additional arguments passed to oslo.context.
"""
super(RequestContext, self).__init__(auth_token=auth_token,
user=user, tenant=tenant,
is_admin=is_admin,
read_only=read_only,
show_deleted=show_deleted,
request_id=request_id,
overwrite=overwrite)
super(RequestContext, self).__init__(**kwargs)
self.is_public_api = is_public_api
self.domain_id = domain_id
self.domain_name = domain_name
# NOTE(dims): roles was added in context.RequestContext recently.
# we should pass roles in __init__ above instead of setting the
# value here once the minimum version of oslo.context is updated.
self.roles = roles or []
def to_policy_values(self):
policy_values = super(RequestContext, self).to_policy_values()
# TODO(vdrok): remove all of these apart from is_public_api and
# project_name after deprecation period
policy_values.update({
'user': self.user,
'domain_id': self.user_domain,
'domain_name': self.user_domain_name,
'tenant': self.tenant,
'project_name': self.project_name,
'is_public_api': self.is_public_api,
})
return policy_values
def to_dict(self):
# TODO(vdrok): reuse the base class to_dict in Pike
return {'auth_token': self.auth_token,
'user': self.user,
'tenant': self.tenant,
@ -63,16 +54,18 @@ class RequestContext(context.RequestContext):
'read_only': self.read_only,
'show_deleted': self.show_deleted,
'request_id': self.request_id,
'domain_id': self.domain_id,
'domain_id': self.user_domain,
'roles': self.roles,
'domain_name': self.domain_name,
'domain_name': self.user_domain_name,
'is_public_api': self.is_public_api}
@classmethod
def from_dict(cls, values):
values.pop('user', None)
values.pop('tenant', None)
return cls(**values)
def from_dict(cls, values, **kwargs):
kwargs.setdefault('is_public_api', values.get('is_public_api', False))
if 'domain_id' in values:
kwargs.setdefault('user_domain', values['domain_id'])
return super(RequestContext, RequestContext).from_dict(values,
**kwargs)
def ensure_thread_contain_context(self):
"""Ensure threading contains context
@ -90,7 +83,7 @@ class RequestContext(context.RequestContext):
def get_admin_context():
"""Create an administrator context."""
context = RequestContext(None,
context = RequestContext(auth_token=None,
tenant=None,
is_admin=True,
overwrite=False)

View File

@ -55,8 +55,8 @@ default_policies = [
description='Show or mask secrets within instance information in API responses'), # noqa
# Roles likely to be overridden by operator
policy.RuleDefault('is_member',
'tenant:demo or tenant:baremetal',
description='May be used to restrict access to specific tenants'), # noqa
'(project_domain_id:default or project_domain_id:None) and (project_name:demo or project_name:baremetal)', # noqa
description='May be used to restrict access to specific projects'), # noqa
policy.RuleDefault('is_observer',
'rule:is_member and (role:observer or role:baremetal_observer)', # noqa
description='Read-only API access'),

View File

@ -25,6 +25,8 @@ from six.moves import http_client
from ironic.api.controllers import root
from ironic.api import hooks
from ironic.common import context
from ironic.common import policy
from ironic.tests import base as tests_base
from ironic.tests.unit.api import base
@ -42,24 +44,6 @@ class FakeRequestState(object):
self.request = FakeRequest(headers, context, environ)
self.response = FakeRequest(headers, context, environ)
def set_context(self):
headers = self.request.headers
creds = {
'user': headers.get('X-User') or headers.get('X-User-Id'),
'tenant': headers.get('X-Tenant') or headers.get('X-Tenant-Id'),
'domain_id': headers.get('X-User-Domain-Id'),
'domain_name': headers.get('X-User-Domain-Name'),
'auth_token': headers.get('X-Auth-Token'),
'roles': headers.get('X-Roles', '').split(','),
}
is_admin = ('admin' in creds['roles'] or
'administrator' in creds['roles'])
is_public_api = self.request.environ.get('is_public_api', False)
self.request.context = context.RequestContext(
is_admin=is_admin, is_public_api=is_public_api,
**creds)
def fake_headers(admin=False):
headers = {
@ -99,6 +83,14 @@ def fake_headers(admin=False):
return headers
def headers_to_environ(headers, **kwargs):
environ = {}
for k, v in headers.items():
environ['HTTP_%s' % k.replace('-', '_').upper()] = v
environ.update(kwargs)
return environ
class TestNoExceptionTracebackHook(base.BaseApiTest):
TRACE = [u'Traceback (most recent call last):',
@ -212,88 +204,52 @@ class TestNoExceptionTracebackHook(base.BaseApiTest):
class TestContextHook(base.BaseApiTest):
@mock.patch.object(context, 'RequestContext')
def test_context_hook_not_admin(self, mock_ctx):
cfg.CONF.set_override('auth_strategy', 'keystone')
headers = fake_headers(admin=False)
reqstate = FakeRequestState(headers=headers)
context_hook = hooks.ContextHook(None)
context_hook.before(reqstate)
mock_ctx.assert_called_with(
auth_token=headers['X-Auth-Token'],
user=headers['X-User'],
tenant=headers['X-Tenant'],
domain_id=headers['X-User-Domain-Id'],
domain_name=headers['X-User-Domain-Name'],
is_public_api=False,
is_admin=False,
roles=headers['X-Roles'].split(','))
@mock.patch.object(context, 'RequestContext')
def test_context_hook_admin(self, mock_ctx):
cfg.CONF.set_override('auth_strategy', 'keystone')
headers = fake_headers(admin=True)
reqstate = FakeRequestState(headers=headers)
@mock.patch.object(policy, 'check')
def _test_context_hook(self, mock_policy, mock_ctx, is_admin=False,
is_public_api=False, auth_strategy='keystone',
request_id=None):
cfg.CONF.set_override('auth_strategy', auth_strategy)
headers = fake_headers(admin=is_admin)
environ = headers_to_environ(headers, is_public_api=is_public_api)
reqstate = FakeRequestState(headers=headers, environ=environ)
context_hook = hooks.ContextHook(None)
ctx = mock.Mock()
if request_id:
ctx.request_id = request_id
mock_ctx.from_environ.return_value = ctx
policy_dict = {'user_id': 'foo'} # Lots of other values here
ctx.to_policy_values.return_value = policy_dict
mock_policy.return_value = is_admin
context_hook.before(reqstate)
mock_ctx.assert_called_with(
auth_token=headers['X-Auth-Token'],
user=headers['X-User'],
tenant=headers['X-Tenant'],
domain_id=headers['X-User-Domain-Id'],
domain_name=headers['X-User-Domain-Name'],
is_public_api=False,
is_admin=True,
roles=headers['X-Roles'].split(','))
creds_dict = {'is_public_api': is_public_api}
mock_ctx.from_environ.assert_called_once_with(environ, **creds_dict)
mock_policy.assert_called_once_with('is_admin', policy_dict,
policy_dict)
self.assertIs(is_admin, ctx.is_admin)
if auth_strategy == 'noauth':
self.assertIsNone(ctx.auth_token)
return context_hook, reqstate
@mock.patch.object(context, 'RequestContext')
def test_context_hook_public_api(self, mock_ctx):
cfg.CONF.set_override('auth_strategy', 'keystone')
headers = fake_headers(admin=True)
env = {'is_public_api': True}
reqstate = FakeRequestState(headers=headers, environ=env)
context_hook = hooks.ContextHook(None)
context_hook.before(reqstate)
mock_ctx.assert_called_with(
auth_token=headers['X-Auth-Token'],
user=headers['X-User'],
tenant=headers['X-Tenant'],
domain_id=headers['X-User-Domain-Id'],
domain_name=headers['X-User-Domain-Name'],
is_public_api=True,
is_admin=True,
roles=headers['X-Roles'].split(','))
def test_context_hook_not_admin(self):
self._test_context_hook()
@mock.patch.object(context, 'RequestContext')
def test_context_hook_noauth_token_removed(self, mock_ctx):
cfg.CONF.set_override('auth_strategy', 'noauth')
headers = fake_headers(admin=False)
reqstate = FakeRequestState(headers=headers)
context_hook = hooks.ContextHook(None)
context_hook.before(reqstate)
mock_ctx.assert_called_with(
auth_token=None,
user=headers['X-User'],
tenant=headers['X-Tenant'],
domain_id=headers['X-User-Domain-Id'],
domain_name=headers['X-User-Domain-Name'],
is_public_api=False,
is_admin=False,
roles=headers['X-Roles'].split(','))
def test_context_hook_admin(self):
self._test_context_hook(is_admin=True)
@mock.patch.object(context, 'RequestContext')
def test_context_hook_after_add_request_id(self, mock_ctx):
headers = fake_headers(admin=True)
reqstate = FakeRequestState(headers=headers)
reqstate.set_context()
reqstate.request.context.request_id = 'fake-id'
context_hook = hooks.ContextHook(None)
def test_context_hook_public_api(self):
self._test_context_hook(is_admin=True, is_public_api=True)
def test_context_hook_noauth_token_removed(self):
self._test_context_hook(auth_strategy='noauth')
def test_context_hook_after_add_request_id(self):
context_hook, reqstate = self._test_context_hook(is_admin=True,
request_id='fake-id')
context_hook.after(reqstate)
self.assertIn('Openstack-Request-Id',
reqstate.response.headers)
self.assertEqual(
'fake-id',
reqstate.response.headers['Openstack-Request-Id'])
self.assertEqual('fake-id',
reqstate.response.headers['Openstack-Request-Id'])
def test_context_hook_after_miss_context(self):
response = self.get_json('/bad/path',
@ -302,6 +258,19 @@ class TestContextHook(base.BaseApiTest):
response.headers)
class TestPolicyDeprecation(tests_base.TestCase):
@mock.patch.object(hooks, 'CHECKED_DEPRECATED_POLICY_ARGS', False)
@mock.patch.object(hooks.LOG, 'warning')
@mock.patch.object(policy, 'get_enforcer')
def test_policy_deprecation_check(self, enforcer_mock, warning_mock):
rules = {'is_member': 'project_name:demo or tenant:baremetal',
'is_default_project_domain': 'project_domain_id:default'}
enforcer_mock.return_value = mock.Mock(file_rules=rules, autospec=True)
hooks.policy_deprecation_check()
self.assertEqual(1, warning_mock.call_count)
class TestPublicUrlHook(base.BaseApiTest):
def test_before_host_url(self):

View File

@ -20,66 +20,33 @@ from ironic.tests import base as tests_base
class RequestContextTestCase(tests_base.TestCase):
def setUp(self):
super(RequestContextTestCase, self).setUp()
@mock.patch.object(oslo_context.RequestContext, "__init__")
def test_create_context(self, context_mock):
test_context = context.RequestContext()
context_mock.assert_called_once_with(
auth_token=None, user=None, tenant=None, is_admin=False,
read_only=False, show_deleted=False, request_id=None,
overwrite=True)
self.assertFalse(test_context.is_public_api)
self.assertIsNone(test_context.domain_id)
self.assertIsNone(test_context.domain_name)
self.assertEqual([], test_context.roles)
def test_from_dict(self):
dict = {
"user": "user1",
"tenant": "tenant1",
"is_public_api": True,
"domain_id": "domain_id1",
"domain_name": "domain_name1",
"roles": None
}
ctx = context.RequestContext.from_dict(dict)
self.assertIsNone(ctx.user)
self.assertIsNone(ctx.tenant)
self.assertTrue(ctx.is_public_api)
self.assertEqual("domain_id1", ctx.domain_id)
self.assertEqual("domain_name1", ctx.domain_name)
self.assertEqual([], ctx.roles)
def test_to_dict(self):
values = {
self.context_dict = {
'auth_token': 'auth_token1',
"user": "user1",
"tenant": "tenant1",
"project_name": "somename",
'is_admin': True,
'read_only': True,
'show_deleted': True,
'request_id': 'id1',
"is_public_api": True,
"domain_id": "domain_id1",
"domain_name": "domain_name1",
"domain": "domain_id2",
"user_domain": "domain_id3",
"user_domain_name": "TreeDomain",
"project_domain": "domain_id4",
"roles": None,
"overwrite": True
}
ctx = context.RequestContext(**values)
ctx_dict = ctx.to_dict()
self.assertIn('auth_token', ctx_dict)
self.assertIn('user', ctx_dict)
self.assertIn('tenant', ctx_dict)
self.assertIn('is_admin', ctx_dict)
self.assertIn('read_only', ctx_dict)
self.assertIn('show_deleted', ctx_dict)
self.assertIn('request_id', ctx_dict)
self.assertIn('domain_id', ctx_dict)
self.assertIn('roles', ctx_dict)
self.assertIn('domain_name', ctx_dict)
self.assertIn('is_public_api', ctx_dict)
self.assertNotIn('overwrite', ctx_dict)
@mock.patch.object(oslo_context.RequestContext, "__init__")
def test_create_context(self, context_mock):
test_context = context.RequestContext()
context_mock.assert_called_once_with()
self.assertFalse(test_context.is_public_api)
def test_to_dict(self):
ctx = context.RequestContext(**self.context_dict)
ctx_dict = ctx.to_dict()
self.assertEqual('auth_token1', ctx_dict['auth_token'])
self.assertEqual('user1', ctx_dict['user'])
self.assertEqual('tenant1', ctx_dict['tenant'])
@ -88,8 +55,33 @@ class RequestContextTestCase(tests_base.TestCase):
self.assertTrue(ctx_dict['show_deleted'])
self.assertEqual('id1', ctx_dict['request_id'])
self.assertTrue(ctx_dict['is_public_api'])
self.assertEqual('domain_id1', ctx_dict['domain_id'])
self.assertEqual('domain_name1', ctx_dict['domain_name'])
self.assertEqual('domain_id3', ctx_dict['domain_id'])
self.assertEqual('TreeDomain', ctx_dict['domain_name'])
self.assertEqual([], ctx_dict['roles'])
self.assertNotIn('overwrite', ctx_dict)
def test_from_dict(self):
test_context = context.RequestContext.from_dict(
{'project_name': 'demo', 'is_public_api': True,
'domain_id': 'meow'})
self.assertEqual('demo', test_context.project_name)
self.assertEqual('meow', test_context.user_domain)
self.assertTrue(test_context.is_public_api)
def test_to_policy_values(self):
ctx = context.RequestContext(**self.context_dict)
ctx_dict = ctx.to_policy_values()
self.assertEqual('user1', ctx_dict['user'])
self.assertEqual('user1', ctx_dict['user_id'])
self.assertEqual('tenant1', ctx_dict['tenant'])
self.assertEqual('tenant1', ctx_dict['project_id'])
self.assertEqual('somename', ctx_dict['project_name'])
self.assertTrue(ctx_dict['is_public_api'])
self.assertTrue(ctx_dict['is_admin_project'])
self.assertEqual('domain_id3', ctx_dict['domain_id'])
self.assertEqual('TreeDomain', ctx_dict['domain_name'])
self.assertEqual('domain_id3', ctx_dict['user_domain_id'])
self.assertEqual('domain_id4', ctx_dict['project_domain_id'])
self.assertEqual([], ctx_dict['roles'])
def test_get_admin_context(self):

View File

@ -42,15 +42,28 @@ class PolicyInCodeTestCase(base.TestCase):
self.assertTrue(policy.check('public_api', creds, creds))
def test_show_password(self):
creds = {'roles': [u'admin'], 'tenant': 'admin'}
self.assertTrue(policy.check('show_password', creds, creds))
creds = {'roles': [u'admin'], 'project_name': 'admin',
'project_domain_id': 'default'}
self.assertFalse(policy.check('show_password', creds, creds))
def test_is_member(self):
creds = [{'project_name': 'demo', 'project_domain_id': 'default'},
{'project_name': 'baremetal', 'project_domain_id': 'default'},
{'project_name': 'demo', 'project_domain_id': None},
{'project_name': 'baremetal', 'project_domain_id': None}]
for c in creds:
self.assertTrue(policy.check('is_member', c, c))
c = {'project_name': 'demo1', 'project_domain_id': 'default2'}
self.assertFalse(policy.check('is_member', c, c))
def test_node_get(self):
creds = {'roles': ['baremetal_observer'], 'tenant': 'demo'}
creds = {'roles': ['baremetal_observer'], 'project_name': 'demo',
'project_domain_id': 'default'}
self.assertTrue(policy.check('baremetal:node:get', creds, creds))
def test_node_create(self):
creds = {'roles': ['baremetal_admin'], 'tenant': 'demo'}
creds = {'roles': ['baremetal_admin'], 'project_name': 'demo',
'project_domain_id': 'default'}
self.assertTrue(policy.check('baremetal:node:create', creds, creds))

View File

@ -179,11 +179,7 @@ class TestRequestContextSerializer(base.TestCase):
self.assertEqual(self.context.to_dict(), serialize_values)
def test_deserialize_context(self):
self.context.user = 'fake-user'
self.context.tenant = 'fake-tenant'
serialize_values = self.context.to_dict()
new_context = self.serializer.deserialize_context(serialize_values)
# Ironic RequestContext from_dict will pop 'user' and 'tenant' and
# initialize to None.
self.assertIsNone(new_context.user)
self.assertIsNone(new_context.tenant)
self.assertEqual(serialize_values, new_context.to_dict())
self.assertIsInstance(new_context, ironic_context.RequestContext)

View File

@ -209,8 +209,8 @@ class _TestObject(object):
base.IronicObject.obj_class_from_name, 'foo', '1.0')
def test_with_alternate_context(self):
ctxt1 = context.RequestContext('foo', 'foo')
ctxt2 = context.RequestContext('bar', tenant='alternate')
ctxt1 = context.RequestContext(auth_token='foo', tenant='foo')
ctxt2 = context.RequestContext(auth_token='bar', tenant='alternate')
obj = MyObj.query(ctxt1)
obj.update_test(ctxt2)
self.assertEqual('alternate-context', obj.bar)

View File

@ -0,0 +1,10 @@
---
deprecations:
- |
Usage of the following values was deprecated in the policy files:
- domain_id and domain_name - user_domain_id should be used
instead of those (note - user_domain is an ID of the domain,
not its name);
- tenant - project_name should be used instead;
- user - user_id should be used instead.