Auto-populate lessee for deployments

Adds a configuration option and capability to automatically
record the lessee for a deployment based upon the original
auth_token information provided in the request context.

Additional token information is now shared through the context
which is extended in the same fashion as most other projects
saving request token information to their RequestContext,
instead of triggering excess API calls in the background to
Keystone to try and figure out requestor's information.

Change-Id: I42a2ceb9d2e7dfdc575eb37ed773a1bc682cec23
This commit is contained in:
Julia Kreger 2021-11-19 12:54:50 -08:00
parent 2e94aa4241
commit c3f397149a
13 changed files with 194 additions and 13 deletions

View File

@ -88,14 +88,16 @@ class ContextHook(hooks.PecanHook):
def before(self, state):
is_public_api = state.request.environ.get('is_public_api', False)
auth_token_info = state.request.environ.get('keystone.token_info')
# set the global_request_id if we have an inbound request id
gr_id = state.request.headers.get(INBOUND_HEADER, "")
if re.match(ID_FORMAT, gr_id):
state.request.environ[GLOBAL_REQ_ID] = gr_id
ctx = context.RequestContext.from_environ(state.request.environ,
is_public_api=is_public_api)
ctx = context.RequestContext.from_environ(
state.request.environ,
is_public_api=is_public_api,
auth_token_info=auth_token_info)
# Do not pass any token with context for noauth mode
if cfg.CONF.auth_strategy != 'keystone':
ctx.auth_token = None

View File

@ -18,15 +18,25 @@ from oslo_context import context
class RequestContext(context.RequestContext):
"""Extends security contexts from the oslo.context library."""
def __init__(self, is_public_api=False, **kwargs):
# NOTE(TheJulia): This is a flag used by oslo.context which allows us to
# pass in a list of keys to preserve when calling from_dict() on the
# RequestContext class.
FROM_DICT_EXTRA_KEYS = ['auth_token_info']
def __init__(self, is_public_api=False, auth_token_info=None, **kwargs):
"""Initialize the RequestContext
:param is_public_api: Specifies whether the request should be processed
without authentication.
:param auth_token_info: Parameter to house auth token validation
response data such as the user auth token's project id as opposed
to the bearer token used. This allows for easy access to attributes
for the end user when actions are taken on behalf of a user.
:param kwargs: additional arguments passed to oslo.context.
"""
super(RequestContext, self).__init__(**kwargs)
self.is_public_api = is_public_api
self.auth_token_info = auth_token_info
def to_policy_values(self):
policy_values = super(RequestContext, self).to_policy_values()
@ -48,6 +58,32 @@ class RequestContext(context.RequestContext):
return
self.update_store()
@classmethod
def from_environ(cls, environ, **kwargs):
"""Load a context object from a request environment.
If keyword arguments are provided then they override the values in the
request environment, injecting the kwarg arguments used by ironic, as
unknown values are filtered out from the final context object in
the base oslo.context library.
:param environ: The environment dictionary associated with a request.
:type environ: dict
"""
context = super().from_environ(environ)
context.is_public_api = environ.get('is_public_api', False)
context.auth_token_info = environ.get('keystone.token_info')
return context
def to_dict(self):
"""Return a dictionary of context attributes."""
# The parent class in oslo.context provides the core standard
# fields, but does not go beyond that. This preserves auth_token_info
# for serialization and ultimately things like RPC transport.
cdict = super().to_dict()
cdict['auth_token_info'] = self.auth_token_info
return cdict
def get_admin_context():
"""Create an administrator context."""

View File

