Fix node detail instance_uuid request handling

The instance_uuid handling on the detailed node information
endpoint of the api (/v1/nodes/detail?instance_uuid=<uuid>),
which is used by services such as Nova for explicit node status
lookups, previously had special conditional logic surrounding it
which skipped the inclusion of the API requestor project-id, from
being incorporated into the database query.

Ultimately, this allowed an authenticated user to obtain a partially
redacted node entry where sensitive informational fields were scrubbed
from the response payload.

With this fix, queries for an explicit instance_uuid now follow the
standard path inside the Ironic API to the database which includes
inclusion of a requestor Project-ID if required by configured policy.

Change-Id: I9bfa5a54e02c8a1e9c8cad6b9acdbad6ab62bef3
Story: 2008976
Task: 42620
This commit is contained in:
Julia Kreger 2021-06-16 10:26:21 -07:00
parent c6e33896f1
commit be3c153d56
4 changed files with 138 additions and 58 deletions

View File

@ -1761,19 +1761,6 @@ class NodesController(rest.RestController):
# The query parameters for the 'next' URL
parameters = {}
if instance_uuid:
# NOTE(rloo) if instance_uuid is specified, the other query
# parameters are ignored. Since there can be at most one node that
# has this instance_uuid, we do not want to generate a 'next' link.
nodes = self._get_nodes_by_instance(instance_uuid)
# NOTE(rloo) if limit==1 and len(nodes)==1 (see
# Collection.has_next()), a 'next' link will
# be generated, which we don't want.
limit = 0
else:
possible_filters = {
'maintenance': maintenance,
'chassis_uuid': chassis_uuid,
@ -1788,6 +1775,7 @@ class NodesController(rest.RestController):
'project': project,
'description_contains': description_contains,
'retired': retired,
'instance_uuid': instance_uuid
}
filters = {}
for key, value in possible_filters.items():
@ -1812,24 +1800,21 @@ class NodesController(rest.RestController):
if detail is not None:
parameters['detail'] = detail
if instance_uuid:
# NOTE(rloo) if limit==1 and len(nodes)==1 (see
# Collection.has_next()), a 'next' link will
# be generated, which we don't want.
# NOTE(TheJulia): This is done after the query as
# instance_uuid is a unique constraint in the DB
# and we cannot pass a limit of 0 to sqlalchemy
# and expect a response.
limit = 0
return node_list_convert_with_links(nodes, limit,
url=resource_url,
fields=fields,
**parameters)
def _get_nodes_by_instance(self, instance_uuid):
"""Retrieve a node by its instance uuid.
It returns a list with the node, or an empty list if no node is found.
"""
try:
node = objects.Node.get_by_instance_uuid(api.request.context,
instance_uuid)
return [node]
except exception.InstanceNotFound:
return []
def _check_names_acceptable(self, names, error_msg):
"""Checks all node 'name's are acceptable, it does not return a value.

View File

@ -316,7 +316,7 @@ class Connection(api.Connection):
_NODE_QUERY_FIELDS = {'console_enabled', 'maintenance', 'retired',
'driver', 'resource_class', 'provision_state',
'uuid', 'id', 'fault', 'conductor_group',
'owner', 'lessee'}
'owner', 'lessee', 'instance_uuid'}
_NODE_IN_QUERY_FIELDS = {'%s_in' % field: field
for field in ('uuid', 'provision_state')}
_NODE_NON_NULL_FILTERS = {'associated': 'instance_uuid',

View File

@ -755,6 +755,81 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertIn('retired_reason', data['nodes'][0])
self.assertIn('network_data', data['nodes'][0])
def test_detail_instance_uuid(self):
instance_uuid = '6eccd391-961c-4da5-b3c5-e2fa5cfbbd9d'
node = obj_utils.create_test_node(
self.context,
instance_uuid=instance_uuid)
data = self.get_json(
'/nodes/detail?instance_uuid=%s' % instance_uuid,
headers={api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(1, len(data['nodes']))
self.assertEqual(node.uuid, data['nodes'][0]["uuid"])
expected_fields = [
'name', 'driver', 'driver_info', 'extra', 'chassis_uuid',
'reservation', 'maintenance', 'console_enabled',
'target_power_state', 'target_provision_state',
'provision_updated_at', 'inspection_finished_at',
'inspection_started_at', 'raid_config', 'target_raid_config',
'network_interface', 'resource_class', 'owner', 'lessee',
'storage_interface', 'traits', 'automated_clean',
'conductor_group', 'protected', 'protected_reason',
'retired', 'retired_reason', 'allocation_uuid', 'network_data'
]
for field in expected_fields:
self.assertIn(field, data['nodes'][0])
for field in api_utils.V31_FIELDS:
self.assertIn(field, data['nodes'][0])
# never expose the chassis_id
self.assertNotIn('chassis_id', data['nodes'][0])
self.assertNotIn('allocation_id', data['nodes'][0])
# no pagination marker should be present
self.assertNotIn('next', data)
@mock.patch.object(policy, 'authorize', spec=True)
def test_detail_instance_uuid_project_not_match(self, mock_authorize):
def mock_authorize_function(rule, target, creds):
if rule == 'baremetal:node:list_all':
raise exception.HTTPForbidden(resource='fake')
return True
mock_authorize.side_effect = mock_authorize_function
instance_uuid = '6eccd391-961c-4da5-b3c5-e2fa5cfbbd9d'
requestor_uuid = '46c0bf8a-846d-49a5-9724-5a61a5efa6bf'
obj_utils.create_test_node(
self.context,
owner='97879042-c0bf-4216-882a-66a7cbf2bd74',
instance_uuid=instance_uuid)
data = self.get_json(
'/nodes/detail?instance_uuid=%s' % instance_uuid,
headers={'X-Project-ID': requestor_uuid,
api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(0, len(data['nodes']))
@mock.patch.object(policy, 'authorize', spec=True)
def test_detail_instance_uuid_project_match(self, mock_authorize):
def mock_authorize_function(rule, target, creds):
if rule == 'baremetal:node:list_all':
raise exception.HTTPForbidden(resource='fake')
return True
mock_authorize.side_effect = mock_authorize_function
instance_uuid = '6eccd391-961c-4da5-b3c5-e2fa5cfbbd9d'
requestor_uuid = '46c0bf8a-846d-49a5-9724-5a61a5efa6bf'
node = obj_utils.create_test_node(
self.context,
owner=requestor_uuid,
instance_uuid=instance_uuid)
data = self.get_json(
'/nodes/detail?instance_uuid=%s' % instance_uuid,
headers={'X-Project-ID': requestor_uuid,
api_base.Version.string: str(api_v1.max_version())})
self.assertEqual(1, len(data['nodes']))
# Assert we did get the node and it matched.
self.assertEqual(node.uuid, data['nodes'][0]["uuid"])
self.assertEqual(node.owner, data['nodes'][0]["owner"])
def test_detail_using_query(self):
node = obj_utils.create_test_node(self.context,
chassis_id=self.chassis.id)

View File

@ -0,0 +1,20 @@
---
security:
- |
Fixes an issue with the ``/v1/nodes/detail`` endpoint where an
authenticated user could explicitly ask for an ``instance_uuid`` lookup
and the associated node would be returned to the user with sensitive
fields redacted in the result payload if the user did not explicitly have
``owner`` or ``lessee`` permissions over the node. This is considered a
low-impact low-risk issue as it requires the API consumer to already know
the UUID value of the associated instance, and the returned information
is mainly metadata in nature. More information can be found in
`Storyboard story 2008976 <https://storyboard.openstack.org/#!/story/2008976>`_.
fixes:
- |
Fixes an issue with the ``/v1/nodes/detail`` endpoint where requests
for an explicit ``instance_uuid`` match would not follow the standard
query handling path and thus not be filtered based on policy determined
access level and node level ``owner`` or ``lessee`` fields appropriately.
Additional information can be found in
`story 2008976 <https://storyboard.openstack.org/#!/story/2008976>`_.