diff --git a/api-ref/source/v3/attachments.inc b/api-ref/source/v3/attachments.inc index 87b57d6092c..cb37848658a 100644 --- a/api-ref/source/v3/attachments.inc +++ b/api-ref/source/v3/attachments.inc @@ -41,6 +41,20 @@ Delete attachment Deletes an attachment. +For security reasons (see bug `#2004555 +`_) the Block Storage API rejects +REST API calls manually made from users with a 409 status code if there is a +Nova instance currently using the attachment, which happens when all the +following conditions are met: + +- Attachment has an instance uuid +- VM exists in Nova +- Instance has the volume attached +- Attached volume in instance is using the attachment + +Calls coming from other OpenStack services (like the Compute Service) are +always accepted. + Available starting in the 3.27 microversion. Response codes @@ -54,6 +68,7 @@ Response codes - 400 - 404 + - 409 Request diff --git a/api-ref/source/v3/volumes-v3-volumes-actions.inc b/api-ref/source/v3/volumes-v3-volumes-actions.inc index 808dcda8d87..bb79e309b17 100644 --- a/api-ref/source/v3/volumes-v3-volumes-actions.inc +++ b/api-ref/source/v3/volumes-v3-volumes-actions.inc @@ -337,6 +337,21 @@ Preconditions - Volume status must be ``in-use``. +For security reasons (see bug `#2004555 +`_), regardless of the policy +defaults, the Block Storage API rejects REST API calls manually made from +users with a 409 status code if completing the request could pose a risk, which +happens if all of these happen: + +- The request comes from a user +- There's an instance uuid in provided attachment or in the volume's attachment +- VM exists in Nova +- Instance has the volume attached +- Attached volume in instance is using the attachment + +Calls coming from other OpenStack services (like the Compute Service) are +always accepted. + Response codes -------------- @@ -344,6 +359,9 @@ Response codes - 202 +.. rest_status_code:: error ../status.yaml + + - 409 Request ------- @@ -415,6 +433,21 @@ perform this operation. Cloud providers can change these permissions through the ``volume_extension:volume_admin_actions:force_detach`` rule in the policy configuration file. +For security reasons (see bug `#2004555 +`_), regardless of the policy +defaults, the Block Storage API rejects REST API calls manually made from +users with a 409 status code if completing the request could pose a risk, which +happens if all of these happen: + +- The request comes from a user +- There's an instance uuid in provided attachment or in the volume's attachment +- VM exists in Nova +- Instance has the volume attached +- Attached volume in instance is using the attachment + +Calls coming from other OpenStack services (like the Compute Service) are +always accepted. + Response codes -------------- @@ -422,6 +455,9 @@ Response codes - 202 +.. rest_status_code:: error ../status.yaml + + - 409 Request ------- @@ -883,6 +919,22 @@ Preconditions - Volume status must be ``in-use``. +For security reasons (see bug `#2004555 +`_), regardless of the policy +defaults, the Block Storage API rejects REST API calls manually made from +users with a 409 status code if completing the request could pose a risk, which +happens if all of these happen: + +- The request comes from a user +- There's an instance uuid in the volume's attachment +- VM exists in Nova +- Instance has the volume attached +- Attached volume in instance is using the attachment + +Calls coming from other OpenStack services (like the Compute Service) are +always accepted. + + Response codes -------------- @@ -890,6 +942,9 @@ Response codes - 202 +.. rest_status_code:: error ../status.yaml + + - 409 Request ------- diff --git a/cinder/compute/nova.py b/cinder/compute/nova.py index b0a0952ffde..3e87009cd0f 100644 --- a/cinder/compute/nova.py +++ b/cinder/compute/nova.py @@ -133,6 +133,7 @@ def novaclient(context, privileged_user=False, timeout=None, api_version=None): class API(base.Base): """API for interacting with novaclient.""" + NotFound = nova_exceptions.NotFound def __init__(self): self.message_api = message_api.API() @@ -237,3 +238,9 @@ class API(base.Base): resource_uuid=volume_id, detail=message_field.Detail.REIMAGE_VOLUME_FAILED) return result + + @staticmethod + def get_server_volume(context, server_id, volume_id): + # Use microversion that includes attachment_id + nova = novaclient(context, api_version='2.89') + return nova.volumes.get_server_volume(server_id, volume_id) diff --git a/cinder/exception.py b/cinder/exception.py index dddcbc68a88..d57842f5d6d 100644 --- a/cinder/exception.py +++ b/cinder/exception.py @@ -1096,3 +1096,10 @@ class DriverInitiatorDataExists(Duplicate): class RequirementMissing(CinderException): message = _('Requirement %(req)s is not installed.') + + +class ConflictNovaUsingAttachment(CinderException): + message = _("Detach volume from instance %(instance_id)s using the " + "Compute API") + code = 409 + safe = True diff --git a/cinder/tests/unit/api/contrib/test_admin_actions.py b/cinder/tests/unit/api/contrib/test_admin_actions.py index e56b112d853..22246868f2e 100644 --- a/cinder/tests/unit/api/contrib/test_admin_actions.py +++ b/cinder/tests/unit/api/contrib/test_admin_actions.py @@ -1037,6 +1037,8 @@ class AdminActionsAttachDetachTest(BaseAdminTest): super(AdminActionsAttachDetachTest, self).setUp() # start service to handle rpc messages for attach requests self.svc = self.start_service('volume', host='test') + self.mock_deletion_allowed = self.mock_object( + volume_api.API, 'attachment_deletion_allowed', return_value=None) def tearDown(self): self.svc.stop() @@ -1092,6 +1094,16 @@ class AdminActionsAttachDetachTest(BaseAdminTest): admin_metadata = volume.admin_metadata self.assertEqual(1, len(admin_metadata)) self.assertEqual('False', admin_metadata['readonly']) + # One call is for the terminate_connection and the other is for the + # detach + self.assertEqual(2, self.mock_deletion_allowed.call_count) + self.mock_deletion_allowed.assert_has_calls( + [mock.call(self.ctx, None, mock.ANY), + mock.call(self.ctx, attachment['id'], mock.ANY)]) + for i in (0, 1): + self.assertIsInstance( + self.mock_deletion_allowed.call_args_list[i][0][2], + objects.Volume) def test_force_detach_host_attached_volume(self): # current status is available @@ -1143,6 +1155,16 @@ class AdminActionsAttachDetachTest(BaseAdminTest): admin_metadata = volume['admin_metadata'] self.assertEqual(1, len(admin_metadata)) self.assertEqual('False', admin_metadata['readonly']) + # One call is for the terminate_connection and the other is for the + # detach + self.assertEqual(2, self.mock_deletion_allowed.call_count) + self.mock_deletion_allowed.assert_has_calls( + [mock.call(self.ctx, None, mock.ANY), + mock.call(self.ctx, attachment['id'], mock.ANY)]) + for i in (0, 1): + self.assertIsInstance( + self.mock_deletion_allowed.call_args_list[i][0][2], + objects.Volume) def test_volume_force_detach_raises_remote_error(self): # current status is available @@ -1186,6 +1208,10 @@ class AdminActionsAttachDetachTest(BaseAdminTest): resp = req.get_response(app()) self.assertEqual(HTTPStatus.BAD_REQUEST, resp.status_int) + self.mock_deletion_allowed.assert_called_once_with( + self.ctx, None, volume) + self.mock_deletion_allowed.reset_mock() + # test for VolumeBackendAPIException volume_remote_error = ( messaging.RemoteError(exc_type='VolumeBackendAPIException')) @@ -1205,6 +1231,8 @@ class AdminActionsAttachDetachTest(BaseAdminTest): self.assertRaises(messaging.RemoteError, req.get_response, app()) + self.mock_deletion_allowed.assert_called_once_with( + self.ctx, None, volume) def test_volume_force_detach_raises_db_error(self): # In case of DB error 500 error code is returned to user @@ -1250,6 +1278,8 @@ class AdminActionsAttachDetachTest(BaseAdminTest): self.assertRaises(messaging.RemoteError, req.get_response, app()) + self.mock_deletion_allowed.assert_called_once_with( + self.ctx, None, volume) def test_volume_force_detach_missing_connector(self): # current status is available @@ -1290,6 +1320,8 @@ class AdminActionsAttachDetachTest(BaseAdminTest): # make request resp = req.get_response(app()) self.assertEqual(HTTPStatus.ACCEPTED, resp.status_int) + self.mock_deletion_allowed.assert_called_once_with( + self.ctx, None, volume) def test_attach_in_used_volume_by_instance(self): """Test that attaching to an in-use volume fails.""" diff --git a/cinder/tests/unit/api/v3/test_attachments.py b/cinder/tests/unit/api/v3/test_attachments.py index 6adf5a37cc5..35251357a2c 100644 --- a/cinder/tests/unit/api/v3/test_attachments.py +++ b/cinder/tests/unit/api/v3/test_attachments.py @@ -159,6 +159,8 @@ class AttachmentsAPITestCase(test.TestCase): @ddt.data('reserved', 'attached') @mock.patch.object(volume_rpcapi.VolumeAPI, 'attachment_delete') def test_delete_attachment(self, status, mock_delete): + self.patch('cinder.volume.api.API.attachment_deletion_allowed', + return_value=None) volume1 = self._create_volume(display_name='fake_volume_1', project_id=fake.PROJECT_ID) attachment = self._create_attachment( diff --git a/cinder/tests/unit/attachments/test_attachments_api.py b/cinder/tests/unit/attachments/test_attachments_api.py index 8f663f39e63..b9564160d44 100644 --- a/cinder/tests/unit/attachments/test_attachments_api.py +++ b/cinder/tests/unit/attachments/test_attachments_api.py @@ -12,12 +12,14 @@ from unittest import mock +from cinder.compute import nova from cinder import context from cinder import db from cinder import exception from cinder import objects from cinder.tests.unit.api.v2 import fakes as v2_fakes from cinder.tests.unit import fake_constants as fake +from cinder.tests.unit import fake_volume from cinder.tests.unit import test from cinder.tests.unit import utils as tests_utils from cinder.volume import api as volume_api @@ -78,10 +80,13 @@ class AttachmentManagerTestCase(test.TestCase): attachment.id) self.assertEqual(connection_info, new_attachment.connection_info) + @mock.patch.object(volume_api.API, 'attachment_deletion_allowed') @mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_delete') def test_attachment_delete_reserved(self, - mock_rpc_attachment_delete): + mock_rpc_attachment_delete, + mock_allowed): """Test attachment_delete with reserved.""" + mock_allowed.return_value = None volume_params = {'status': 'available'} vref = tests_utils.create_volume(self.context, **volume_params) @@ -94,18 +99,22 @@ class AttachmentManagerTestCase(test.TestCase): self.assertEqual(vref.id, aref.volume_id) self.volume_api.attachment_delete(self.context, aobj) + mock_allowed.assert_called_once_with(self.context, aobj) # Since it's just reserved and never finalized, we should never make an # rpc call mock_rpc_attachment_delete.assert_not_called() + @mock.patch.object(volume_api.API, 'attachment_deletion_allowed') @mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_delete') @mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_update') def test_attachment_create_update_and_delete( self, mock_rpc_attachment_update, - mock_rpc_attachment_delete): + mock_rpc_attachment_delete, + mock_allowed): """Test attachment_delete.""" + mock_allowed.return_value = None volume_params = {'status': 'available'} connection_info = {'fake_key': 'fake_value', 'fake_key2': ['fake_value1', 'fake_value2']} @@ -142,6 +151,7 @@ class AttachmentManagerTestCase(test.TestCase): self.volume_api.attachment_delete(self.context, aref) + mock_allowed.assert_called_once_with(self.context, aref) mock_rpc_attachment_delete.assert_called_once_with(self.context, aref.id, mock.ANY) @@ -173,10 +183,13 @@ class AttachmentManagerTestCase(test.TestCase): vref.id) self.assertEqual(2, len(vref.volume_attachment)) + @mock.patch.object(volume_api.API, 'attachment_deletion_allowed') @mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_update') def test_attachment_create_reserve_delete( self, - mock_rpc_attachment_update): + mock_rpc_attachment_update, + mock_allowed): + mock_allowed.return_value = None volume_params = {'status': 'available'} connector = { "initiator": "iqn.1993-08.org.debian:01:cad181614cec", @@ -211,12 +224,15 @@ class AttachmentManagerTestCase(test.TestCase): # attachments reserve self.volume_api.attachment_delete(self.context, aref) + mock_allowed.assert_called_once_with(self.context, aref) vref = objects.Volume.get_by_id(self.context, vref.id) self.assertEqual('reserved', vref.status) - def test_reserve_reserve_delete(self): + @mock.patch.object(volume_api.API, 'attachment_deletion_allowed') + def test_reserve_reserve_delete(self, mock_allowed): """Test that we keep reserved status across multiple reserves.""" + mock_allowed.return_value = None volume_params = {'status': 'available'} vref = tests_utils.create_volume(self.context, **volume_params) @@ -235,6 +251,7 @@ class AttachmentManagerTestCase(test.TestCase): self.assertEqual('reserved', vref.status) self.volume_api.attachment_delete(self.context, aref) + mock_allowed.assert_called_once_with(self.context, aref) vref = objects.Volume.get_by_id(self.context, vref.id) self.assertEqual('reserved', vref.status) @@ -344,3 +361,169 @@ class AttachmentManagerTestCase(test.TestCase): self.context, vref, fake.UUID1) + + def _get_attachment(self, with_instance_id=True): + volume = fake_volume.fake_volume_obj(self.context, id=fake.VOLUME_ID) + volume.volume_attachment = objects.VolumeAttachmentList() + attachment = fake_volume.volume_attachment_ovo( + self.context, + volume_id=fake.VOLUME_ID, + instance_uuid=fake.INSTANCE_ID if with_instance_id else None, + connection_info='{"a": 1}') + attachment.volume = volume + return attachment + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_service_call(self, mock_get_server): + """Service calls are never redirected.""" + self.context.service_roles = ['reader', 'service'] + attachment = self._get_attachment() + self.volume_api.attachment_deletion_allowed(self.context, attachment) + mock_get_server.assert_not_called() + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_service_call_different_service_name( + self, mock_get_server): + """Service calls are never redirected and role can be different. + + In this test we support 2 different service roles, the standard service + and a custom one called captain_awesome, and passing the custom one + works as expected. + """ + self.override_config('service_token_roles', + ['service', 'captain_awesome'], + group='keystone_authtoken') + + self.context.service_roles = ['reader', 'captain_awesome'] + attachment = self._get_attachment() + self.volume_api.attachment_deletion_allowed(self.context, attachment) + mock_get_server.assert_not_called() + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_no_instance(self, mock_get_server): + """Attachments with no instance id are never redirected.""" + attachment = self._get_attachment(with_instance_id=False) + self.volume_api.attachment_deletion_allowed(self.context, attachment) + mock_get_server.assert_not_called() + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_no_conn_info(self, mock_get_server): + """Attachments with no connection information are never redirected.""" + attachment = self._get_attachment(with_instance_id=False) + attachment.connection_info = None + self.volume_api.attachment_deletion_allowed(self.context, attachment) + + mock_get_server.assert_not_called() + + def test_attachment_deletion_allowed_no_attachment(self): + """For users don't allow operation with no attachment reference.""" + self.assertRaises(exception.ConflictNovaUsingAttachment, + self.volume_api.attachment_deletion_allowed, + self.context, None) + + @mock.patch('cinder.objects.VolumeAttachment.get_by_id', + side_effect=exception.VolumeAttachmentNotFound()) + def test_attachment_deletion_allowed_attachment_id_not_found(self, + mock_get): + """For users don't allow if attachment cannot be found.""" + attachment = self._get_attachment(with_instance_id=False) + attachment.connection_info = None + self.assertRaises(exception.ConflictNovaUsingAttachment, + self.volume_api.attachment_deletion_allowed, + self.context, fake.ATTACHMENT_ID) + mock_get.assert_called_once_with(self.context, fake.ATTACHMENT_ID) + + def test_attachment_deletion_allowed_volume_no_attachments(self): + """For users allow if volume has no attachments.""" + volume = tests_utils.create_volume(self.context) + self.volume_api.attachment_deletion_allowed(self.context, None, volume) + + def test_attachment_deletion_allowed_multiple_attachment(self): + """For users don't allow if volume has multiple attachments.""" + attachment = self._get_attachment() + volume = attachment.volume + volume.volume_attachment = objects.VolumeAttachmentList( + objects=[attachment, attachment]) + self.assertRaises(exception.ConflictNovaUsingAttachment, + self.volume_api.attachment_deletion_allowed, + self.context, None, volume) + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_vm_not_found(self, mock_get_server): + """Don't reject if instance doesn't exist""" + mock_get_server.side_effect = nova.API.NotFound(404) + attachment = self._get_attachment() + self.volume_api.attachment_deletion_allowed(self.context, attachment) + + mock_get_server.assert_called_once_with(self.context, fake.INSTANCE_ID, + fake.VOLUME_ID) + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_attachment_from_volume( + self, mock_get_server): + """Don't reject if instance doesn't exist""" + mock_get_server.side_effect = nova.API.NotFound(404) + attachment = self._get_attachment() + volume = attachment.volume + volume.volume_attachment = objects.VolumeAttachmentList( + objects=[attachment]) + self.volume_api.attachment_deletion_allowed(self.context, None, volume) + + mock_get_server.assert_called_once_with(self.context, fake.INSTANCE_ID, + volume.id) + + @mock.patch('cinder.objects.VolumeAttachment.get_by_id') + def test_attachment_deletion_allowed_mismatched_volume_and_attach_id( + self, mock_get_attatchment): + """Reject if volume and attachment don't match.""" + attachment = self._get_attachment() + volume = attachment.volume + volume.volume_attachment = objects.VolumeAttachmentList( + objects=[attachment]) + attachment2 = self._get_attachment() + attachment2.volume_id = attachment.volume.id = fake.VOLUME2_ID + self.assertRaises(exception.InvalidInput, + self.volume_api.attachment_deletion_allowed, + self.context, attachment2.id, volume) + mock_get_attatchment.assert_called_once_with(self.context, + attachment2.id) + + @mock.patch('cinder.objects.VolumeAttachment.get_by_id') + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_not_found_attachment_id( + self, mock_get_server, mock_get_attachment): + """Don't reject if instance doesn't exist""" + mock_get_server.side_effect = nova.API.NotFound(404) + mock_get_attachment.return_value = self._get_attachment() + + self.volume_api.attachment_deletion_allowed(self.context, + fake.ATTACHMENT_ID) + + mock_get_attachment.assert_called_once_with(self.context, + fake.ATTACHMENT_ID) + + mock_get_server.assert_called_once_with(self.context, fake.INSTANCE_ID, + fake.VOLUME_ID) + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_mismatch_id(self, mock_get_server): + """Don't reject if attachment id on nova doesn't match""" + mock_get_server.return_value.attachment_id = fake.ATTACHMENT2_ID + attachment = self._get_attachment() + self.volume_api.attachment_deletion_allowed(self.context, attachment) + + mock_get_server.assert_called_once_with(self.context, fake.INSTANCE_ID, + fake.VOLUME_ID) + + @mock.patch('cinder.compute.nova.API.get_server_volume') + def test_attachment_deletion_allowed_user_call_fails(self, + mock_get_server): + """Fail user calls""" + attachment = self._get_attachment() + mock_get_server.return_value.attachment_id = attachment.id + self.assertRaises(exception.ConflictNovaUsingAttachment, + self.volume_api.attachment_deletion_allowed, + self.context, attachment) + + mock_get_server.assert_called_once_with(self.context, fake.INSTANCE_ID, + fake.VOLUME_ID) diff --git a/cinder/tests/unit/policies/test_attachments.py b/cinder/tests/unit/policies/test_attachments.py index 7307a83ca54..8d3040b2f40 100644 --- a/cinder/tests/unit/policies/test_attachments.py +++ b/cinder/tests/unit/policies/test_attachments.py @@ -62,6 +62,9 @@ class AttachmentsPolicyTest(base.BasePolicyTest): self.api_path = '/v3/%s/attachments' % (self.project_id) self.api_version = mv.NEW_ATTACH + self.mock_is_service = self.patch( + 'cinder.volume.api.API.is_service_request', + return_value=True) def _initialize_connection(self, volume, connector): return {'data': connector} diff --git a/cinder/tests/unit/policies/test_volume_actions.py b/cinder/tests/unit/policies/test_volume_actions.py index d9b2edc8e6a..17dacd5ef86 100644 --- a/cinder/tests/unit/policies/test_volume_actions.py +++ b/cinder/tests/unit/policies/test_volume_actions.py @@ -89,6 +89,9 @@ class VolumeActionsPolicyTest(base.BasePolicyTest): self._initialize_connection) self.api_path = '/v3/%s/volumes' % (self.project_id) self.api_version = mv.BASE_VERSION + self.mock_is_service = self.patch( + 'cinder.volume.api.API.is_service_request', + return_value=True) def _initialize_connection(self, volume, connector): return {'data': connector} @@ -946,6 +949,7 @@ class VolumeProtectionTests(test_base.CinderPolicyTests): self.assertEqual(HTTPStatus.ACCEPTED, response.status_int) body = {"os-detach": {}} + # Detach for user call succeeds because the volume has no attachments response = self._get_request_response(admin_context, path, 'POST', body=body) self.assertEqual(HTTPStatus.ACCEPTED, response.status_int) @@ -966,6 +970,7 @@ class VolumeProtectionTests(test_base.CinderPolicyTests): body=body) self.assertEqual(HTTPStatus.ACCEPTED, response.status_int) + # Succeeds for a user call because there are no attachments body = {"os-detach": {}} response = self._get_request_response(user_context, path, 'POST', body=body) @@ -1062,6 +1067,7 @@ class VolumeProtectionTests(test_base.CinderPolicyTests): 'terminate_connection') def test_admin_can_initialize_terminate_conn(self, mock_t, mock_i): admin_context = self.admin_context + admin_context.service_roles = ['service'] volume = self._create_fake_volume(admin_context) path = '/v3/%(project_id)s/volumes/%(volume_id)s/action' % { @@ -1084,6 +1090,7 @@ class VolumeProtectionTests(test_base.CinderPolicyTests): 'terminate_connection') def test_owner_can_initialize_terminate_conn(self, mock_t, mock_i): user_context = self.user_context + user_context.service_roles = ['service'] volume = self._create_fake_volume(user_context) path = '/v3/%(project_id)s/volumes/%(volume_id)s/action' % { diff --git a/cinder/tests/unit/volume/test_connection.py b/cinder/tests/unit/volume/test_connection.py index 0d472d6228b..6bd9b89b6f7 100644 --- a/cinder/tests/unit/volume/test_connection.py +++ b/cinder/tests/unit/volume/test_connection.py @@ -1334,7 +1334,8 @@ class VolumeAttachDetachTestCase(base.BaseVolumeTestCase): self.context, volume, None, None, None, None) - def test_volume_detach_in_maintenance(self): + @mock.patch('cinder.volume.api.API.attachment_deletion_allowed') + def test_volume_detach_in_maintenance(self, mock_attachment_deletion): """Test detach the volume in maintenance.""" test_meta1 = {'fake_key1': 'fake_value1', 'fake_key2': 'fake_value2'} volume = tests_utils.create_volume(self.context, metadata=test_meta1, @@ -1345,3 +1346,5 @@ class VolumeAttachDetachTestCase(base.BaseVolumeTestCase): volume_api.detach, self.context, volume, None) + mock_attachment_deletion.assert_called_once_with(self.context, + None, volume) diff --git a/cinder/volume/api.py b/cinder/volume/api.py index 480f4dc9520..ccf58523c4c 100644 --- a/cinder/volume/api.py +++ b/cinder/volume/api.py @@ -34,6 +34,7 @@ import webob from cinder.api import common from cinder.common import constants +from cinder import compute from cinder import context from cinder import coordination from cinder import db @@ -862,11 +863,14 @@ class API(base.Base): attachment_id: str) -> None: context.authorize(vol_action_policy.DETACH_POLICY, target_obj=volume) + self.attachment_deletion_allowed(context, attachment_id, volume) + if volume['status'] == 'maintenance': LOG.info('Unable to detach volume, ' 'because it is in maintenance.', resource=volume) msg = _("The volume cannot be detached in maintenance mode.") raise exception.InvalidVolume(reason=msg) + detach_results = self.volume_rpcapi.detach_volume(context, volume, attachment_id) LOG.info("Detach volume completed successfully.", @@ -893,6 +897,19 @@ class API(base.Base): resource=volume) return init_results + @staticmethod + def is_service_request(ctxt: 'context.RequestContext') -> bool: + """Check if a request is coming from a service + + A request is coming from a service if it has a service token and the + service user has one of the roles configured in the + `service_token_roles` configuration option in the + `[keystone_authtoken]` section (defaults to `service`). + """ + roles = ctxt.service_roles + service_roles = set(CONF.keystone_authtoken.service_token_roles) + return bool(roles and service_roles.intersection(roles)) + def terminate_connection(self, context: context.RequestContext, volume: objects.Volume, @@ -900,6 +917,8 @@ class API(base.Base): force: bool = False) -> None: context.authorize(vol_action_policy.TERMINATE_POLICY, target_obj=volume) + self.attachment_deletion_allowed(context, None, volume) + self.volume_rpcapi.terminate_connection(context, volume, connector, @@ -2521,11 +2540,90 @@ class API(base.Base): attachment_ref.save() return attachment_ref + def attachment_deletion_allowed(self, + ctxt: context.RequestContext, + attachment_or_attachment_id, + volume=None): + """Check if deleting an attachment is allowed (Bug #2004555) + + Allowed is based on the REST API policy, the status of the attachment, + where it is used, and who is making the request. + + Deleting an attachment on the Cinder side while leaving the volume + connected to the nova host results in leftover devices that can lead to + data leaks/corruption. + + OS-Brick may have code to detect it, but in some cases it is detected + after it has already been exposed, so it's better to prevent users from + being able to intentionally triggering the issue. + """ + # It's ok to delete an attachment if the request comes from a service + if self.is_service_request(ctxt): + return + + if not attachment_or_attachment_id and volume: + if not volume.volume_attachment: + return + if len(volume.volume_attachment) == 1: + attachment_or_attachment_id = volume.volume_attachment[0] + + if isinstance(attachment_or_attachment_id, str): + try: + attachment = objects.VolumeAttachment.get_by_id( + ctxt, attachment_or_attachment_id) + except exception.VolumeAttachmentNotFound: + attachment = None + else: + attachment = attachment_or_attachment_id + + if attachment: + if volume: + if volume.id != attachment.volume_id: + raise exception.InvalidInput( + reason='Mismatched volume and attachment') + + server_id = attachment.instance_uuid + # It's ok to delete if it's not connected to a vm. + if not server_id or not attachment.connection_info: + return + + volume = volume or attachment.volume + nova = compute.API() + LOG.info('Attachment connected to vm %s, checking data on nova', + server_id) + # If nova is down the client raises 503 and we report that + try: + nova_volume = nova.get_server_volume(ctxt, server_id, + volume.id) + except nova.NotFound: + LOG.warning('Instance or volume not found on Nova, deleting ' + 'attachment locally, which may leave leftover ' + 'devices on Nova compute') + return + + if nova_volume.attachment_id != attachment.id: + LOG.warning('Mismatch! Nova has different attachment id (%s) ' + 'for the volume, deleting attachment locally. ' + 'May leave leftover devices in a compute node', + nova_volume.attachment_id) + return + else: + server_id = '' + + LOG.error('Detected user call to delete in-use attachment. Call must ' + 'come from the nova service and nova must be configured to ' + 'send the service token. Bug #2004555') + raise exception.ConflictNovaUsingAttachment(instance_id=server_id) + def attachment_delete(self, ctxt: context.RequestContext, attachment) -> objects.VolumeAttachmentList: + # Check if policy allows user to delete attachment ctxt.authorize(attachment_policy.DELETE_POLICY, target_obj=attachment) + + self.attachment_deletion_allowed(ctxt, attachment) + volume = attachment.volume if attachment.attach_status == fields.VolumeAttachStatus.RESERVED: diff --git a/doc/source/configuration/block-storage/service-token.rst b/doc/source/configuration/block-storage/service-token.rst index 1c48552f2ee..a04aa05e0f1 100644 --- a/doc/source/configuration/block-storage/service-token.rst +++ b/doc/source/configuration/block-storage/service-token.rst @@ -1,6 +1,6 @@ -========================================================= -Using service tokens to prevent long-running job failures -========================================================= +==================== +Using service tokens +==================== When a user initiates a request whose processing involves multiple services (for example, a boot-from-volume request to the Compute Service will require @@ -8,20 +8,32 @@ processing by the Block Storage Service, and may require processing by the Image Service), the user's token is handed from service to service. This ensures that the requestor is tracked correctly for audit purposes and also guarantees that the requestor has the appropriate permissions to do what needs -to be done by the other services. If the chain of operations takes a long -time, however, the user's token may expire before the action is completed, -leading to the failure of the user's original request. +to be done by the other services. -One way to deal with this is to set a long token life in Keystone, and this may -be what you are currently doing. But this can be problematic for installations -whose security policies prefer short user token lives. Beginning with the -Queens release, an alternative solution is available. You have the ability to -configure some services (particularly Nova and Cinder) to send a "service -token" along with the user's token. When properly configured, the Identity -Service will validate an expired user token *when it is accompanied by a valid -service token*. Thus if the user's token expires somewhere during a long -running chain of operations among various OpenStack services, the operations -can continue. +There are several instances where we want to differentiate between a request +coming from the user to one coming from another OpenStack service on behalf of +the user: + +- **For security reasons** There are some operations in the Block Storage + service, required for normal operations, that could be exploited by a + malicious user to gain access to resources belonging to other users. By + differentiating when the request comes directly from a user and when from + another OpenStack service the Cinder service can protect the deployment. + +- To prevent long-running job failures: If the chain of operations takes a long + time, the user's token may expire before the action is completed, leading to + the failure of the user's original request. + + One way to deal with this is to set a long token life in Keystone, and this + may be what you are currently doing. But this can be problematic for + installations whose security policies prefer short user token lives. + Beginning with the Queens release, an alternative solution is available. You + have the ability to configure some services (particularly Nova and Cinder) to + send a "service token" along with the user's token. When properly + configured, the Identity Service will validate an expired user token *when it + is accompanied by a valid service token*. Thus if the user's token expires + somewhere during a long running chain of operations among various OpenStack + services, the operations can continue. .. note:: There's nothing special about a service token. It's a regular token diff --git a/doc/source/configuration/index.rst b/doc/source/configuration/index.rst index 002469fac40..ed599de0358 100644 --- a/doc/source/configuration/index.rst +++ b/doc/source/configuration/index.rst @@ -6,6 +6,7 @@ Cinder Service Configuration :maxdepth: 1 block-storage/block-storage-overview.rst + block-storage/service-token.rst block-storage/volume-drivers.rst block-storage/backup-drivers.rst block-storage/schedulers.rst @@ -15,10 +16,16 @@ Cinder Service Configuration block-storage/policy-config-HOWTO.rst block-storage/fc-zoning.rst block-storage/volume-encryption.rst - block-storage/service-token.rst block-storage/config-options.rst block-storage/samples/index.rst +.. warning:: + + For security reasons **Service Tokens must to be configured** in OpenStack + for Cinder to operate securely. Pay close attention to the :doc:`specific + section describing it: `. See + https://bugs.launchpad.net/nova/+bug/2004555 for details. + .. note:: The examples of common configurations for shared diff --git a/doc/source/install/index.rst b/doc/source/install/index.rst index 931ea78f921..ae1732a3892 100644 --- a/doc/source/install/index.rst +++ b/doc/source/install/index.rst @@ -35,6 +35,13 @@ Adding Cinder to your OpenStack Environment The following links describe how to install the Cinder Block Storage Service: +.. warning:: + + For security reasons **Service Tokens must to be configured** in OpenStack + for Cinder to operate securely. Pay close attention to the :doc:`specific + section describing it: <../configuration/block-storage/service-token>`. See + https://bugs.launchpad.net/nova/+bug/2004555 for details. + .. toctree:: get-started-block-storage diff --git a/releasenotes/notes/redirect-detach-nova-4b7b7902d7d182e0.yaml b/releasenotes/notes/redirect-detach-nova-4b7b7902d7d182e0.yaml new file mode 100644 index 00000000000..dd3ea4fc483 --- /dev/null +++ b/releasenotes/notes/redirect-detach-nova-4b7b7902d7d182e0.yaml @@ -0,0 +1,43 @@ +--- +critical: + - | + Detaching volumes will fail if Nova is not `configured to send service + tokens `_, + please read the upgrade section for more information. (`Bug #2004555 + `_). +upgrade: + - | + Nova must be `configured to send service tokens + `_ + **and** cinder must be configured to recognize at least one of the roles + that the nova service user has been assigned in keystone. By default, + cinder will recognize the ``service`` role, so if the nova service user + is assigned a differently named role in your cloud, you must adjust your + cinder configuration file (``service_token_roles`` configuration option + in the ``keystone_authtoken`` section). If nova and cinder are not + configured correctly in this regard, detaching volumes will no longer + work (`Bug #2004555 `_). +security: + - | + As part of the fix for `Bug #2004555 + `_, cinder now rejects + user attachment delete requests for attachments that are being used by nova + instances to ensure that no leftover devices are produced on the compute + nodes which could be used to access another project's volumes. Terminate + connection, detach, and force detach volume actions (calls that are not + usually made by users directly) are, in most cases, not allowed for users. +fixes: + - | + `Bug #2004555 `_: Fixed + issue where a user manually deleting an attachment, calling terminate + connection, detach, or force detach, for a volume that is still used by a + nova instance resulted in leftover devices on the compute node. These + operations will now fail when it is believed to be a problem. +issues: + - | + For security reasons (`Bug #2004555 + `_) manually deleting an + attachment, manually doing the ``os-terminate_connection``, ``os-detach`` + or ``os-force_detach`` actions will no longer be allowed in most cases + unless the request is coming from another OpenStack service on behalf of a + user.