@ -83,6 +83,26 @@ def start_deploy(task, manager, configdrive=None, event='deploy',
instance_info.pop('kernel', None)
instance_info.pop('ramdisk', None)
node.instance_info = instance_info
elif CONF.conductor.automatic_lessee:
# This should only be on deploy...
project = utils.get_token_project_from_request(task.context)
if (project and node.lessee is None):
LOG.debug('Adding lessee $(project)s to node %(uuid)s.',
{'project': project,
'uuid': node.uuid})
node.set_driver_internal_info('automatic_lessee', True)
node.lessee = project
elif project and node.lessee is not None:
# Since the model is a bit of a matrix and we're largely
# just empowering operators, lets at least log a warning
# since they may need to remedy something here. Or maybe
# not.
LOG.warning('Could not automatically save lessee '
'$(project)s to node %(uuid)s. Node already '
'has a defined lessee of %(lessee)s.',
{'project': project,
'uuid': node.uuid,
'lessee': node.lessee})
# Infer the image type to make sure the deploy driver
# validates only the necessary variables for different

View File

@ -1097,6 +1097,10 @@ class ConductorManager(base_manager.BaseConductorManager):
node.del_driver_internal_info('root_uuid_or_disk_id')
node.del_driver_internal_info('is_whole_disk_image')
node.del_driver_internal_info('deploy_boot_mode')
if node.driver_internal_info.get('automatic_lessee'):
# Remove lessee, as it was automatically added
node.lessee = None
node.del_driver_internal_info('automatic_lessee')
network.remove_vifs_from_node(task)
node.save()
if node.allocation_id:

View File

@ -547,7 +547,6 @@ class ConductorAPI(object):
if deploy_steps:
version = '1.52'
new_kws['deploy_steps'] = deploy_steps
cctxt = self._prepare_call(topic=topic, version=version)
return cctxt.call(context, 'do_node_deploy', node_id=node_id,
rebuild=rebuild, configdrive=configdrive, **new_kws)

View File

@ -1692,3 +1692,27 @@ def exclude_current_conductor(current_conductor, offline_conductors):
current_conductor)
return [x for x in offline_conductors if x != current_conductor]
def get_token_project_from_request(ctx):
"""Identifies the request originator project via keystone token details.
This method evaluates the ``auth_token_info`` field, which is used to
pass information returned from keystone as a token's
verification. This information is based upon the actual, original
requestor context provided ``auth_token``.
When a service, such as Nova proxies a request, the request provided
auth token value is intended to be from the original user.
:returns: The project ID value.
"""
try:
if ctx.auth_token_info:
project = ctx.auth_token_info.get('token', {}).get('project', {})
if project:
return project.get('id')
except AttributeError:
LOG.warning('Attempted to identify requestor project ID value, '
'however we were unable to do so. Possible older API?')

View File

@ -348,6 +348,16 @@ opts = [
'be specified multiple times to define priorities '
'for multiple steps. If set to 0, this specific step '
'will not run during verification. ')),
cfg.BoolOpt('automatic_lessee',
default=False,
mutable=True,
help=_('If the conductor should record the Project ID '
'indicated by Keystone for a requested deployment. '
'Allows rights to be granted to directly access the '
'deployed node as a lessee within the RBAC security '
'model. The conductor does *not* record this value '
'otherwise, and this information is not backfilled '
'for prior instances which have been deployed.')),
]

View File

@ -208,10 +208,13 @@ class TestContextHook(base.BaseApiTest):
@mock.patch.object(policy, 'check', autospec=True)
def _test_context_hook(self, mock_policy, mock_ctx, is_admin=False,
is_public_api=False, auth_strategy='keystone',
request_id=None):
request_id=None, auth_token_info=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)
environ = headers_to_environ(
headers,
**{'is_public_api': is_public_api,
'keystone.token_info': auth_token_info})
reqstate = FakeRequestState(headers=headers, environ=environ)
context_hook = hooks.ContextHook(None)
ctx = mock.Mock()
@ -223,10 +226,14 @@ class TestContextHook(base.BaseApiTest):
mock_policy.return_value = False
context_hook.before(reqstate)
creds_dict = {'is_public_api': is_public_api}
mock_ctx.from_environ.assert_called_once_with(environ, **creds_dict)
if auth_token_info:
creds_dict['auth_token_info'] = auth_token_info
mock_ctx.from_environ.assert_called_once_with(
environ, **creds_dict)
mock_policy.assert_not_called()
if auth_strategy == 'noauth':
self.assertIsNone(ctx.auth_token)
mock_policy.assert_not_called()
return context_hook, reqstate
def test_context_hook_not_admin(self):
@ -250,6 +257,9 @@ class TestContextHook(base.BaseApiTest):
self.assertNotIn('Openstack-Request-Id',
response.headers)
def test_context_hook_auth_token_info(self):
self._test_context_hook(auth_token_info='data-dict')
class TestPolicyDeprecation(tests_base.TestCase):

View File

@ -37,6 +37,9 @@ class RequestContextTestCase(tests_base.TestCase):
"roles": None,
"overwrite": True
}
self.environ = {
'keystone.token_info': {'key': 'value'},
}
@mock.patch.object(oslo_context.RequestContext, "__init__", autospec=True)
def test_create_context(self, context_mock):
@ -63,3 +66,26 @@ class RequestContextTestCase(tests_base.TestCase):
context_get_mock.return_value = self.context
self.context.ensure_thread_contain_context()
self.assertFalse(self.context.update_store.called)
def test_create_context_with_environ(self):
test_context = context.RequestContext().from_environ(self.environ)
self.assertEqual({'key': 'value'}, test_context.auth_token_info)
def test_to_dict_get_auth_token_info(self):
test_context = context.RequestContext().from_environ(self.environ)
self.assertEqual({'key': 'value'}, test_context.auth_token_info)
result = test_context.to_dict()
self.assertIn('auth_token_info', result)
expected = {'key': 'value'}
self.assertDictEqual(expected, result['auth_token_info'])
def test_from_dict(self):
cdict = {'auth_token_info': 'magicdict'}
ctxt = context.RequestContext().from_dict(cdict)
self.assertEqual('magicdict', ctxt.auth_token_info)
def test_from_dict_older_api_server(self):
# Validates auth_token_info is None if not included.
cdict = {}
ctxt = context.RequestContext().from_dict(cdict)
self.assertIsNone(ctxt.auth_token_info)

