From 6b109f597dfd432c0bd5b06df2748ce5d1d301fe Mon Sep 17 00:00:00 2001 From: Dan Smith Date: Mon, 4 May 2026 15:46:47 -0700 Subject: [PATCH] Fix resize to unpermitted flavors As noted in the referenced bug, we were previously not passing the user's context during a resize operation when looking up the target flavor. This means we end up using an admin context, which can find any flavor, including private ones we should not be able to see thus allowing resize to another project's private flavor. Simply passing the context to the lookup does the needed filtering. Assisted-By: Claude Opus 4.6 Closes-Bug: #2151256 Change-Id: I469688be203c319dd048d6a99057b8d98369de0e Signed-off-by: Dan Smith (cherry picked from commit ef7ddda106e9f3c745043db96e4c76cc3b9ace2a) (cherry picked from commit 36dd74f6811ee3742f81199731825016830c0d0b) (cherry picked from commit 07deedd552405f161cf62f0edea6891ef93adc95) --- nova/compute/api.py | 2 +- nova/tests/functional/test_servers.py | 37 +++++++++++++++++++++++++++ nova/tests/unit/compute/test_api.py | 18 ++++++++++--- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/nova/compute/api.py b/nova/compute/api.py index 5223b29b699..47c6b27d855 100644 --- a/nova/compute/api.py +++ b/nova/compute/api.py @@ -4240,7 +4240,7 @@ def resize(self, context, instance, flavor_id=None, clean_shutdown=True, new_flavor = current_flavor else: new_flavor = flavors.get_flavor_by_flavor_id( - flavor_id, read_deleted="no") + flavor_id, ctxt=context, read_deleted="no") # NOTE(wenping): We use this instead of the 'block_accelerator' # decorator since the operation can differ depending on args, # and for resize we have two flavors to worry about, we should diff --git a/nova/tests/functional/test_servers.py b/nova/tests/functional/test_servers.py index cf8df5bb761..0be5f460dfe 100644 --- a/nova/tests/functional/test_servers.py +++ b/nova/tests/functional/test_servers.py @@ -2109,6 +2109,43 @@ def test_resize_not_enough_resource(self): self._delete_and_check_allocations(server) + def test_resize_with_private_flavor(self): + """Ensure a non-admin user cannot resize to a private flavor that + has not been granted access to their project. + """ + source_hostname = self.compute1.host + + server = self._boot_and_check_allocations( + self.flavor1, source_hostname) + + # Create a private flavor and grant access to a different project. + private_flavor_body = {'flavor': { + 'name': 'private_flavor', + 'ram': 1024, + 'vcpus': 1, + 'disk': 10, + 'os-flavor-access:is_public': False, + }} + private_flavor = self.admin_api.post_flavor(private_flavor_body) + self.admin_api.api_post( + 'flavors/%s/action' % private_flavor['id'], + {'addTenantAccess': {'tenant': 'other-project'}}) + + # Use a non-admin API client to attempt the resize. + non_admin_api = self.api_fixture.api + non_admin_api.microversion = self.microversion + + resize_req = { + 'resize': { + 'flavorRef': private_flavor['id'] + } + } + ex = self.assertRaises( + client.OpenStackApiException, + non_admin_api.post_server_action, + server['id'], resize_req) + self.assertEqual(400, ex.response.status_code) + def test_resize_delete_while_verify(self): """Test scenario where the server is deleted while in the VERIFY_RESIZE state and ensures the allocations are properly diff --git a/nova/tests/unit/compute/test_api.py b/nova/tests/unit/compute/test_api.py index 8d015aa156d..8cc4d4d26da 100644 --- a/nova/tests/unit/compute/test_api.py +++ b/nova/tests/unit/compute/test_api.py @@ -2172,6 +2172,7 @@ def _check_state(expected_task_state=None): if flavor_id_passed: mock_get_flavor.assert_called_once_with('new-flavor-id', + ctxt=self.context, read_deleted='no') if not (flavor_id_passed and same_flavor): @@ -2380,7 +2381,9 @@ def test_resize_invalid_flavor_fails(self, mock_get_flavor, mock_count, self.assertRaises(exception.FlavorNotFound, self.compute_api.resize, self.context, fake_inst, flavor_id='flavor-id') - mock_get_flavor.assert_called_once_with('flavor-id', read_deleted='no') + mock_get_flavor.assert_called_once_with('flavor-id', + ctxt=self.context, + read_deleted='no') # Should never reach these. mock_count.assert_not_called() mock_limit.assert_not_called() @@ -2413,7 +2416,8 @@ def test_resize_vol_backed_smaller_min_ram(self, mock_get_flavor, self.assertRaises(exception.FlavorMemoryTooSmall, self.compute_api.resize, self.context, fake_inst, flavor_id=new_flavor.id) - mock_get_flavor.assert_called_once_with(200, read_deleted='no') + mock_get_flavor.assert_called_once_with(200, ctxt=self.context, + read_deleted='no') # Should never reach these. mock_count.assert_not_called() mock_limit.assert_not_called() @@ -2441,7 +2445,9 @@ def test_resize_disabled_flavor_fails(self, mock_get_flavor, mock_count, self.assertRaises(exception.FlavorNotFound, self.compute_api.resize, self.context, fake_inst, flavor_id='flavor-id') - mock_get_flavor.assert_called_once_with('flavor-id', read_deleted='no') + mock_get_flavor.assert_called_once_with('flavor-id', + ctxt=self.context, + read_deleted='no') # Should never reach these. mock_count.assert_not_called() mock_limit.assert_not_called() @@ -2540,7 +2546,9 @@ def test_resize_quota_exceeds_fails(self, mock_get_flavor, mock_upsize, fake_inst, flavor_id='flavor-id') mock_save.assert_not_called() - mock_get_flavor.assert_called_once_with('flavor-id', read_deleted='no') + mock_get_flavor.assert_called_once_with('flavor-id', + ctxt=self.context, + read_deleted='no') mock_upsize.assert_called_once_with(test.MatchType(objects.Flavor), test.MatchType(objects.Flavor)) # mock.ANY might be 'instances', 'cores', or 'ram' @@ -2616,6 +2624,7 @@ def test_resize_instance_quota_exceeds_with_multiple_resources( self.assertEqual('1, 512', e.kwargs['used']) self.assertEqual('1, 512', e.kwargs['allowed']) mock_get_flavor.assert_called_once_with('fake_flavor_id', + ctxt=self.context, read_deleted="no") else: self.fail("Exception not raised") @@ -2642,6 +2651,7 @@ def test_resize_instance_quota_exceeds_with_multiple_resources_ul( 'fake_flavor_id') mock_get_flavor.assert_called_once_with('fake_flavor_id', + ctxt=self.context, read_deleted="no") mock_enforce.assert_called_once_with( self.context, "fake", mock_get_flavor.return_value, False, 1, 1)