Merge "Reject unsafe delete attachment calls"
This commit is contained in:
commit
456b6399be
@ -41,6 +41,20 @@ Delete attachment
|
||||
|
||||
Deletes an attachment.
|
||||
|
||||
For security reasons (see bug `#2004555
|
||||
<https://bugs.launchpad.net/nova/+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
|
||||
|
@ -337,6 +337,21 @@ Preconditions
|
||||
|
||||
- Volume status must be ``in-use``.
|
||||
|
||||
For security reasons (see bug `#2004555
|
||||
<https://bugs.launchpad.net/nova/+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
|
||||
<https://bugs.launchpad.net/nova/+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
|
||||
<https://bugs.launchpad.net/nova/+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
|
||||
-------
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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."""
|
||||
|
@ -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(
|
||||
|
@ -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)
|
||||
|
@ -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}
|
||||
|
@ -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' % {
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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: <block-storage/service-token>`. See
|
||||
https://bugs.launchpad.net/nova/+bug/2004555 for details.
|
||||
|
||||
.. note::
|
||||
|
||||
The examples of common configurations for shared
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,43 @@
|
||||
---
|
||||
critical:
|
||||
- |
|
||||
Detaching volumes will fail if Nova is not `configured to send service
|
||||
tokens <https://docs.openstack.org/cinder/latest/configuration/block-storage/service-token.html>`_,
|
||||
please read the upgrade section for more information. (`Bug #2004555
|
||||
<https://bugs.launchpad.net/cinder/+bug/2004555>`_).
|
||||
upgrade:
|
||||
- |
|
||||
Nova must be `configured to send service tokens
|
||||
<https://docs.openstack.org/cinder/latest/configuration/block-storage/service-token.html>`_
|
||||
**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 <https://bugs.launchpad.net/cinder/+bug/2004555>`_).
|
||||
security:
|
||||
- |
|
||||
As part of the fix for `Bug #2004555
|
||||
<https://bugs.launchpad.net/cinder/+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 <https://bugs.launchpad.net/cinder/+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
|
||||
<https://bugs.launchpad.net/cinder/+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.
|
Loading…
Reference in New Issue
Block a user