View File

@ -337,10 +337,16 @@ class DoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
@mock.patch.object(conductor_utils, 'validate_instance_info_traits',
autospec=True)
@mock.patch.object(images, 'is_whole_disk_image', autospec=True)
def test_start_deploy(self, mock_iwdi, mock_validate_traits,
mock_validate_deploy_user_steps_and_templates,
mock_deploy_validate,
mock_power_validate, mock_process_event):
def _test_start_deploy(self, mock_iwdi, mock_validate_traits,
mock_validate_deploy_user_steps_and_templates,
mock_deploy_validate,
mock_power_validate, mock_process_event,
automatic_lessee=False):
self.context.auth_token_info = {
'token': {'project': {'id': 'user-project'}}
}
if automatic_lessee:
self.config(automatic_lessee=True, group='conductor')
self._start_service()
mock_iwdi.return_value = False
deploy_steps = [{"interface": "bios", "step": "factory_reset",
@ -366,6 +372,18 @@ class DoNodeDeployTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
mock.ANY, 'deploy', call_args=(
deployments.do_node_deploy, task, 1, None, deploy_steps),
callback=mock.ANY, err_handler=mock.ANY)
if automatic_lessee:
self.assertEqual('user-project', node.lessee)
self.assertIn('automatic_lessee', node.driver_internal_info)
else:
self.assertIsNone(node.lessee)
self.assertNotIn('automatic_lessee', node.driver_internal_info)
def test_start_deploy(self):
self._test_start_deploy(automatic_lessee=False)
def test_start_deploy_records_lessee(self):
self._test_start_deploy(automatic_lessee=True)
@mgr_utils.mock_record_keepalive

View File

@ -2476,7 +2476,9 @@ class DoNodeTearDownTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
driver_internal_info={'is_whole_disk_image': False,
'deploy_steps': {},
'root_uuid_or_disk_id': 'foo',
'instance': {'ephemeral_gb': 10}})
'instance': {'ephemeral_gb': 10},
'automatic_lessee': True},
lessee='fooproject')
port = obj_utils.create_test_port(
self.context, node_id=node.id,
internal_info={'tenant_vif_port_id': 'foo'})
@ -2498,11 +2500,13 @@ class DoNodeTearDownTestCase(mgr_utils.ServiceSetUpMixin, db_base.DbTestCase):
self.assertIsNone(node.last_error)
self.assertIsNone(node.instance_uuid)
self.assertIsNone(node.allocation_id)
self.assertIsNone(node.lessee)
self.assertEqual({}, node.instance_info)
self.assertNotIn('instance', node.driver_internal_info)
self.assertIsNone(node.driver_internal_info['deploy_steps'])
self.assertNotIn('root_uuid_or_disk_id', node.driver_internal_info)
self.assertNotIn('is_whole_disk_image', node.driver_internal_info)
self.assertNotIn('automatic_lessee', node.driver_internal_info)
mock_tear_down.assert_called_once_with(task.driver.deploy, task)
mock_clean.assert_called_once_with(task)
self.assertEqual({}, port.internal_info)

View File

@ -2667,3 +2667,20 @@ class NodeHistoryRecordTestCase(db_base.DbTestCase):
mock_history.create = mock_create
mock_history.assert_not_called()
mock_create.assert_not_called()
class GetTokenProjectFromRequestTestCase(db_base.DbTestCase):
def setUp(self):
super(GetTokenProjectFromRequestTestCase, self).setUp()
self.auth_token_info = {'token': {'project': {'id': 'user-project'}}}
def test_no_token_info(self):
self.assertIsNone(self.context.auth_token_info)
res = conductor_utils.get_token_project_from_request(self.context)
self.assertIsNone(res)
def test_returns_project_id_if_present(self):
self.context.auth_token_info = self.auth_token_info
res = conductor_utils.get_token_project_from_request(self.context)
self.assertEqual('user-project', res)

View File

@ -0,0 +1,11 @@
---
features:
- |
Adds a new feature to permit Ironic to automatically provide an instance
requestor's project, ``lessee`` rights to the Bare Metal machine under
the Role Based Access Control model implemented in Ironic. It does this
by saving the project ID of the requestor to the Node ``lessee`` field
automatically, and removing the rights when undeploying the machine.
This feature, is normally disabled, but can be enabled using the
``[conductor]automatic_lessee`` configuration option. This option will not
work in a mixed-version upgrade with older API services.