Require service token for bound ARQ operations
Users should not be able to directly delete or unbind ARQs that are bound to instances. Only Nova, identified by a valid service token, should perform these operations as part of instance lifecycle. Add is_service_request() check (following Cinder's OSSA-2023-003 pattern) hardcoded in the API layer so it cannot be bypassed via policy overrides. Unbound ARQs can still be deleted by their owner without a service token. Requires Nova [service_user] send_service_user_token=true and Cyborg [keystone_authtoken] service_token_roles_required=true. CVE-2026-40214 Closes-Bug: #2144056 Generated-By: Cursor claude-opus-4.6 Signed-off-by: Sean Mooney <work@seanmooney.info> Change-Id: I7ee74b61de044845756443fe7e2dc806b7a14f86
This commit is contained in:
@@ -31,6 +31,7 @@ from cyborg.api.controllers.v2 import versions
|
||||
from cyborg.common import authorize_wsgi
|
||||
from cyborg.common import constants
|
||||
from cyborg.common import exception
|
||||
from cyborg.common import service_token_utils
|
||||
from cyborg.common.i18n import _
|
||||
|
||||
|
||||
@@ -105,6 +106,39 @@ class ARQCollection(base.APIBase):
|
||||
return collection
|
||||
|
||||
|
||||
def _require_service_token(context, action):
|
||||
"""Raise if the request does not carry a valid service token.
|
||||
|
||||
Used for operations that must only be performed by Nova on
|
||||
behalf of a user (bind, unbind, delete-by-instance).
|
||||
"""
|
||||
if not service_token_utils.is_service_request(context):
|
||||
raise exception.Forbidden(
|
||||
message=_(
|
||||
'This operation requires a service token. '
|
||||
'Use the Compute API to manage instance accelerators.'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _check_bound_arq_service_token(context, extarq, action):
|
||||
"""Reject direct user operations on bound ARQs.
|
||||
|
||||
Once an ARQ has instance_uuid set, only Nova (identified
|
||||
by a service token) may modify or delete it.
|
||||
"""
|
||||
if extarq.arq.instance_uuid and not service_token_utils.is_service_request(
|
||||
context
|
||||
):
|
||||
raise exception.Forbidden(
|
||||
message=_(
|
||||
'ARQ %(arq)s is bound to instance %(instance)s. '
|
||||
'Use the Compute API to manage instance accelerators.'
|
||||
)
|
||||
% {'arq': extarq.arq.uuid, 'instance': extarq.arq.instance_uuid}
|
||||
)
|
||||
|
||||
|
||||
class ARQsController(base.CyborgController):
|
||||
"""REST controller for ARQs.
|
||||
|
||||
@@ -266,12 +300,15 @@ class ARQsController(base.CyborgController):
|
||||
)
|
||||
elif arqs:
|
||||
LOG.info("[arqs] delete. arqs=(%s)", arqs)
|
||||
pecan.request.conductor_api.arq_delete_by_uuid(context, arqs)
|
||||
arqlist = arqs.split(',')
|
||||
for arq_uuid in arqlist:
|
||||
extarq = objects.ExtARQ.get(context, arq_uuid)
|
||||
_check_bound_arq_service_token(context, extarq, 'delete')
|
||||
objects.ExtARQ.delete_by_uuid(context, arqlist)
|
||||
else: # instance is not None
|
||||
LOG.info("[arqs] delete. instance=(%s)", instance)
|
||||
pecan.request.conductor_api.arq_delete_by_instance_uuid(
|
||||
context, instance
|
||||
)
|
||||
_require_service_token(context, 'delete')
|
||||
objects.ExtARQ.delete_by_instance(context, instance)
|
||||
|
||||
def _validate_arq_patch(self, patch):
|
||||
"""Validate a single patch for an ARQ.
|
||||
@@ -393,7 +430,10 @@ class ARQsController(base.CyborgController):
|
||||
# associated with the instance specified in the binding.
|
||||
patch = list(patch_list.values())[0]
|
||||
if patch[0]['op'] == 'add':
|
||||
_require_service_token(context, 'update')
|
||||
self._check_if_already_bound(context, valid_fields)
|
||||
elif patch[0]['op'] == 'remove':
|
||||
_require_service_token(context, 'update')
|
||||
|
||||
pecan.request.conductor_api.arq_apply_patch(
|
||||
context, patch_list, valid_fields
|
||||
|
||||
@@ -21,6 +21,9 @@ from cyborg.common import rpc
|
||||
|
||||
def parse_args(argv, default_config_files=None):
|
||||
rpc.set_defaults(control_exchange='cyborg')
|
||||
cfg.CONF.set_default(
|
||||
'service_token_roles_required', True, group='keystone_authtoken'
|
||||
)
|
||||
version_string = version.version_info.release_string()
|
||||
cfg.CONF(
|
||||
argv[1:],
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Utilities for service token validation."""
|
||||
|
||||
from oslo_config import cfg
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
def is_service_request(ctxt):
|
||||
"""Check if a request is coming from a service.
|
||||
|
||||
A request is considered to come from a service if it has a
|
||||
service token and the service user has one of the roles
|
||||
configured in ``[keystone_authtoken] service_token_roles``
|
||||
(defaults to ``service``).
|
||||
|
||||
:param ctxt: The request context.
|
||||
:returns: True if the request has a valid service token.
|
||||
"""
|
||||
roles = ctxt.service_roles
|
||||
service_roles = set(CONF.keystone_authtoken.service_token_roles)
|
||||
return bool(roles and service_roles.intersection(roles))
|
||||
@@ -259,21 +259,25 @@ class TestARQsController(v2_test.APITestV2):
|
||||
exc.args[0],
|
||||
)
|
||||
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_delete_by_uuid')
|
||||
@mock.patch(
|
||||
'cyborg.conductor.rpcapi.ConductorAPI.arq_delete_by_instance_uuid'
|
||||
)
|
||||
def test_delete(self, mock_by_inst, mock_by_arq):
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
|
||||
@mock.patch('cyborg.objects.ExtARQ.get')
|
||||
@mock.patch('cyborg.common.service_token_utils.is_service_request')
|
||||
def test_delete_by_arq(self, mock_is_svc, mock_get, mock_delete):
|
||||
mock_is_svc.return_value = True
|
||||
url = self.ARQ_URL
|
||||
arq = self.fake_extarqs[0].arq
|
||||
instance = arq.instance_uuid
|
||||
|
||||
mock_by_arq.return_value = None
|
||||
mock_get.return_value = self.fake_extarqs[0]
|
||||
args = '?' + "arqs=" + str(arq['uuid'])
|
||||
response = self.delete(url + args, headers=self.headers)
|
||||
self.assertEqual(HTTPStatus.NO_CONTENT, response.status_int)
|
||||
|
||||
mock_by_inst.return_value = None
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
|
||||
@mock.patch('cyborg.common.service_token_utils.is_service_request')
|
||||
def test_delete_by_instance(self, mock_is_svc, mock_delete):
|
||||
mock_is_svc.return_value = True
|
||||
url = self.ARQ_URL
|
||||
arq = self.fake_extarqs[0].arq
|
||||
instance = arq.instance_uuid
|
||||
args = '?' + "instance=" + instance
|
||||
response = self.delete(url + args, headers=self.headers)
|
||||
self.assertEqual(HTTPStatus.NO_CONTENT, response.status_int)
|
||||
@@ -295,10 +299,16 @@ class TestARQsController(v2_test.APITestV2):
|
||||
# now, improve this case with assertRaises later.
|
||||
self.assertIn("Bad response: 403 Forbidden", exc.args[0])
|
||||
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.service_token_utils.is_service_request'
|
||||
)
|
||||
@mock.patch.object(arqs.ARQsController, '_check_if_already_bound')
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
|
||||
def test_apply_patch(self, mock_apply_patch, mock_check_if_bound):
|
||||
def test_apply_patch(
|
||||
self, mock_apply_patch, mock_check_if_bound, mock_is_svc
|
||||
):
|
||||
"""Test the happy path for ARQ bind (patch)."""
|
||||
mock_is_svc.return_value = True
|
||||
patch_list, device_rp_uuid = fake_extarq.get_patch_list()
|
||||
arq_uuids = list(patch_list.keys())
|
||||
obj_extarq = self.fake_extarqs[0]
|
||||
@@ -319,11 +329,15 @@ class TestARQsController(v2_test.APITestV2):
|
||||
)
|
||||
mock_check_if_bound.assert_called_once_with(mock.ANY, valid_fields)
|
||||
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.service_token_utils.is_service_request'
|
||||
)
|
||||
@mock.patch.object(arqs.ARQsController, '_check_if_already_bound')
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
|
||||
def test_apply_patch_allow_project_id(
|
||||
self, mock_apply_patch, mock_check_if_bound
|
||||
self, mock_apply_patch, mock_check_if_bound, mock_is_svc
|
||||
):
|
||||
mock_is_svc.return_value = True
|
||||
patch_list, _ = fake_extarq.get_patch_list()
|
||||
explicit_pid = str(uuids.explicit_project)
|
||||
for arq_uuid, patch in patch_list.items():
|
||||
@@ -687,3 +701,268 @@ class TestARQProjectIsolation(v2_test.APITestV2):
|
||||
uuid = extarq.arq['uuid']
|
||||
out = self.get_json(self.ARQ_URL + '/%s' % uuid, headers=headers)
|
||||
self.assertEqual(uuid, out['uuid'])
|
||||
|
||||
|
||||
class TestARQServiceTokenProtection(v2_test.APITestV2):
|
||||
"""Tests for Bug #2144056: service token requirement for bound ARQs.
|
||||
|
||||
Binding (add), unbinding (remove), and deleting a bound ARQ all
|
||||
require a service token so that only Nova can perform these
|
||||
operations on behalf of the user.
|
||||
"""
|
||||
|
||||
ARQ_URL = '/accelerator_requests'
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
resolved = fake_extarq.get_fake_extarq_resolved_objs()
|
||||
self.unbound_extarq = resolved[0]
|
||||
self.bound_extarq = resolved[1]
|
||||
|
||||
def _member_headers(self):
|
||||
headers = self.gen_headers(
|
||||
cyborg_context.RequestContext(
|
||||
user_id=str(uuids.member_user),
|
||||
project_id=str(uuids.member_project),
|
||||
is_admin=False,
|
||||
)
|
||||
)
|
||||
headers['X-Roles'] = 'member'
|
||||
return headers
|
||||
|
||||
def _service_token_headers(self):
|
||||
headers = self._member_headers()
|
||||
headers['X-Service-Roles'] = 'service'
|
||||
headers['X-Service-Token'] = 'fake-service-token'
|
||||
return headers
|
||||
|
||||
# -- DELETE tests ------------------------------------------------
|
||||
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
|
||||
@mock.patch('cyborg.objects.ExtARQ.get')
|
||||
def test_delete_bound_arq_without_service_token_rejected(
|
||||
self, mock_get, mock_delete
|
||||
):
|
||||
mock_get.return_value = self.bound_extarq
|
||||
headers = self._member_headers()
|
||||
uuid = self.bound_extarq.arq['uuid']
|
||||
response = self.delete(
|
||||
self.ARQ_URL + '?arqs=%s' % uuid,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
|
||||
@mock.patch('cyborg.objects.ExtARQ.get')
|
||||
def test_delete_bound_arq_with_service_token_succeeds(
|
||||
self, mock_get, mock_delete
|
||||
):
|
||||
mock_get.return_value = self.bound_extarq
|
||||
headers = self._service_token_headers()
|
||||
uuid = self.bound_extarq.arq['uuid']
|
||||
response = self.delete(
|
||||
self.ARQ_URL + '?arqs=%s' % uuid,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(204, response.status_int)
|
||||
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_uuid')
|
||||
@mock.patch('cyborg.objects.ExtARQ.get')
|
||||
def test_delete_unbound_arq_without_service_token_succeeds(
|
||||
self, mock_get, mock_delete
|
||||
):
|
||||
mock_get.return_value = self.unbound_extarq
|
||||
headers = self._member_headers()
|
||||
uuid = self.unbound_extarq.arq['uuid']
|
||||
response = self.delete(
|
||||
self.ARQ_URL + '?arqs=%s' % uuid,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(204, response.status_int)
|
||||
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
|
||||
def test_delete_by_instance_without_service_token_rejected(
|
||||
self, mock_delete
|
||||
):
|
||||
headers = self._member_headers()
|
||||
response = self.delete(
|
||||
self.ARQ_URL + '?instance=%s' % str(uuids.instance1),
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
|
||||
def test_delete_by_instance_with_service_token_succeeds(self, mock_delete):
|
||||
headers = self._service_token_headers()
|
||||
response = self.delete(
|
||||
self.ARQ_URL + '?instance=%s' % str(uuids.instance1),
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(204, response.status_int)
|
||||
|
||||
@mock.patch('cyborg.common.service_token_utils.is_service_request')
|
||||
@mock.patch('cyborg.objects.ExtARQ.delete_by_instance')
|
||||
def test_service_token_check_not_bypassable_by_policy(
|
||||
self, mock_delete, mock_is_service
|
||||
):
|
||||
"""Even with admin role, bound ARQ delete requires service token."""
|
||||
mock_is_service.return_value = False
|
||||
headers = self.gen_headers(self.context)
|
||||
response = self.delete(
|
||||
self.ARQ_URL + '?instance=%s' % str(uuids.instance1),
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
|
||||
mock_delete.assert_not_called()
|
||||
|
||||
# -- PATCH (bind / unbind) tests ---------------------------------
|
||||
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
|
||||
)
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
|
||||
def test_bind_without_service_token_rejected(
|
||||
self, mock_apply, mock_validate
|
||||
):
|
||||
"""Binding (setting instance_uuid) requires a service token."""
|
||||
mock_validate.return_value = {
|
||||
'hostname': 'fake-host',
|
||||
'device_rp_uuid': str(uuids.device_rp),
|
||||
'instance_uuid': str(uuids.instance1),
|
||||
}
|
||||
arq_uuid = self.unbound_extarq.arq['uuid']
|
||||
patch_list = {
|
||||
arq_uuid: [
|
||||
{'path': '/hostname', 'op': 'add', 'value': 'fake-host'},
|
||||
{
|
||||
'path': '/device_rp_uuid',
|
||||
'op': 'add',
|
||||
'value': str(uuids.device_rp),
|
||||
},
|
||||
{
|
||||
'path': '/instance_uuid',
|
||||
'op': 'add',
|
||||
'value': str(uuids.instance1),
|
||||
},
|
||||
]
|
||||
}
|
||||
headers = self._member_headers()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
response = self.patch_json(
|
||||
self.ARQ_URL,
|
||||
params=patch_list,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
|
||||
mock_apply.assert_not_called()
|
||||
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.ARQsController._check_if_already_bound'
|
||||
)
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
|
||||
)
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
|
||||
def test_bind_with_service_token_succeeds(
|
||||
self, mock_apply, mock_validate, mock_check_bound
|
||||
):
|
||||
mock_validate.return_value = {
|
||||
'hostname': 'fake-host',
|
||||
'device_rp_uuid': str(uuids.device_rp),
|
||||
'instance_uuid': str(uuids.instance1),
|
||||
}
|
||||
arq_uuid = self.unbound_extarq.arq['uuid']
|
||||
patch_list = {
|
||||
arq_uuid: [
|
||||
{'path': '/hostname', 'op': 'add', 'value': 'fake-host'},
|
||||
{
|
||||
'path': '/device_rp_uuid',
|
||||
'op': 'add',
|
||||
'value': str(uuids.device_rp),
|
||||
},
|
||||
{
|
||||
'path': '/instance_uuid',
|
||||
'op': 'add',
|
||||
'value': str(uuids.instance1),
|
||||
},
|
||||
]
|
||||
}
|
||||
headers = self._service_token_headers()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
response = self.patch_json(
|
||||
self.ARQ_URL,
|
||||
params=patch_list,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(202, response.status_int)
|
||||
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
|
||||
)
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
|
||||
def test_unbind_without_service_token_rejected(
|
||||
self, mock_apply, mock_validate
|
||||
):
|
||||
mock_validate.return_value = {
|
||||
'hostname': None,
|
||||
'device_rp_uuid': None,
|
||||
'instance_uuid': None,
|
||||
}
|
||||
arq_uuid = self.bound_extarq.arq['uuid']
|
||||
patch_list = {
|
||||
arq_uuid: [
|
||||
{'path': '/hostname', 'op': 'remove', 'value': ''},
|
||||
{'path': '/device_rp_uuid', 'op': 'remove', 'value': ''},
|
||||
{'path': '/instance_uuid', 'op': 'remove', 'value': ''},
|
||||
]
|
||||
}
|
||||
headers = self._member_headers()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
response = self.patch_json(
|
||||
self.ARQ_URL,
|
||||
params=patch_list,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(HTTPStatus.FORBIDDEN, response.status_int)
|
||||
mock_apply.assert_not_called()
|
||||
|
||||
@mock.patch(
|
||||
'cyborg.api.controllers.v2.arqs.ARQsController._validate_arq_patch'
|
||||
)
|
||||
@mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_apply_patch')
|
||||
def test_unbind_with_service_token_succeeds(
|
||||
self, mock_apply, mock_validate
|
||||
):
|
||||
mock_validate.return_value = {
|
||||
'hostname': None,
|
||||
'device_rp_uuid': None,
|
||||
'instance_uuid': None,
|
||||
}
|
||||
arq_uuid = self.bound_extarq.arq['uuid']
|
||||
patch_list = {
|
||||
arq_uuid: [
|
||||
{'path': '/hostname', 'op': 'remove', 'value': ''},
|
||||
{'path': '/device_rp_uuid', 'op': 'remove', 'value': ''},
|
||||
{'path': '/instance_uuid', 'op': 'remove', 'value': ''},
|
||||
]
|
||||
}
|
||||
headers = self._service_token_headers()
|
||||
headers['Content-Type'] = 'application/json'
|
||||
response = self.patch_json(
|
||||
self.ARQ_URL,
|
||||
params=patch_list,
|
||||
headers=headers,
|
||||
expect_errors=True,
|
||||
)
|
||||
self.assertEqual(202, response.status_int)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
||||
# not use this file except in compliance with the License. You may obtain
|
||||
# a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""Tests for service token validation utilities."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from oslo_config import cfg
|
||||
|
||||
from cyborg.common import service_token_utils
|
||||
from cyborg.tests import base
|
||||
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
class TestIsServiceRequest(base.TestCase):
|
||||
"""Tests for is_service_request() helper."""
|
||||
|
||||
def _make_context(self, service_roles=None):
|
||||
ctx = mock.MagicMock()
|
||||
ctx.service_roles = service_roles or []
|
||||
return ctx
|
||||
|
||||
def test_with_service_role(self):
|
||||
ctx = self._make_context(service_roles=['service'])
|
||||
self.assertTrue(service_token_utils.is_service_request(ctx))
|
||||
|
||||
def test_without_service_token(self):
|
||||
ctx = self._make_context(service_roles=[])
|
||||
self.assertFalse(service_token_utils.is_service_request(ctx))
|
||||
|
||||
def test_with_none_service_roles(self):
|
||||
ctx = self._make_context(service_roles=None)
|
||||
self.assertFalse(service_token_utils.is_service_request(ctx))
|
||||
|
||||
def test_with_wrong_role(self):
|
||||
ctx = self._make_context(service_roles=['member'])
|
||||
self.assertFalse(service_token_utils.is_service_request(ctx))
|
||||
|
||||
def test_with_multiple_roles_including_service(self):
|
||||
ctx = self._make_context(service_roles=['admin', 'service'])
|
||||
self.assertTrue(service_token_utils.is_service_request(ctx))
|
||||
|
||||
def test_with_custom_config(self):
|
||||
CONF.set_override(
|
||||
'service_token_roles', ['custom_role'], group='keystone_authtoken'
|
||||
)
|
||||
ctx = self._make_context(service_roles=['custom_role'])
|
||||
self.assertTrue(service_token_utils.is_service_request(ctx))
|
||||
|
||||
def test_custom_config_no_match(self):
|
||||
CONF.set_override(
|
||||
'service_token_roles', ['custom_role'], group='keystone_authtoken'
|
||||
)
|
||||
ctx = self._make_context(service_roles=['service'])
|
||||
self.assertFalse(service_token_utils.is_service_request(ctx))
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
security:
|
||||
- |
|
||||
This issue is assigned CVE-2026-40214.
|
||||
|
||||
Fixed a cross-tenant access control vulnerability in accelerator
|
||||
request (ARQ) management. The ``project_id`` field was never
|
||||
populated on ARQ records, which meant non-admin users could list,
|
||||
view, and delete ARQs belonging to other projects. This could
|
||||
lead to information disclosure (leaking instance UUIDs across
|
||||
tenants) and denial of service (deleting another tenant's ARQ
|
||||
prevents their instance from restarting).
|
||||
|
||||
ARQs are now scoped to the requesting project. Non-admin users
|
||||
can only see and manage their own project's ARQs.
|
||||
|
||||
Additionally, binding, unbinding, and deleting bound ARQs now
|
||||
require a service token. Only Nova, identified by a valid
|
||||
service token with the ``service`` role, may set or clear the
|
||||
``instance_uuid`` on an ARQ or delete a bound ARQ. This
|
||||
prevents users from directly manipulating ARQs that Nova is
|
||||
managing, following the same pattern as the Cinder
|
||||
OSSA-2023-003 fix.
|
||||
upgrade:
|
||||
- |
|
||||
Nova must be configured with
|
||||
``[service_user] send_service_user_token = true`` for Cyborg to
|
||||
accept bound-ARQ operations (bind, unbind, delete). This is the same
|
||||
requirement as for Cinder volume attachments since OSSA-2023-003.
|
||||
|
||||
Cyborg now defaults
|
||||
``[keystone_authtoken] service_token_roles_required`` to ``true``
|
||||
so that keystonemiddleware validates the service token roles.
|
||||
Operators who have not already set this should ensure the service
|
||||
user has the ``service`` role in Keystone.
|
||||
Reference in New Issue
Block a user