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:
Sean Mooney
2026-03-13 14:48:28 +00:00
parent ebfe437109
commit 9e38e24905
6 changed files with 472 additions and 15 deletions
+44 -4
View File
@@ -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
+3
View File
@@ -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:],
+34
View File
@@ -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))
+290 -11
View File
@@ -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.