Version in base suite: 14.0.0-3 Base version: cyborg_14.0.0-3 Target version: cyborg_14.0.0-3+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/c/cyborg/cyborg_14.0.0-3.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/c/cyborg/cyborg_14.0.0-3+deb13u1.dsc changelog | 29 patches/CVE-2026-40213_CVE-2026-40214_1_Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch | 159 + patches/CVE-2026-40213_CVE-2026-40214_2_Fix_cyborg-status_upgrade_check_tests.patch | 66 patches/CVE-2026-40213_CVE-2026-40214_3_Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch | 1121 +++++++++ patches/CVE-2026-40213_CVE-2026-40214_4_Set_project_id_on_ARQ_creation_and_binding.patch | 493 ++++ patches/CVE-2026-40213_CVE-2026-40214_5_Refactor_session_handling_and_align_test_contexts.patch | 1190 ++++++++++ patches/CVE-2026-40213_CVE-2026-40214_6_Add_project_id_backfill_for_existing_ARQs.patch | 879 +++++++ patches/CVE-2026-40213_CVE-2026-40214_7_Enforce_project-scoped_access_for_ARQs.patch | 513 ++++ patches/CVE-2026-40213_CVE-2026-40214_8_Require_service_token_for_bound_ARQ_operations.patch | 617 +++++ patches/series | 8 10 files changed, 5075 insertions(+) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpa9es6ine/cyborg_14.0.0-3.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpa9es6ine/cyborg_14.0.0-3+deb13u1.dsc: no acceptable signature found diff -Nru cyborg-14.0.0/debian/changelog cyborg-14.0.0/debian/changelog --- cyborg-14.0.0/debian/changelog 2025-07-12 08:23:11.000000000 +0000 +++ cyborg-14.0.0/debian/changelog 2026-05-11 08:00:13.000000000 +0000 @@ -1,3 +1,32 @@ +cyborg (14.0.0-3+deb13u1) trixie-security; urgency=medium + + * CVE-2026-40213: Cyborg uses rule:allow (check_str='@') as the default + policy for multiple API endpoints. This unconditionally authorizes any + request carrying a valid Keystone token regardless of roles, project + membership, or scope. An authenticated user with zero role assignments can + complete various actions such as reprogramming FPGA bitstreams on arbitrary + compute nodes via agent RPC. + CVE-2026-40214: The Accelerator Request (ARQ) API does not enforce project + ownership at any layer. The project_id column in the database is never + populated (NULL for every ARQ), database queries have no project filtering, + and policy checks are self-referential (the authorize_wsgi decorator + compares the caller's project_id with itself rather than the target + resource). Any authenticated non-admin user can complete various actions + such as deleting ARQs bound to other projects' instances, aka cross-tenant + denial of service. + Applied upstream patches: + - Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch + - Fix_cyborg-status_upgrade_check_tests.patch + - Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch + - Set_project_id_on_ARQ_creation_and_binding.patch + - Refactor_session_handling_and_align_test_contexts.patch + - Add_project_id_backfill_for_existing_ARQs.patch + - Enforce_project-scoped_access_for_ARQs.patch + - Require_service_token_for_bound_ARQ_operations.patch + (Closes: #1136006). + + -- Thomas Goirand Mon, 11 May 2026 10:00:13 +0200 + cyborg (14.0.0-3) unstable; urgency=medium * Add export OS_OSLO_MESSAGING_RABBIT__PROCESSNAME in all daemons. diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_1_Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_1_Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_1_Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_1_Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,159 @@ +Author: Chandan Kumar (raukadah) +Date: Wed, 11 Mar 2026 10:18:16 +0000 +Descriptino: CVE-2026-40213 / CVE-2026-40214: Use common_checks.check_policy_json from oslo.upgradecheck + The hand-rolled _check_policy_json in cyborg-status fails with + NoSuchOptError because oslo_policy config options are never + registered. Replace it with common_checks.check_policy_json + from oslo.upgradecheck[1] which handles option registration + internally. + . + [1] https://review.opendev.org/c/openstack/oslo.upgradecheck/+/763484 + . +Bug: https://launchpad.net/bugs/2143734 +Bug-Debian: https://bugs.debian.org/1136006 +Generated-By: Cursor(claude-4.6-opus) +Note: It addresses the merge conflicts occured during cherry-pick. +Change-Id: Id2f06cb5a2abb783340a9805c07d08d723f77de2 +Signed-off-by: Chandan Kumar (raukadah) +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/980294 +Last-Update: 2026-05-11 + +diff --git a/cyborg/cmd/status.py b/cyborg/cmd/status.py +index 63925b7..069493d 100644 +--- a/cyborg/cmd/status.py ++++ b/cyborg/cmd/status.py +@@ -15,8 +15,8 @@ + import sys + + from oslo_config import cfg ++from oslo_upgradecheck import common_checks + from oslo_upgradecheck import upgradecheck +-from oslo_utils import fileutils + + from cyborg.common.i18n import _ + +@@ -30,35 +30,12 @@ + and added to _upgrade_checks tuple. + """ + +- def _check_policy_json(self): +- "Checks to see if policy file is JSON-formatted policy file." +- msg = _("Your policy file is JSON-formatted which is " +- "deprecated since Victoria release (Cyborg 5.0.0). " +- "You need to switch to YAML-formatted file. You can use the " +- "``oslopolicy-convert-json-to-yaml`` tool to convert existing " +- "JSON-formatted files to YAML-formatted files in a " +- "backwards-compatible manner: " +- "https://docs.openstack.org/oslo.policy/" +- "latest/cli/oslopolicy-convert-json-to-yaml.html.") +- status = upgradecheck.Result(upgradecheck.Code.SUCCESS) +- # NOTE(gmann): Check if policy file exist and is in +- # JSON format by actually loading the file not just +- # by checking the extension. +- policy_path = CONF.find_file(CONF.oslo_policy.policy_file) +- if policy_path and fileutils.is_json(policy_path): +- status = upgradecheck.Result(upgradecheck.Code.FAILURE, msg) +- return status +- +- # The format of the check functions is to return an +- # oslo_upgradecheck.upgradecheck.Result +- # object with the appropriate +- # oslo_upgradecheck.upgradecheck.Code and details set. +- # If the check hits warnings or failures then those should be stored +- # in the returned Result's "details" attribute. The +- # summary will be rolled up at the end of the check() method. + _upgrade_checks = ( + # Added in Victoria +- (_('Policy File JSON to YAML Migration'), _check_policy_json), ++ ( ++ _('Policy File JSON to YAML Migration'), ++ (common_checks.check_policy_json, {'conf': CONF}), ++ ), + ) + + +diff --git a/cyborg/tests/unit/cmd/test_status.py b/cyborg/tests/unit/cmd/test_status.py +index 90900a5..a1ca9bd 100644 +--- a/cyborg/tests/unit/cmd/test_status.py ++++ b/cyborg/tests/unit/cmd/test_status.py +@@ -19,23 +19,19 @@ + + from oslo_config import cfg + from oslo_serialization import jsonutils ++from oslo_upgradecheck import common_checks + from oslo_upgradecheck import upgradecheck + + from cyborg.cmd import status +-from cyborg.common import authorize_wsgi + from cyborg.tests import base + + + class TestUpgradeCheckPolicyJSON(base.TestCase): + + def setUp(self): +- super(TestUpgradeCheckPolicyJSON, self).setUp() +- self.cmd = status.UpgradeCommands() +- authorize_wsgi.CONF.clear_override('policy_file', group='oslo_policy') +- self.data = { +- 'rule_admin': 'True', +- 'rule_admin2': 'is_admin:True' +- } ++ super().setUp() ++ self.cmd = status.Checks() ++ self.data = {'rule_admin': 'True', 'rule_admin2': 'is_admin:True'} + self.temp_dir = self.useFixture(fixtures.TempDir()) + fd, self.json_file = tempfile.mkstemp(dir=self.temp_dir.path) + fd, self.yaml_file = tempfile.mkstemp(dir=self.temp_dir.path) +@@ -55,34 +51,42 @@ + 'oslo_config.cfg._search_dirs')).mock + self.mock_search.side_effect = fake_search_dirs + ++ def _check_policy_json(self): ++ return common_checks.check_policy_json(self.cmd, cfg.CONF) ++ + def test_policy_json_file_fail_upgrade(self): + # Test with policy json file full path set in config. + self.flags(policy_file=self.json_file, group="oslo_policy") +- self.assertEqual(upgradecheck.Code.FAILURE, +- self.cmd._check_policy_json().code) ++ self.assertEqual( ++ upgradecheck.Code.FAILURE, self._check_policy_json().code ++ ) + + def test_policy_yaml_file_pass_upgrade(self): + # Test with full policy yaml file path set in config. + self.flags(policy_file=self.yaml_file, group="oslo_policy") +- self.assertEqual(upgradecheck.Code.SUCCESS, +- self.cmd._check_policy_json().code) ++ self.assertEqual( ++ upgradecheck.Code.SUCCESS, self._check_policy_json().code ++ ) + + def test_no_policy_file_pass_upgrade(self): + # Test with no policy file exist. +- self.assertEqual(upgradecheck.Code.SUCCESS, +- self.cmd._check_policy_json().code) ++ self.assertEqual( ++ upgradecheck.Code.SUCCESS, self._check_policy_json().code ++ ) + + def test_default_policy_yaml_file_pass_upgrade(self): + tmpfilename = os.path.join(self.temp_dir.path, 'policy.yaml') + with open(tmpfilename, 'w') as fh: + yaml.dump(self.data, fh) +- self.assertEqual(upgradecheck.Code.SUCCESS, +- self.cmd._check_policy_json().code) ++ self.assertEqual( ++ upgradecheck.Code.SUCCESS, self._check_policy_json().code ++ ) + + def test_old_default_policy_json_file_fail_upgrade(self): + self.flags(policy_file='policy.json', group="oslo_policy") + tmpfilename = os.path.join(self.temp_dir.path, 'policy.json') + with open(tmpfilename, 'w') as fh: + jsonutils.dump(self.data, fh) +- self.assertEqual(upgradecheck.Code.FAILURE, +- self.cmd._check_policy_json().code) ++ self.assertEqual( ++ upgradecheck.Code.FAILURE, self._check_policy_json().code ++ ) diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_2_Fix_cyborg-status_upgrade_check_tests.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_2_Fix_cyborg-status_upgrade_check_tests.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_2_Fix_cyborg-status_upgrade_check_tests.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_2_Fix_cyborg-status_upgrade_check_tests.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,66 @@ +Author: Sean Mooney +Date: Sun, 26 Apr 2026 18:26:01 +0000 +Description: CVE-2026-40213 / CVE-2026-40214: Fix cyborg-status upgrade check tests + Move the flags() config-override helper from the API-specific + BaseApiTest up to cyborg.tests.base.TestCase so all unit tests + can use it. This is needed by TestUpgradeCheckPolicyJSON which + inherits from base.TestCase, not BaseApiTest. + . + Also clear the policy_file config override set by PolicyFixture + at the start of TestUpgradeCheckPolicyJSON.setUp so that + common_checks.check_policy_json starts from a clean slate + instead of seeing the fixture's policy file. + . +Generated-By: cursor opus 4.6 +Change-Id: I7d1e25aa9bdcef8e9b21953591dd8ef28e8c4d3a +Signed-off-by: Sean Mooney +Bug-Debian: https://bugs.debian.org/1136006 +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/987698 +Last-Update: 2026-05-11 + +Index: cyborg/cyborg/tests/base.py +=================================================================== +--- cyborg.orig/cyborg/tests/base.py ++++ cyborg/cyborg/tests/base.py +@@ -65,6 +65,12 @@ class TestCase(base.BaseTestCase): + """Override config options for a test.""" + self.cfg_fixture.config(**kw) + ++ def flags(self, **kw): ++ """Override flag variables for a test.""" ++ group = kw.pop('group', None) ++ for k, v in kw.items(): ++ cfg.CONF.set_override(k, v, group) ++ + def set_defaults(self, **kw): + """Set default values of config options.""" + group = kw.pop('group', None) +Index: cyborg/cyborg/tests/unit/api/base.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/api/base.py ++++ cyborg/cyborg/tests/unit/api/base.py +@@ -48,12 +48,6 @@ class BaseApiTest(base.DbTestCase): + + self.addCleanup(reset_pecan) + +- def flags(self, **kw): +- """Override flag variables for a test.""" +- group = kw.pop('group', None) +- for k, v in kw.items(): +- cfg.CONF.set_override(k, v, group) +- + def _make_app(self): + # Determine where we are so we can set up paths in the config + root_dir = self.get_path() +Index: cyborg/cyborg/tests/unit/cmd/test_status.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/cmd/test_status.py ++++ cyborg/cyborg/tests/unit/cmd/test_status.py +@@ -31,6 +31,7 @@ class TestUpgradeCheckPolicyJSON(base.Te + def setUp(self): + super().setUp() + self.cmd = status.Checks() ++ cfg.CONF.clear_override('policy_file', group='oslo_policy') + self.data = {'rule_admin': 'True', 'rule_admin2': 'is_admin:True'} + self.temp_dir = self.useFixture(fixtures.TempDir()) + fd, self.json_file = tempfile.mkstemp(dir=self.temp_dir.path) diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_3_Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_3_Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_3_Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_3_Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,1121 @@ +Author: Sean Mooney +Date: Sun, 26 Apr 2026 18:24:39 +0000 +Description: CVE-2026-40213 / CVE-2026-40214: Fix rule:allow policy bypass on device/deployable/attribute APIs + Ten API endpoints in cyborg/common/policy.py used check_str='rule:allow' + (@), which unconditionally authorises any authenticated Keystone user + regardless of role, project membership, or scope. This allowed any + tenant to enumerate the full accelerator hardware topology and trigger + privileged operations including FPGA reprogramming and hardware metadata + mutation. + . + Replace the unconditional rule:allow with role-checked rules available + on all maintained stable branches: + . + cyborg:arq:create rule:allow -> rule:project_member_or_admin + cyborg:device:get_one rule:allow -> rule:admin_api + cyborg:device:get_all rule:allow -> rule:admin_api + cyborg:deployable:get_one rule:allow -> rule:admin_api + cyborg:deployable:get_all rule:allow -> rule:admin_api + cyborg:deployable:program rule:allow -> rule:admin_api + cyborg:attribute:get_one rule:allow -> rule:admin_api + cyborg:attribute:get_all rule:allow -> rule:admin_api + cyborg:attribute:create rule:allow -> rule:admin_api + cyborg:attribute:delete rule:allow -> rule:admin_api + . + arq:create receives project_member_or_admin rather than admin_api + because Nova forwards the end-user token when creating ARQs; admin_api + would break all non-admin instance launches. + . + Also remove the dead fpga_policies group (cyborg:fpga:{get_one, + get_all,update}) whose rules were registered but never evaluated at + runtime as no /v2/fpgas endpoint exists. + . + Add unit tests in cyborg/tests/unit/policies/ covering authorised and + unauthorised contexts for each affected endpoint group, following the + pattern established by test_device_profiles.py. + . + CVE-2026-40213 + . +Bug: https://launchpad.net/bugs/2143263 +Bug-Debian: https://bugs.debian.org/1136006 +Assisted-By: claude-code sonnet 4.6 +Change-Id: I56f04adcfe270f02dfd6511a1aea1074e3d2dedb +Signed-off-by: Sean Mooney +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/987699 +Last-Update: 2026-05-11 + +diff --git a/.zuul.yaml b/.zuul.yaml +index 493f548..c494844 100644 +--- a/.zuul.yaml ++++ b/.zuul.yaml +@@ -7,14 +7,18 @@ + - openstack-python3-jobs + check: + jobs: +- - cyborg-tempest +- - cyborg-tempest-jammy +- - cyborg-tempest-ipv6-only ++ - cyborg-tempest: ++ vars: ++ tempest_exclude_regex: test_delete_accelerator_request_by_instance_uuid ++ - cyborg-tempest-ipv6-only: ++ vars: ++ tempest_exclude_regex: test_delete_accelerator_request_by_instance_uuid + - cyborg-tox-bandit + gate: + jobs: +- - cyborg-tempest +- - cyborg-tempest-jammy ++ - cyborg-tempest: ++ vars: ++ tempest_exclude_regex: test_delete_accelerator_request_by_instance_uuid + + - job: + name: cyborg-tox-bandit +diff --git a/cyborg/common/authorize_wsgi.py b/cyborg/common/authorize_wsgi.py +index 590157c..ba181b4 100644 +--- a/cyborg/common/authorize_wsgi.py ++++ b/cyborg/common/authorize_wsgi.py +@@ -64,6 +64,14 @@ + if suppress_deprecation_warnings: + _ENFORCER.suppress_deprecation_warnings = True + _ENFORCER.register_defaults(policies.list_policies()) ++ if not CONF.oslo_policy.enforce_scope: ++ LOG.warning( ++ 'oslo_policy.enforce_scope is disabled. System-scoped tokens ' ++ 'will be accepted by Cyborg APIs, bypassing project-level ' ++ 'isolation. This is a security risk. Operators should carefully ' ++ 'review their security posture before disabling scope ' ++ 'enforcement.' ++ ) + + + def get_enforcer(): +@@ -89,8 +97,11 @@ + """ + enforcer = get_enforcer() + try: +- return enforcer.authorize(rule, target, creds, do_raise=do_raise, +- *args, **kwargs) ++ return enforcer.authorize( ++ rule, target, creds, do_raise=do_raise, *args, **kwargs ++ ) ++ except policy.InvalidScope: ++ raise exception.HTTPForbidden(resource=rule) + except policy.PolicyNotAuthorized: + raise exception.HTTPForbidden(resource=rule) + +@@ -137,8 +148,6 @@ + context = pecan.request.context + credentials = context.to_policy_values() + credentials['is_admin'] = context.is_admin +- if context.system_scope == 'all': +- credentials['system'] = True + target = {} + # maybe we can pass "_get_resource" to authorize_wsgi + if need_target and hasattr(self, "_get_resource"): +diff --git a/cyborg/common/policy.py b/cyborg/common/policy.py +index 746bb11..7951c45 100644 +--- a/cyborg/common/policy.py ++++ b/cyborg/common/policy.py +@@ -24,73 +24,109 @@ + # depend on their existence throughout the code. + + accelerator_request_policies = [ +- policy.RuleDefault('cyborg:arq:get_all', +- 'rule:default', +- description='Retrieve accelerator request records.'), +- policy.RuleDefault('cyborg:arq:get_one', +- 'rule:default', +- description='Get an accelerator request record.'), +- policy.RuleDefault('cyborg:arq:create', +- 'rule:allow', +- description='Create accelerator request records.'), +- policy.RuleDefault('cyborg:arq:delete', +- 'rule:default', +- description='Delete accelerator request records.'), +- policy.RuleDefault('cyborg:arq:update', +- 'rule:default', +- description='Update accelerator request records.'), ++ policy.RuleDefault( ++ 'cyborg:arq:get_all', ++ 'rule:default', ++ description='Retrieve accelerator request records.', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:arq:get_one', ++ 'rule:default', ++ description='Get an accelerator request record.', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:arq:create', ++ 'rule:project_member_or_admin', ++ description='Create accelerator request records.', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:arq:delete', ++ 'rule:default', ++ description='Delete accelerator request records.', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:arq:update', ++ 'rule:default', ++ description='Update accelerator request records.', ++ scope_types=['project'], ++ ), + ] + + device_policies = [ +- policy.RuleDefault('cyborg:device:get_one', +- 'rule:allow', +- description='Show device detail'), +- policy.RuleDefault('cyborg:device:get_all', +- 'rule:allow', +- description='Retrieve all device records'), +- policy.RuleDefault('cyborg:device:disable', +- 'rule:admin_api', +- description='Disable a device'), +- policy.RuleDefault('cyborg:device:enable', +- 'rule:admin_api', +- description='Enable a device'), ++ policy.RuleDefault( ++ 'cyborg:device:get_one', ++ 'rule:admin_api', ++ description='Show device detail', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:device:get_all', ++ 'rule:admin_api', ++ description='Retrieve all device records', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:device:disable', ++ 'rule:admin_api', ++ description='Disable a device', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:device:enable', ++ 'rule:admin_api', ++ description='Enable a device', ++ scope_types=['project'], ++ ), + ] + + deployable_policies = [ +- policy.RuleDefault('cyborg:deployable:get_one', +- 'rule:allow', +- description='Show deployable detail'), +- policy.RuleDefault('cyborg:deployable:get_all', +- 'rule:allow', +- description='Retrieve all deployable records'), +- policy.RuleDefault('cyborg:deployable:program', +- 'rule:allow', +- description='FPGA programming.'), ++ policy.RuleDefault( ++ 'cyborg:deployable:get_one', ++ 'rule:admin_api', ++ description='Show deployable detail', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:deployable:get_all', ++ 'rule:admin_api', ++ description='Retrieve all deployable records', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:deployable:program', ++ 'rule:admin_api', ++ description='FPGA programming.', ++ scope_types=['project'], ++ ), + ] + + attribute_policies = [ +- policy.RuleDefault('cyborg:attribute:get_one', +- 'rule:allow', +- description='Show attribute detail'), +- policy.RuleDefault('cyborg:attribute:get_all', +- 'rule:allow', +- description='Retrieve all attribute records'), +- policy.RuleDefault('cyborg:attribute:create', +- 'rule:allow', +- description='Create an attribute record'), +- policy.RuleDefault('cyborg:attribute:delete', +- 'rule:allow', +- description='Delete attribute records.'), +-] +- +-fpga_policies = [ +- policy.RuleDefault('cyborg:fpga:get_one', +- 'rule:allow', +- description='Show fpga detail'), +- policy.RuleDefault('cyborg:fpga:get_all', +- 'rule:allow', +- description='Retrieve all fpga records'), +- policy.RuleDefault('cyborg:fpga:update', +- 'rule:allow', +- description='Update fpga records'), ++ policy.RuleDefault( ++ 'cyborg:attribute:get_one', ++ 'rule:admin_api', ++ description='Show attribute detail', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:attribute:get_all', ++ 'rule:admin_api', ++ description='Retrieve all attribute records', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:attribute:create', ++ 'rule:admin_api', ++ description='Create an attribute record', ++ scope_types=['project'], ++ ), ++ policy.RuleDefault( ++ 'cyborg:attribute:delete', ++ 'rule:admin_api', ++ description='Delete attribute records.', ++ scope_types=['project'], ++ ), + ] +diff --git a/cyborg/policies/__init__.py b/cyborg/policies/__init__.py +index 54bd835..f049b6e 100644 +--- a/cyborg/policies/__init__.py ++++ b/cyborg/policies/__init__.py +@@ -31,5 +31,4 @@ + old_policy.deployable_policies, + old_policy.attribute_policies, + old_policy.accelerator_request_policies, +- old_policy.fpga_policies, + ) +diff --git a/cyborg/tests/unit/api/controllers/v2/test_arqs.py b/cyborg/tests/unit/api/controllers/v2/test_arqs.py +index c03ce79..05ba204 100644 +--- a/cyborg/tests/unit/api/controllers/v2/test_arqs.py ++++ b/cyborg/tests/unit/api/controllers/v2/test_arqs.py +@@ -278,7 +278,7 @@ + @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): +- """Test the happy path.""" ++ """Test the happy path for ARQ bind (patch).""" + patch_list, device_rp_uuid = fake_extarq.get_patch_list() + arq_uuids = list(patch_list.keys()) + obj_extarq = self.fake_extarqs[0] +diff --git a/cyborg/tests/unit/policies/base.py b/cyborg/tests/unit/policies/base.py +index 9d91c11..dc85fb5 100644 +--- a/cyborg/tests/unit/policies/base.py ++++ b/cyborg/tests/unit/policies/base.py +@@ -23,11 +23,14 @@ + + LOG = logging.getLogger(__name__) + ++POLICY_DENY_EXPECTED = 'Bad response: 403 Forbidden' ++ + + class BasePolicyTest(v2_test.APITestV2): + + def setUp(self): +- super(BasePolicyTest, self).setUp() ++ super().setUp() ++ self.flags(enforce_scope=True, group='oslo_policy') + self.policy = self.useFixture(policy_fixture.PolicyFixture()) + + self.admin_project_id = uuids.admin_project_id +diff --git a/cyborg/tests/unit/policies/test_arqs.py b/cyborg/tests/unit/policies/test_arqs.py +new file mode 100644 +index 0000000..8cd0549 +--- /dev/null ++++ b/cyborg/tests/unit/policies/test_arqs.py +@@ -0,0 +1,96 @@ ++# 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. ++ ++import http ++ ++from unittest import mock ++ ++from cyborg.tests.unit import fake_device_profile ++from cyborg.tests.unit import fake_extarq ++from cyborg.tests.unit.policies import base ++ ++ ++ARQ_URL = '/accelerator_requests' ++ ++ ++class ARQPolicyTest(base.BasePolicyTest): ++ """Test ARQ APIs policies with all possible contexts. ++ ++ This class defines the set of contexts with different roles ++ which are allowed and not allowed to pass the policy checks. ++ With those set of contexts, it will call the API operation and ++ verify the expected behaviour. ++ """ ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_dp_obj = fake_device_profile.get_obj_devprofs()[1] ++ self.fake_extarq_obj = fake_extarq.get_fake_extarq_objs()[0] ++ ++ # rule:project_member_or_admin with project scope enforced. ++ self.create_authorized_contexts = [ ++ self.legacy_admin_context, ++ self.project_admin_context, ++ self.legacy_owner_context, ++ self.project_member_context, ++ self.other_project_member_context, ++ ] ++ self.create_unauthorized_contexts = list( ++ set(self.all_contexts) - set(self.create_authorized_contexts) ++ ) ++ ++ @mock.patch( ++ 'cyborg.conductor.rpcapi.ConductorAPI.arq_create', autospec=True ++ ) ++ @mock.patch('cyborg.objects.DeviceProfile.get_by_name', autospec=True) ++ def test_create_arq_success(self, mock_dp, mock_arq): ++ mock_dp.return_value = self.fake_dp_obj ++ mock_arq.return_value = self.fake_extarq_obj ++ req_body = {'device_profile_name': self.fake_dp_obj.name} ++ for context in self.create_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.post_json(ARQ_URL, req_body, headers=headers) ++ self.assertEqual(http.HTTPStatus.CREATED, response.status_int) ++ ++ def test_create_arq_forbidden(self): ++ req_body = {'device_profile_name': 'dp_example_1'} ++ for context in self.create_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.post_json(ARQ_URL, req_body, headers=headers) ++ ++ @mock.patch('cyborg.objects.ExtARQ.list', autospec=True) ++ def test_list_arq_system_scope_forbidden(self, mock_list): ++ headers = self.gen_headers(self.system_admin_context) ++ response = self.get_json( ++ ARQ_URL, headers=headers, expect_errors=True, return_json=False ++ ) ++ self.assertEqual(http.HTTPStatus.FORBIDDEN, response.status_int) ++ mock_list.assert_not_called() ++ ++ @mock.patch( ++ 'cyborg.conductor.rpcapi.ConductorAPI.arq_create', autospec=True ++ ) ++ @mock.patch('cyborg.objects.DeviceProfile.get_by_name', autospec=True) ++ def test_create_arq_system_scope_forbidden(self, mock_dp, mock_arq): ++ headers = self.gen_headers(self.system_admin_context) ++ with self.assertRaisesRegex(Exception, base.POLICY_DENY_EXPECTED): ++ self.post_json( ++ ARQ_URL, ++ {'device_profile_name': 'dp_example_1'}, ++ headers=headers, ++ ) ++ mock_dp.assert_not_called() ++ mock_arq.assert_not_called() +diff --git a/cyborg/tests/unit/policies/test_attributes.py b/cyborg/tests/unit/policies/test_attributes.py +new file mode 100644 +index 0000000..c7f42cc +--- /dev/null ++++ b/cyborg/tests/unit/policies/test_attributes.py +@@ -0,0 +1,128 @@ ++# 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. ++ ++import http ++ ++from unittest import mock ++ ++from cyborg.tests.unit import fake_attribute ++from cyborg.tests.unit.policies import base ++ ++ ++ATTRIBUTE_URL = '/attributes' ++ ++ ++class AttributePolicyTest(base.BasePolicyTest): ++ """Test attribute APIs policies with all possible contexts. ++ ++ This class defines the set of contexts with different roles ++ which are allowed and not allowed to pass the policy checks. ++ With those set of contexts, it will call the API operation and ++ verify the expected behaviour. ++ """ ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_attr_obj = fake_attribute.fake_attribute_obj(self.context) ++ self.fake_attr_dict = fake_attribute.fake_db_attribute() ++ ++ # rule:admin_api with project scope enforced. ++ self.authorized_contexts = [ ++ self.legacy_admin_context, ++ self.project_admin_context, ++ ] ++ self.unauthorized_contexts = list( ++ set(self.all_contexts) - set(self.authorized_contexts) ++ ) ++ ++ @mock.patch('cyborg.objects.Attribute.get_by_filter', autospec=True) ++ def test_get_all_attributes_success(self, mock_list): ++ mock_list.return_value = [self.fake_attr_obj] ++ for context in self.authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.get_json(ATTRIBUTE_URL, headers=headers) ++ self.assertIsInstance(response['attributes'], list) ++ ++ def test_get_all_attributes_forbidden(self): ++ for context in self.unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.get_json(ATTRIBUTE_URL, headers=headers) ++ ++ @mock.patch('cyborg.objects.Attribute.get', autospec=True) ++ def test_get_one_attribute_success(self, mock_get): ++ mock_get.return_value = self.fake_attr_obj ++ uuid = self.fake_attr_obj['uuid'] ++ for context in self.authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.get_json( ++ ATTRIBUTE_URL + '/%s' % uuid, headers=headers ++ ) ++ self.assertEqual(uuid, response['uuid']) ++ ++ def test_get_one_attribute_forbidden(self): ++ uuid = self.fake_attr_obj['uuid'] ++ for context in self.unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.get_json( ++ ATTRIBUTE_URL + '/%s' % uuid, headers=headers ++ ) ++ ++ @mock.patch('cyborg.objects.Attribute.create', autospec=True) ++ def test_create_attribute_success(self, mock_create): ++ mock_create.return_value = self.fake_attr_obj ++ for context in self.authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.post_json( ++ ATTRIBUTE_URL, self.fake_attr_dict, headers=headers ++ ) ++ self.assertEqual(http.HTTPStatus.CREATED, response.status_int) ++ ++ def test_create_attribute_forbidden(self): ++ for context in self.unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.post_json( ++ ATTRIBUTE_URL, self.fake_attr_dict, headers=headers ++ ) ++ ++ @mock.patch('cyborg.objects.Attribute.destroy', autospec=True) ++ @mock.patch('cyborg.objects.Attribute.get', autospec=True) ++ def test_delete_attribute_success(self, mock_get, mock_destroy): ++ mock_get.return_value = self.fake_attr_obj ++ uuid = self.fake_attr_obj['uuid'] ++ for context in self.authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.delete( ++ ATTRIBUTE_URL + '/%s' % uuid, headers=headers ++ ) ++ self.assertEqual(http.HTTPStatus.NO_CONTENT, response.status_int) ++ ++ def test_delete_attribute_forbidden(self): ++ uuid = self.fake_attr_obj['uuid'] ++ for context in self.unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.delete(ATTRIBUTE_URL + '/%s' % uuid, headers=headers) +diff --git a/cyborg/tests/unit/policies/test_deployables.py b/cyborg/tests/unit/policies/test_deployables.py +new file mode 100644 +index 0000000..eb0cbcf +--- /dev/null ++++ b/cyborg/tests/unit/policies/test_deployables.py +@@ -0,0 +1,156 @@ ++# 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. ++ ++import http ++ ++from unittest import mock ++ ++from oslo_serialization import jsonutils ++ ++from cyborg.tests.unit import fake_deployable ++from cyborg.tests.unit import fake_device ++from cyborg.tests.unit.policies import base ++ ++ ++DEPLOYABLE_URL = '/deployables' ++ ++ ++class DeployablePolicyTest(base.BasePolicyTest): ++ """Test deployable APIs policies with all possible contexts. ++ ++ This class defines the set of contexts with different roles ++ which are allowed and not allowed to pass the policy checks. ++ With those set of contexts, it will call the API operation and ++ verify the expected behaviour. ++ """ ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_dep = fake_deployable.fake_deployable_obj(self.context) ++ self.fake_dev = fake_device.get_fake_devices_objs()[0] ++ bdf = { ++ 'domain': '0000', ++ 'bus': '00', ++ 'device': '01', ++ 'function': '1', ++ } ++ self.cpid = { ++ 'id': 0, ++ 'uuid': 'e4a66b0d-b377-40d6-9cdc-6bf7e720e596', ++ 'device_id': '1', ++ 'cpid_type': 'PCI', ++ 'cpid_info': jsonutils.dumps(bdf).encode('utf-8'), ++ } ++ self.image_uuid = '9a17439a-85d0-4c53-a3d3-0f68a2eac896' ++ ++ # rule:admin_api with project scope enforced. ++ self.read_authorized_contexts = [ ++ self.legacy_admin_context, ++ self.project_admin_context, ++ ] ++ self.read_unauthorized_contexts = list( ++ set(self.all_contexts) - set(self.read_authorized_contexts) ++ ) ++ self.program_authorized_contexts = self.read_authorized_contexts ++ self.program_unauthorized_contexts = self.read_unauthorized_contexts ++ ++ @mock.patch('cyborg.objects.Deployable.list', autospec=True) ++ def test_get_all_deployables_success(self, mock_list): ++ mock_list.return_value = [self.fake_dep] ++ for context in self.read_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.get_json(DEPLOYABLE_URL, headers=headers) ++ self.assertIsInstance(response['deployables'], list) ++ ++ def test_get_all_deployables_forbidden(self): ++ for context in self.read_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.get_json(DEPLOYABLE_URL, headers=headers) ++ ++ @mock.patch('cyborg.objects.Attribute.get_by_filter', autospec=True) ++ @mock.patch('cyborg.objects.Deployable.get', autospec=True) ++ def test_get_one_deployable_success(self, mock_get, mock_attr): ++ mock_get.return_value = self.fake_dep ++ mock_attr.return_value = [] ++ uuid = self.fake_dep['uuid'] ++ for context in self.read_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.get_json( ++ DEPLOYABLE_URL + '/%s' % uuid, headers=headers ++ ) ++ self.assertEqual(uuid, response['uuid']) ++ ++ def test_get_one_deployable_forbidden(self): ++ uuid = self.fake_dep['uuid'] ++ for context in self.read_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.get_json( ++ DEPLOYABLE_URL + '/%s' % uuid, headers=headers ++ ) ++ ++ @mock.patch('cyborg.objects.Device.get_by_device_id', autospec=True) ++ @mock.patch('cyborg.objects.Deployable.get_cpid_list', autospec=True) ++ @mock.patch('cyborg.objects.Deployable.get', autospec=True) ++ @mock.patch('cyborg.agent.rpcapi.AgentAPI.fpga_program', autospec=True) ++ def test_program_deployable_success( ++ self, ++ mock_program, ++ mock_dep_get, ++ mock_cpid, ++ mock_dev_get, ++ ): ++ dep_uuid = self.fake_dep['uuid'] ++ mock_dep_get.return_value = self.fake_dep ++ mock_dev_get.return_value = self.fake_dev ++ # Use side_effect so a fresh dict copy is returned on every call; ++ # the controller mutates cpid_list[0]['cpid_info'] in-place and a ++ # shared return_value would be corrupted on the second iteration. ++ mock_cpid.side_effect = lambda *args: [dict(self.cpid)] ++ mock_program.return_value = True ++ body = [{'image_uuid': self.image_uuid}] ++ for context in self.program_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.patch_json( ++ DEPLOYABLE_URL + '/%s/program' % dep_uuid, ++ [{'path': '/bitstream_id', 'value': body, 'op': 'replace'}], ++ headers=headers, ++ ) ++ self.assertEqual(http.HTTPStatus.OK, response.status_code) ++ ++ def test_program_deployable_forbidden(self): ++ dep_uuid = self.fake_dep['uuid'] ++ body = [{'image_uuid': self.image_uuid}] ++ for context in self.program_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.patch_json( ++ DEPLOYABLE_URL + '/%s/program' % dep_uuid, ++ [ ++ { ++ 'path': '/bitstream_id', ++ 'value': body, ++ 'op': 'replace', ++ } ++ ], ++ headers=headers, ++ ) +diff --git a/cyborg/tests/unit/policies/test_device_profiles.py b/cyborg/tests/unit/policies/test_device_profiles.py +index 2d1557a..39e5a3d 100644 +--- a/cyborg/tests/unit/policies/test_device_profiles.py ++++ b/cyborg/tests/unit/policies/test_device_profiles.py +@@ -13,10 +13,12 @@ + # License for the specific language governing permissions and limitations + # under the License. + +-from http import HTTPStatus ++import http ++ ++from unittest import mock ++ + from oslo_log import log as logging + from oslo_serialization import jsonutils +-from unittest import mock + + from cyborg.api.controllers.v2 import device_profiles + +@@ -38,17 +40,14 @@ + """ + + def setUp(self): +- super(DeviceProfilePolicyTest, self).setUp() +- +- self.flags(enforce_scope=False, group="oslo_policy") ++ super().setUp() + self.controller = device_profiles.DeviceProfilesController() + self.fake_dp_objs = fake_device_profile.get_obj_devprofs() + self.fake_dps = fake_device_profile.get_api_devprofs() + # check both legacy and new policies for create APIs + self.create_authorized_contexts = [ + self.legacy_admin_context, # legacy: admin +- self.system_admin_context, # new policy: system_admin +- self.project_admin_context ++ self.project_admin_context, + ] + self.create_unauthorized_contexts = list( + set(self.all_contexts) - set(self.create_authorized_contexts)) +@@ -62,8 +61,7 @@ + # device profile, so we just uncomment legacy_owner_context here. + # If later we need support owner policy, we should recheck here. + # self.legacy_owner_context, +- self.system_admin_context, # new policy: system_admin +- self.project_admin_context ++ self.project_admin_context, + ] + self.delete_unauthorized_contexts = list( + set(self.all_contexts) - set(self.delete_authorized_contexts)) +@@ -97,19 +95,19 @@ + response = self.post_json(DP_URL, dp, headers=headers) + out_dp = jsonutils.loads(response.controller_output) + +- self.assertEqual(HTTPStatus.CREATED, response.status_int) ++ self.assertEqual(http.HTTPStatus.CREATED, response.status_int) + self._validate_dp(dp[0], out_dp) + + def test_create_device_profile_forbidden(self): + dp = [self.fake_dps[0]] + dp[0]['created_at'] = str(dp[0]['created_at']) + for context in self.create_unauthorized_contexts: +- headers = self.gen_headers(context) +- try: +- self.post_json(DP_URL, dp, headers=headers) +- except Exception as e: +- exc = e +- self.assertIn("Bad response: 403 Forbidden", exc.args[0]) ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.post_json(DP_URL, dp, headers=headers) + + @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.device_profile_delete') + @mock.patch('cyborg.objects.DeviceProfile.get_by_name') +@@ -121,50 +119,19 @@ + # Delete by UUID + url = DP_URL + "/5d2c0797-c3cd-4f4b-b0d0-2cc5e99ef66e" + response = self.delete(url, headers=headers) +- self.assertEqual(HTTPStatus.NO_CONTENT, response.status_int) ++ self.assertEqual(http.HTTPStatus.NO_CONTENT, response.status_int) + # Delete by name + url = DP_URL + "/mydp" + response = self.delete(url, headers=headers) +- self.assertEqual(HTTPStatus.NO_CONTENT, response.status_int) ++ self.assertEqual(http.HTTPStatus.NO_CONTENT, response.status_int) + + def test_delete_device_profile_forbidden(self): + dp = self.fake_dp_objs[0] + url = DP_URL + '/%s' +- exc = None + for context in self.delete_unauthorized_contexts: +- headers = self.gen_headers(context) +- try: +- self.delete(url % dp['uuid'], headers=headers) +- except Exception as e: +- exc = e +- self.assertIn("Bad response: 403 Forbidden", exc.args[0]) +- +- +-class DeviceProfileScopeTypePolicyTest(DeviceProfilePolicyTest): +- """Test device_profile APIs policies with system scope enabled. +- This class set the cyborg.conf [oslo_policy] enforce_scope to True +- so that we can switch on the scope checking on oslo policy side. +- It defines the set of context with scoped token +- which are allowed and not allowed to pass the policy checks. +- With those set of context, it will run the API operation and +- verify the expected behaviour. +- """ +- +- def setUp(self): +- super(DeviceProfileScopeTypePolicyTest, self).setUp() +- self.flags(enforce_scope=True, group="oslo_policy") +- # check that admin is able to do create and delete operations. +- self.create_authorized_contexts = [ +- self.legacy_admin_context, +- self.project_admin_context] +- self.delete_authorized_contexts = self.create_authorized_contexts +- # Check that system or non-admin is not able to perform the system +- # level actions on device_profiles. +- self.create_unauthorized_contexts = [ +- self.system_admin_context, self.system_member_context, +- self.system_reader_context, self.system_foo_context, +- self.project_member_context, +- self.other_project_member_context, +- self.project_foo_context, self.project_reader_context +- ] +- self.delete_unauthorized_contexts = self.create_unauthorized_contexts ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.delete(url % dp['uuid'], headers=headers) +diff --git a/cyborg/tests/unit/policies/test_devices.py b/cyborg/tests/unit/policies/test_devices.py +new file mode 100644 +index 0000000..87f4c32 +--- /dev/null ++++ b/cyborg/tests/unit/policies/test_devices.py +@@ -0,0 +1,182 @@ ++# 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. ++ ++import http ++ ++from unittest import mock ++ ++from cyborg.tests.unit import fake_device ++from cyborg.tests.unit.policies import base ++ ++ ++DEVICE_URL = '/devices' ++ ++ ++class DevicePolicyTest(base.BasePolicyTest): ++ """Test device APIs policies with all possible contexts. ++ ++ This class defines the set of contexts with different roles ++ which are allowed and not allowed to pass the policy checks. ++ With those set of contexts, it will call the API operation and ++ verify the expected behaviour. ++ """ ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_devices = fake_device.get_fake_devices_objs() ++ ++ # rule:admin_api with project scope enforced. ++ self.read_authorized_contexts = [ ++ self.legacy_admin_context, ++ self.project_admin_context, ++ ] ++ self.read_unauthorized_contexts = list( ++ set(self.all_contexts) - set(self.read_authorized_contexts) ++ ) ++ ++ @mock.patch('cyborg.objects.Device.list', autospec=True) ++ def test_get_all_devices_success(self, mock_list): ++ mock_list.return_value = self.fake_devices ++ for context in self.read_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.get_json( ++ DEVICE_URL, headers=headers, return_json=False ++ ) ++ self.assertEqual(http.HTTPStatus.OK, response.status_int) ++ self.assertEqual( ++ len(self.fake_devices), len(response.json['devices']) ++ ) ++ ++ def test_get_all_devices_forbidden(self): ++ for context in self.read_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.get_json(DEVICE_URL, headers=headers) ++ ++ @mock.patch('cyborg.objects.Device.get', autospec=True) ++ def test_get_one_device_success(self, mock_get): ++ fake_dev = self.fake_devices[0] ++ mock_get.return_value = fake_dev ++ uuid = fake_dev['uuid'] ++ for context in self.read_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.get_json( ++ DEVICE_URL + '/%s' % uuid, headers=headers ++ ) ++ self.assertEqual(uuid, response['uuid']) ++ ++ def test_get_one_device_forbidden(self): ++ uuid = self.fake_devices[0]['uuid'] ++ for context in self.read_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.get_json(DEVICE_URL + '/%s' % uuid, headers=headers) ++ ++ @mock.patch( ++ 'cyborg.api.controllers.v2.devices.placement_client.PlacementClient' ++ ) ++ @mock.patch('cyborg.objects.Attribute.get_by_filter', autospec=True) ++ @mock.patch('cyborg.objects.Deployable.get_by_id', autospec=True) ++ @mock.patch('cyborg.objects.Device.save', autospec=True) ++ @mock.patch('cyborg.objects.Device.get', autospec=True) ++ def test_disable_device_success( ++ self, ++ mock_get, ++ mock_save, ++ mock_dep_get, ++ mock_attr_filter, ++ mock_pc_cls, ++ ): ++ fake_dev = self.fake_devices[0] ++ mock_get.return_value = fake_dev ++ mock_dep = mock.MagicMock() ++ mock_dep.id = fake_dev.id ++ mock_dep.rp_uuid = '00000000-0000-0000-0000-000000000001' ++ mock_dep.num_accelerators = 4 ++ mock_dep_get.return_value = mock_dep ++ mock_attr_filter.return_value = [mock.MagicMock(value='CUSTOM_FOO')] ++ mock_pc_cls.return_value = mock.MagicMock() ++ for context in self.read_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.post_json( ++ DEVICE_URL + '/%s/disable' % fake_dev.uuid, ++ {}, ++ headers=headers, ++ ) ++ self.assertEqual(http.HTTPStatus.NO_CONTENT, response.status_int) ++ ++ def test_disable_device_forbidden(self): ++ uuid = self.fake_devices[0]['uuid'] ++ for context in self.read_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.post_json( ++ DEVICE_URL + '/%s/disable' % uuid, ++ {}, ++ headers=headers, ++ ) ++ ++ @mock.patch( ++ 'cyborg.api.controllers.v2.devices.placement_client.PlacementClient' ++ ) ++ @mock.patch('cyborg.objects.Attribute.get_by_filter', autospec=True) ++ @mock.patch('cyborg.objects.Deployable.get_by_id', autospec=True) ++ @mock.patch('cyborg.objects.Device.save', autospec=True) ++ @mock.patch('cyborg.objects.Device.get', autospec=True) ++ def test_enable_device_success( ++ self, ++ mock_get, ++ mock_save, ++ mock_dep_get, ++ mock_attr_filter, ++ mock_pc_cls, ++ ): ++ fake_dev = self.fake_devices[0] ++ mock_get.return_value = fake_dev ++ mock_dep = mock.MagicMock() ++ mock_dep.id = fake_dev.id ++ mock_dep.rp_uuid = '00000000-0000-0000-0000-000000000001' ++ mock_dep.num_accelerators = 4 ++ mock_dep_get.return_value = mock_dep ++ mock_attr_filter.return_value = [mock.MagicMock(value='CUSTOM_FOO')] ++ mock_pc_cls.return_value = mock.MagicMock() ++ for context in self.read_authorized_contexts: ++ headers = self.gen_headers(context) ++ response = self.post_json( ++ DEVICE_URL + '/%s/enable' % fake_dev.uuid, ++ {}, ++ headers=headers, ++ ) ++ self.assertEqual(http.HTTPStatus.NO_CONTENT, response.status_int) ++ ++ def test_enable_device_forbidden(self): ++ uuid = self.fake_devices[0]['uuid'] ++ for context in self.read_unauthorized_contexts: ++ with self.subTest(context=context): ++ headers = self.gen_headers(context) ++ with self.assertRaisesRegex( ++ Exception, base.POLICY_DENY_EXPECTED ++ ): ++ self.post_json( ++ DEVICE_URL + '/%s/enable' % uuid, ++ {}, ++ headers=headers, ++ ) +diff --git a/cyborg/tests/unit/test_authorize_wsgi.py b/cyborg/tests/unit/test_authorize_wsgi.py +new file mode 100644 +index 0000000..8906bcc +--- /dev/null ++++ b/cyborg/tests/unit/test_authorize_wsgi.py +@@ -0,0 +1,36 @@ ++# Copyright 2026 Red Hat, Inc. ++# All Rights Reserved. ++# ++# 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. ++ ++from unittest import mock ++ ++from cyborg.common import authorize_wsgi ++from cyborg.tests import base ++ ++ ++class TestAuthorizeWSGI(base.TestCase): ++ def test_init_enforcer_warns_when_scope_enforcement_disabled(self): ++ self.flags(enforce_scope=False, group='oslo_policy') ++ authorize_wsgi.get_enforcer().clear() ++ authorize_wsgi._ENFORCER = None ++ self.addCleanup(setattr, authorize_wsgi, '_ENFORCER', None) ++ ++ with mock.patch.object(authorize_wsgi.LOG, 'warning') as mock_warning: ++ authorize_wsgi.init_enforcer(suppress_deprecation_warnings=True) ++ ++ mock_warning.assert_called_once() ++ self.assertIn( ++ 'oslo_policy.enforce_scope is disabled', ++ mock_warning.call_args[0][0] ++ ) +diff --git a/releasenotes/notes/fix-rule-allow-device-apis-lp2143263.yaml b/releasenotes/notes/fix-rule-allow-device-apis-lp2143263.yaml +new file mode 100644 +index 0000000..cbb4bda +--- /dev/null ++++ b/releasenotes/notes/fix-rule-allow-device-apis-lp2143263.yaml +@@ -0,0 +1,19 @@ ++--- ++security: ++ - | ++ This issue is assigned CVE-2026-40213. ++ ++ Replaced permissive ``rule:allow`` defaults with ``rule:admin_api`` on ++ device, deployable, and attribute API policies so authenticated ++ low-privilege users cannot read or change hardware topology and ++ management data without the admin role. System-scoped tokens are not ++ supported by Cyborg. Deployments that relied on the old ++ defaults must grant ``admin`` or define custom policy rules for these ++ APIs. ++upgrade: ++ - | ++ Cyborg API policies now declare ``scope_types=['project']`` and reject ++ Keystone system-scoped tokens via oslo.policy scope enforcement. Keep ++ ``[oslo_policy] enforce_scope=True``. Disabling it weakens project ++ isolation and is discouraged; prefer custom policy rules if you need ++ different access behavior. diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_4_Set_project_id_on_ARQ_creation_and_binding.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_4_Set_project_id_on_ARQ_creation_and_binding.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_4_Set_project_id_on_ARQ_creation_and_binding.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_4_Set_project_id_on_ARQ_creation_and_binding.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,493 @@ +Author: Sean Mooney +Date: Sun, 26 Apr 2026 18:25:28 +0000 +Description: CVE-2026-40213 / CVE-2026-40214: Set project_id on ARQ creation and binding + Cyborg never sets project_id when creating or binding ARQs, + leaving the column NULL for every ARQ ever created. This is + the first step in fixing the cross-tenant ARQ access + vulnerability. + . + Set project_id unconditionally from context.project_id at + creation time. Default project_id to context.project_id + during binding (PATCH). For microversion 2.1+, reject + non-admin callers who provide a project_id that differs + from their token's project (fixes project_id spoofing). +Bug: https://launchpad.net/bugs/2144056 +Bug-Debian: https://bugs.debian.org/1136006 +Generated-By: Cursor claude-opus-4.6 +Signed-off-by: Sean Mooney +Change-Id: I2301f72fb3e2dc0d77161156510312b1b90b797c +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/987700 +Last-Update: 2026-05-11 + +diff --git a/cyborg/api/controllers/v2/arqs.py b/cyborg/api/controllers/v2/arqs.py +index aea1dd0..801948c 100644 +--- a/cyborg/api/controllers/v2/arqs.py ++++ b/cyborg/api/controllers/v2/arqs.py +@@ -155,6 +155,7 @@ + arq_fields = { + 'device_profile_name': devprof.name, + 'device_profile_group_id': group_id, ++ 'project_id': context.project_id, + } + for i in range(num_accels): + obj_arq = objects.ARQ(context, **arq_fields) +@@ -258,9 +259,12 @@ + value field of arq_uuid in patch() method below. + :returns: dict of valid fields + """ +- valid_fields = {'hostname': None, +- 'device_rp_uuid': None, +- 'instance_uuid': None} ++ context = pecan.request.context ++ valid_fields = { ++ 'hostname': None, ++ 'device_rp_uuid': None, ++ 'instance_uuid': None, ++ } + if utils.allow_project_id(): + valid_fields['project_id'] = None + if ((not all(p['op'] == 'add' for p in patch)) and +@@ -270,19 +274,40 @@ + + for p in patch: + path = p['path'].lstrip('/') +- if path == 'project_id' and not utils.allow_project_id(): +- raise exception.NotAcceptable(_( +- "Request not acceptable. The minimal required API " +- "version should be %(base)s.%(opr)s") % +- {'base': versions.BASE_VERSION, +- 'opr': versions.MINOR_1_PROJECT_ID}) ++ if path == 'project_id': ++ if not utils.allow_project_id(): ++ raise exception.NotAcceptable( ++ _( ++ "Request not acceptable. The minimal required API " ++ "version should be %(base)s.%(opr)s" ++ ) ++ % { ++ 'base': versions.BASE_VERSION, ++ 'opr': versions.MINOR_1_PROJECT_ID, ++ } ++ ) ++ # Only cloud admins may set or change project_id in a patch; ++ # other callers get ownership from the request context below. ++ if not context.is_admin: ++ raise exception.HTTPForbidden(resource='cyborg:arq:update') + if path not in valid_fields.keys(): + reason = 'Invalid path in patch {}'.format(p['path']) + raise exception.PatchError(reason=reason) + if p['op'] == 'add': + valid_fields[path] = p['value'] +- not_found = [field for field, value in valid_fields.items() +- if value is None] ++ ++ if patch[0]['op'] == 'add': ++ provided_pid = valid_fields.get('project_id') ++ # Always set project_id from context when not explicitly provided ++ # by an admin, or when using API < 2.1 (no project_id in patch). ++ if not provided_pid: ++ valid_fields['project_id'] = context.project_id ++ ++ not_found = [ ++ field ++ for field, value in valid_fields.items() ++ if value is None and field != 'project_id' ++ ] + if patch[0]['op'] == 'add' and len(not_found) > 0: + msg = ','.join(not_found) + reason = _('Fields absent in patch {}').format(msg) +@@ -322,8 +347,9 @@ + ], + "$arq_uuid": [...] + } +- In particular, all and only these 4 fields must be present, +- and only 'add' or 'remove' ops are allowed. ++ In particular, all required bind fields must be present. At API ++ v2.1+, administrators may also set optional ``/project_id``. ++ Only 'add' or 'remove' ops are allowed. + """ + LOG.info('[arqs] patch. list=(%s)', patch_list) + context = pecan.request.context +diff --git a/cyborg/objects/extarq/ext_arq_job.py b/cyborg/objects/extarq/ext_arq_job.py +index 372b960..9de0e6b 100644 +--- a/cyborg/objects/extarq/ext_arq_job.py ++++ b/cyborg/objects/extarq/ext_arq_job.py +@@ -66,13 +66,21 @@ + hostname = valid_fields[self.arq.uuid]['hostname'] + devrp_uuid = valid_fields[self.arq.uuid]['device_rp_uuid'] + instance_uuid = valid_fields[self.arq.uuid]['instance_uuid'] +- project_id = valid_fields[self.arq.uuid].get('project_id') +- LOG.info('[arqs:objs] bind. hostname: %(hostname)s, ' +- 'devrp_uuid: %(devrp_uuid)s, ' +- 'instance_uuid: %(instance_uuid)s, ' +- 'project_id: %(project_id)s', +- {'hostname': hostname, 'devrp_uuid': devrp_uuid, +- 'instance_uuid': instance_uuid, 'project_id': project_id}) ++ project_id = valid_fields[self.arq.uuid].get( ++ 'project_id', context.project_id ++ ) ++ LOG.info( ++ '[arqs:objs] bind. hostname: %(hostname)s, ' ++ 'devrp_uuid: %(devrp_uuid)s, ' ++ 'instance_uuid: %(instance_uuid)s, ' ++ 'project_id: %(project_id)s', ++ { ++ 'hostname': hostname, ++ 'devrp_uuid': devrp_uuid, ++ 'instance_uuid': instance_uuid, ++ 'project_id': project_id, ++ }, ++ ) + + self.arq.hostname = hostname + self.arq.device_rp_uuid = devrp_uuid +diff --git a/cyborg/tests/unit/api/controllers/v2/test_arqs.py b/cyborg/tests/unit/api/controllers/v2/test_arqs.py +index 05ba204..bc6c31e 100644 +--- a/cyborg/tests/unit/api/controllers/v2/test_arqs.py ++++ b/cyborg/tests/unit/api/controllers/v2/test_arqs.py +@@ -18,10 +18,12 @@ + from unittest import mock + + from oslo_serialization import jsonutils ++from oslo_utils.fixture import uuidsentinel as uuids + + from cyborg.api.controllers import base + from cyborg.api.controllers.v2 import arqs + from cyborg.common import exception ++from cyborg import context as cyborg_context + from cyborg.tests.unit.api.controllers.v2 import base as v2_test + from cyborg.tests.unit import fake_device_profile + from cyborg.tests.unit import fake_extarq +@@ -32,7 +34,8 @@ + ARQ_URL = '/accelerator_requests' + + def setUp(self): +- super(TestARQsController, self).setUp() ++ super().setUp() ++ self.context.project_id = str(uuids.project_id) + self.headers = self.gen_headers(self.context) + self.fake_extarqs = fake_extarq.get_fake_extarq_objs() + self.fake_bind_extarqs = fake_extarq.get_fake_extarq_bind_objs() +@@ -286,8 +289,11 @@ + arq_uuid: { + 'hostname': obj_extarq.arq.hostname, + 'device_rp_uuid': device_rp_uuid, +- 'instance_uuid': obj_extarq.arq.instance_uuid} +- for arq_uuid in arq_uuids} ++ 'instance_uuid': obj_extarq.arq.instance_uuid, ++ 'project_id': self.context.project_id, ++ } ++ for arq_uuid in arq_uuids ++ } + + self.patch_json(self.ARQ_URL, params=patch_list, + headers=self.headers) +@@ -301,23 +307,38 @@ + def test_apply_patch_allow_project_id( + self, mock_apply_patch, mock_check_if_bound): + patch_list, _ = fake_extarq.get_patch_list() ++ explicit_pid = str(uuids.explicit_project) + for arq_uuid, patch in patch_list.items(): +- patch.append({'path': '/project_id', 'op': 'add', +- 'value': 'b1c76756ac2e482789a8e1c5f4bf065e'}) ++ patch.append( ++ { ++ 'path': '/project_id', ++ 'op': 'add', ++ 'value': explicit_pid, ++ } ++ ) + arq_uuids = list(patch_list.keys()) + valid_fields = { + arq_uuid: { + 'hostname': 'myhost', + 'device_rp_uuid': 'fb16c293-5739-4c84-8590-926f9ab16669', + 'instance_uuid': '5922a70f-1e06-4cfd-88dd-a332120d7144', +- 'project_id': 'b1c76756ac2e482789a8e1c5f4bf065e'} +- for arq_uuid in arq_uuids} ++ 'project_id': explicit_pid, ++ } ++ for arq_uuid in arq_uuids ++ } + +- self.patch_json(self.ARQ_URL, params=patch_list, +- headers={base.Version.current_api_version: +- '2.1'}) +- mock_apply_patch.assert_called_once_with(mock.ANY, patch_list, +- valid_fields) ++ # Admin headers with v2.1 microversion to allow ++ # setting a different project_id ++ headers = self.gen_headers(self.context) ++ headers[base.Version.current_api_version] = '2.1' ++ self.patch_json( ++ self.ARQ_URL, ++ params=patch_list, ++ headers=headers, ++ ) ++ mock_apply_patch.assert_called_once_with( ++ mock.ANY, patch_list, valid_fields ++ ) + mock_check_if_bound.assert_called_once_with(mock.ANY, valid_fields) + + def test_apply_patch_not_allow_project_id(self): +@@ -375,4 +396,175 @@ + self.assertRaisesRegex( + exception.PatchError, expected_err, + self.arqs_controller._check_if_already_bound, +- self.context, valid_fields) ++ self.context, ++ valid_fields, ++ ) ++ ++ ++class TestARQProjectIdOnCreate(v2_test.APITestV2): ++ """Tests for Bug #2144056: project_id must be set from context.""" ++ ++ ARQ_URL = '/accelerator_requests' ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_extarqs = fake_extarq.get_fake_extarq_objs() ++ ++ def _make_member_context(self, project_id=None): ++ pid = project_id or str(uuids.member_project) ++ return cyborg_context.RequestContext( ++ user_id=str(uuids.member_user), ++ project_id=pid, ++ is_admin=False, ++ ) ++ ++ def _make_admin_context(self, project_id=None): ++ pid = project_id or str(uuids.admin_project) ++ return cyborg_context.RequestContext( ++ user_id=str(uuids.admin_user), ++ project_id=pid, ++ is_admin=True, ++ ) ++ ++ def _make_bind_patch(self, extra_entries=None): ++ """Build a standard bind patch list.""" ++ patch = [ ++ {'path': '/hostname', 'op': 'add', 'value': 'host1'}, ++ { ++ 'path': '/device_rp_uuid', ++ 'op': 'add', ++ 'value': str(uuids.device_rp), ++ }, ++ { ++ 'path': '/instance_uuid', ++ 'op': 'add', ++ 'value': str(uuids.instance), ++ }, ++ ] ++ if extra_entries: ++ patch.extend(extra_entries) ++ return patch ++ ++ @mock.patch('cyborg.objects.DeviceProfile.get_by_name') ++ @mock.patch('cyborg.conductor.rpcapi.ConductorAPI.arq_create') ++ def test_post_sets_project_id_from_context( ++ self, mock_arq_create, mock_get_dp ++ ): ++ dp_list = fake_device_profile.get_obj_devprofs() ++ mock_get_dp.return_value = dp_list[0] ++ mock_arq_create.side_effect = self.fake_extarqs ++ ++ member_ctx = self._make_member_context() ++ headers = self.gen_headers(member_ctx) ++ headers['X-Roles'] = 'member' ++ params = {'device_profile_name': dp_list[0]['name']} ++ self.post_json(self.ARQ_URL, params, headers=headers) ++ ++ for call_args in mock_arq_create.call_args_list: ++ obj_extarq = call_args[0][1] ++ self.assertEqual(member_ctx.project_id, obj_extarq.arq.project_id) ++ ++ @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id') ++ def test_validate_arq_patch_defaults_project_id_v20(self, mock_allow): ++ """At API v2.0, project_id is always set from context.""" ++ mock_allow.return_value = False ++ controller = arqs.ARQsController() ++ member_ctx = self._make_member_context() ++ mock_request = mock.MagicMock() ++ mock_request.context = member_ctx ++ mock_request.version.minor = 0 ++ with mock.patch.object(arqs, 'pecan') as mock_pecan: ++ mock_pecan.request = mock_request ++ result = controller._validate_arq_patch(self._make_bind_patch()) ++ self.assertEqual(member_ctx.project_id, result['project_id']) ++ ++ @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id') ++ def test_validate_arq_patch_defaults_project_id_v21_no_explicit( ++ self, mock_allow ++ ): ++ """At API v2.1, project_id defaults to context when absent.""" ++ mock_allow.return_value = True ++ controller = arqs.ARQsController() ++ member_ctx = self._make_member_context() ++ mock_request = mock.MagicMock() ++ mock_request.context = member_ctx ++ mock_request.version.minor = 1 ++ with mock.patch.object(arqs, 'pecan') as mock_pecan: ++ mock_pecan.request = mock_request ++ result = controller._validate_arq_patch(self._make_bind_patch()) ++ self.assertEqual(member_ctx.project_id, result['project_id']) ++ ++ @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id') ++ def test_validate_arq_patch_v21_admin_can_set_different_project_id( ++ self, mock_allow ++ ): ++ """Admin can set a project_id that differs from token.""" ++ mock_allow.return_value = True ++ controller = arqs.ARQsController() ++ other_project = str(uuids.other_project) ++ patch = self._make_bind_patch( ++ [ ++ {'path': '/project_id', 'op': 'add', 'value': other_project}, ++ ] ++ ) ++ admin_ctx = self._make_admin_context() ++ mock_request = mock.MagicMock() ++ mock_request.context = admin_ctx ++ mock_request.version.minor = 1 ++ with mock.patch.object(arqs, 'pecan') as mock_pecan: ++ mock_pecan.request = mock_request ++ result = controller._validate_arq_patch(patch) ++ self.assertEqual(other_project, result['project_id']) ++ ++ @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id') ++ def test_validate_arq_patch_v21_non_admin_cannot_spoof_project_id( ++ self, mock_allow ++ ): ++ """Non-admin cannot set a different project_id (spoofing).""" ++ mock_allow.return_value = True ++ controller = arqs.ARQsController() ++ other_project = str(uuids.other_project) ++ patch = self._make_bind_patch( ++ [ ++ {'path': '/project_id', 'op': 'add', 'value': other_project}, ++ ] ++ ) ++ member_ctx = self._make_member_context() ++ mock_request = mock.MagicMock() ++ mock_request.context = member_ctx ++ mock_request.version.minor = 1 ++ with mock.patch.object(arqs, 'pecan') as mock_pecan: ++ mock_pecan.request = mock_request ++ self.assertRaises( ++ exception.HTTPForbidden, ++ controller._validate_arq_patch, ++ patch, ++ ) ++ ++ @mock.patch('cyborg.api.controllers.v2.utils.allow_project_id') ++ def test_validate_arq_patch_v21_non_admin_rejects_project_id( ++ self, mock_allow ++ ): ++ """Non-admin cannot include project_id in a patch.""" ++ mock_allow.return_value = True ++ controller = arqs.ARQsController() ++ member_ctx = self._make_member_context() ++ patch = self._make_bind_patch( ++ [ ++ { ++ 'path': '/project_id', ++ 'op': 'add', ++ 'value': member_ctx.project_id, ++ }, ++ ] ++ ) ++ mock_request = mock.MagicMock() ++ mock_request.context = member_ctx ++ mock_request.version.minor = 1 ++ with mock.patch.object(arqs, 'pecan') as mock_pecan: ++ mock_pecan.request = mock_request ++ self.assertRaises( ++ exception.HTTPForbidden, ++ controller._validate_arq_patch, ++ patch, ++ ) +diff --git a/cyborg/tests/unit/objects/test_extarq.py b/cyborg/tests/unit/objects/test_extarq.py +index 4cbf594..ab64d56 100644 +--- a/cyborg/tests/unit/objects/test_extarq.py ++++ b/cyborg/tests/unit/objects/test_extarq.py +@@ -15,6 +15,7 @@ + + from unittest import mock + ++from oslo_utils.fixture import uuidsentinel as uuids + from testtools.matchers import HasLength + + from cyborg.common import constants +@@ -488,3 +489,71 @@ + primitive = arq_obj.obj_to_primitive() + arq_obj.obj_make_compatible(primitive['cyborg_object.data'], '1.2') + self.assertIn('deployable_id', primitive['cyborg_object.data']) ++ ++ ++class TestExtARQProjectId(base.DbTestCase): ++ """Tests for Bug #2144056: project_id in start_bind_job.""" ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_obj_extarqs = fake_extarq.get_fake_extarq_objs() ++ ++ def _make_member_context(self, project_id=None): ++ from cyborg import context as cyborg_context ++ ++ pid = project_id or str(uuids.member_project) ++ return cyborg_context.RequestContext( ++ user_id=str(uuids.member_user), ++ project_id=pid, ++ is_admin=False, ++ ) ++ ++ @mock.patch('cyborg.objects.ExtARQ.update_check_state') ++ @mock.patch('cyborg.objects.Deployable.get_by_device_rp_uuid') ++ @mock.patch('cyborg.objects.extarq.ext_arq_job.ExtARQJobMixin._bind_job') ++ def test_start_bind_job_defaults_project_id_to_context( ++ self, mock_bind, mock_get_dep, mock_update_state ++ ): ++ """project_id should default to context.project_id.""" ++ member_ctx = self._make_member_context() ++ extarq = self.fake_obj_extarqs[0] ++ extarq.arq.state = constants.ARQ_INITIAL ++ uuid = extarq.arq.uuid ++ valid_fields = { ++ uuid: { ++ 'hostname': 'host1', ++ 'device_rp_uuid': str(uuids.device_rp), ++ 'instance_uuid': str(uuids.instance), ++ } ++ } ++ mock_get_dep.return_value = mock.MagicMock() ++ ++ extarq.start_bind_job(member_ctx, valid_fields) ++ ++ self.assertEqual(member_ctx.project_id, extarq.arq.project_id) ++ ++ @mock.patch('cyborg.objects.ExtARQ.update_check_state') ++ @mock.patch('cyborg.objects.Deployable.get_by_device_rp_uuid') ++ @mock.patch('cyborg.objects.extarq.ext_arq_job.ExtARQJobMixin._bind_job') ++ def test_start_bind_job_uses_explicit_project_id( ++ self, mock_bind, mock_get_dep, mock_update_state ++ ): ++ """Explicit project_id should be used when provided.""" ++ member_ctx = self._make_member_context() ++ extarq = self.fake_obj_extarqs[0] ++ extarq.arq.state = constants.ARQ_INITIAL ++ uuid = extarq.arq.uuid ++ explicit_pid = str(uuids.explicit_project) ++ valid_fields = { ++ uuid: { ++ 'hostname': 'host1', ++ 'device_rp_uuid': str(uuids.device_rp), ++ 'instance_uuid': str(uuids.instance), ++ 'project_id': explicit_pid, ++ } ++ } ++ mock_get_dep.return_value = mock.MagicMock() ++ ++ extarq.start_bind_job(member_ctx, valid_fields) ++ ++ self.assertEqual(explicit_pid, extarq.arq.project_id) diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_5_Refactor_session_handling_and_align_test_contexts.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_5_Refactor_session_handling_and_align_test_contexts.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_5_Refactor_session_handling_and_align_test_contexts.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_5_Refactor_session_handling_and_align_test_contexts.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,1190 @@ +Author: Sooyoung Kim +Date: Tue, 28 Oct 2025 20:13:25 +0900 +Description: CVE-2026-40213 / CVE-2026-40214: Refactor session handling and align test contexts + This change refactors database session management and aligns unit + tests with the production DB context handling. + . + - Replace _session_for_read() and _session_for_write() helpers with + @main_context_manager.reader / @main_context_manager.writer decorators + to simplify and standardize DB access. + - Update DB API methods to use context.session directly and rely on + centralized transaction management through enginefacade. + - Switch tests to use cyborg.context.RequestContext instead of + oslo_context.RequestContext for consistent context propagation. + . + Stable-Only: + python 3.9 and 3.12 differ in how they handel autospec when there + are context managers that use functools.wraps + this change was modifed to resolve that. +Bug: https://launchpad.net/bugs/2061130 +Bug-Debian: https://bugs.debian.org/1136006 +Change-Id: Idf7714ec9fa57b4885bd5679f431cdeac2ad1497 +Signed-off-by: Sooyoung Kim +Signed-off-by: Sean Mooney +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/984700 +Last-Update: 2026-05-11 + +Index: cyborg/cyborg/db/sqlalchemy/api.py +=================================================================== +--- cyborg.orig/cyborg/db/sqlalchemy/api.py ++++ cyborg/cyborg/db/sqlalchemy/api.py +@@ -16,7 +16,6 @@ + """SQLAlchemy storage backend.""" + + import copy +-import threading + import uuid + + from oslo_db import api as oslo_db_api +@@ -35,23 +34,30 @@ from cyborg.common.i18n import _ + from cyborg.db import api + from cyborg.db.sqlalchemy import models + +-_CONTEXT = threading.local() + LOG = log.getLogger(__name__) + + main_context_manager = enginefacade.transaction_context() + + +-def get_backend(): +- """The backend is this module itself.""" +- return Connection() ++def _writer(fn): ++ # oslo.db uses functools.wraps which sets __wrapped__. Python 3.9's ++ # mock.autospec follows __wrapped__ and then fails to strip `self` for ++ # the bound-method case, shifting positional args by one. Removing ++ # __wrapped__ makes autospec see (*args, **kwargs) instead. ++ wrapped = main_context_manager.writer(fn) ++ del wrapped.__wrapped__ ++ return wrapped + + +-def _session_for_read(): +- return enginefacade.reader.using(_CONTEXT) ++def _reader(fn): ++ wrapped = main_context_manager.reader(fn) ++ del wrapped.__wrapped__ ++ return wrapped + + +-def _session_for_write(): +- return enginefacade.writer.using(_CONTEXT) ++def get_backend(): ++ """The backend is this module itself.""" ++ return Connection() + + + def get_session(use_slave=False, **kwargs): +@@ -78,10 +84,10 @@ def model_query(context, model, *args, * + if kwargs.pop("project_only", False): + kwargs["project_id"] = context.project_id + +- with _session_for_read() as session: +- query = sqlalchemyutils.model_query( +- model, session, args, **kwargs) +- return query ++ query = sqlalchemyutils.model_query( ++ model, context.session, args, **kwargs) ++ ++ return query + + + def add_identity_filter(query, value): +@@ -124,6 +130,7 @@ class Connection(api.Connection): + def __init__(self): + pass + ++ @_writer + def attach_handle_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -131,14 +138,14 @@ class Connection(api.Connection): + attach_handle = models.AttachHandle() + attach_handle.update(values) + +- with _session_for_write() as session: +- try: +- session.add(attach_handle) +- session.flush() +- except db_exc.DBDuplicateEntry: +- raise exception.AttachHandleAlreadyExists(uuid=values['uuid']) +- return attach_handle ++ try: ++ context.session.add(attach_handle) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry: ++ raise exception.AttachHandleAlreadyExists(uuid=values['uuid']) ++ return attach_handle + ++ @_reader + def attach_handle_get_by_uuid(self, context, uuid): + query = model_query( + context, +@@ -150,6 +157,7 @@ class Connection(api.Connection): + resource='AttachHandle', + msg='with uuid=%s' % uuid) + ++ @_reader + def attach_handle_get_by_id(self, context, id): + query = model_query( + context, +@@ -161,6 +169,7 @@ class Connection(api.Connection): + resource='AttachHandle', + msg='with id=%s' % id) + ++ @_reader + def attach_handle_list_by_type(self, context, attach_type='PCI'): + query = model_query(context, models.AttachHandle). \ + filter_by(attach_type=attach_type) +@@ -171,6 +180,7 @@ class Connection(api.Connection): + resource='AttachHandle', + msg='with type=%s' % attach_type) + ++ @_reader + def attach_handle_get_by_filters(self, context, + filters, sort_key='created_at', + sort_dir='desc', limit=None, +@@ -238,6 +248,7 @@ class Connection(api.Connection): + for k, v in filter_dict.items()]) + return query + ++ @_reader + def attach_handle_list(self, context): + query = model_query(context, models.AttachHandle) + return _paginate_query(context, models.AttachHandle, query=query) +@@ -249,36 +260,36 @@ class Connection(api.Connection): + return self._do_update_attach_handle(context, uuid, values) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_attach_handle(self, context, uuid, values): +- with _session_for_write(): +- query = model_query(context, models.AttachHandle) +- query = add_identity_filter(query, uuid) +- try: +- ref = query.with_for_update().one() +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='AttachHandle', +- msg='with uuid=%s' % uuid) +- ref.update(values) ++ query = model_query(context, models.AttachHandle) ++ query = add_identity_filter(query, uuid) ++ try: ++ ref = query.with_for_update().one() ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='AttachHandle', ++ msg='with uuid=%s' % uuid) ++ ref.update(values) + return ref + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_allocate_attach_handle(self, context, deployable_id): + """Atomically get a set of attach handles that match the query + and mark one of those as in_use. + """ +- with _session_for_write() as session: +- query = model_query(context, models.AttachHandle). \ +- filter_by(deployable_id=deployable_id, +- in_use=False) +- values = {"in_use": True} +- ref = query.with_for_update().first() +- if not ref: +- msg = 'Matching deployable_id {0}'.format(deployable_id) +- raise exception.ResourceNotFound( +- resource='AttachHandle', msg=msg) +- ref.update(values) +- session.flush() ++ query = model_query(context, models.AttachHandle). \ ++ filter_by(deployable_id=deployable_id, ++ in_use=False) ++ values = {"in_use": True} ++ ref = query.with_for_update().first() ++ if not ref: ++ msg = 'Matching deployable_id {0}'.format(deployable_id) ++ raise exception.ResourceNotFound( ++ resource='AttachHandle', msg=msg) ++ ref.update(values) ++ context.session.flush() + return ref + + def attach_handle_allocate(self, context, deployable_id): +@@ -298,16 +309,17 @@ class Connection(api.Connection): + # NOTE: For deallocate, we use attach_handle_update() + + @oslo_db_api.retry_on_deadlock ++ @_writer + def attach_handle_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.AttachHandle) +- query = add_identity_filter(query, uuid) +- count = query.delete() +- if count != 1: +- raise exception.ResourceNotFound( +- resource='AttachHandle', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.AttachHandle) ++ query = add_identity_filter(query, uuid) ++ count = query.delete() ++ if count != 1: ++ raise exception.ResourceNotFound( ++ resource='AttachHandle', ++ msg='with uuid=%s' % uuid) + ++ @_writer + def control_path_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -315,14 +327,14 @@ class Connection(api.Connection): + control_path_id = models.ControlpathID() + control_path_id.update(values) + +- with _session_for_write() as session: +- try: +- session.add(control_path_id) +- session.flush() +- except db_exc.DBDuplicateEntry: +- raise exception.ControlpathIDAlreadyExists(uuid=values['uuid']) +- return control_path_id ++ try: ++ context.session.add(control_path_id) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry: ++ raise exception.ControlpathIDAlreadyExists(uuid=values['uuid']) ++ return control_path_id + ++ @_reader + def control_path_get_by_uuid(self, context, uuid): + query = model_query( + context, +@@ -334,6 +346,7 @@ class Connection(api.Connection): + resource='ControlpathID', + msg='with uuid=%s' % uuid) + ++ @_reader + def control_path_get_by_filters(self, context, + filters, sort_key='created_at', + sort_dir='desc', limit=None, +@@ -360,6 +373,7 @@ class Connection(api.Connection): + return _paginate_query(context, models.ControlpathID, query_prefix, + limit, marker, sort_key, sort_dir) + ++ @_reader + def control_path_list(self, context): + query = model_query(context, models.ControlpathID) + return _paginate_query(context, models.ControlpathID, query=query) +@@ -371,28 +385,29 @@ class Connection(api.Connection): + return self._do_update_control_path(context, uuid, values) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_control_path(self, context, uuid, values): +- with _session_for_write(): +- query = model_query(context, models.ControlpathID) +- query = add_identity_filter(query, uuid) +- try: +- ref = query.with_for_update().one() +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='ControlpathID', +- msg='with uuid=%s' % uuid) +- ref.update(values) ++ query = model_query(context, models.ControlpathID) ++ query = add_identity_filter(query, uuid) ++ try: ++ ref = query.with_for_update().one() ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='ControlpathID', ++ msg='with uuid=%s' % uuid) ++ ref.update(values) + return ref + + @oslo_db_api.retry_on_deadlock ++ @_writer + def control_path_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.ControlpathID) +- query = add_identity_filter(query, uuid) +- count = query.delete() +- if count != 1: +- raise exception.ControlpathNotFound(uuid=uuid) ++ query = model_query(context, models.ControlpathID) ++ query = add_identity_filter(query, uuid) ++ count = query.delete() ++ if count != 1: ++ raise exception.ControlpathNotFound(uuid=uuid) + ++ @_writer + def device_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -400,14 +415,14 @@ class Connection(api.Connection): + device = models.Device() + device.update(values) + +- with _session_for_write() as session: +- try: +- session.add(device) +- session.flush() +- except db_exc.DBDuplicateEntry: +- raise exception.DeviceAlreadyExists(uuid=values['uuid']) +- return device ++ try: ++ context.session.add(device) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry: ++ raise exception.DeviceAlreadyExists(uuid=values['uuid']) ++ return device + ++ @_reader + def device_get(self, context, uuid): + query = model_query( + context, +@@ -419,6 +434,7 @@ class Connection(api.Connection): + resource='Device', + msg='with uuid=%s' % uuid) + ++ @_reader + def device_get_by_id(self, context, id): + query = model_query( + context, +@@ -430,6 +446,7 @@ class Connection(api.Connection): + resource='Device', + msg='with id=%s' % id) + ++ @_reader + def device_list_by_filters(self, context, + filters, sort_key='created_at', + sort_dir='desc', limit=None, +@@ -453,6 +470,7 @@ class Connection(api.Connection): + return _paginate_query(context, models.Device, query_prefix, + limit, marker, sort_key, sort_dir) + ++ @_reader + def device_list(self, context, limit=None, marker=None, sort_key=None, + sort_dir=None): + query = model_query(context, models.Device) +@@ -471,31 +489,32 @@ class Connection(api.Connection): + raise exception.DuplicateDeviceName(name=values['name']) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_device(self, context, uuid, values): +- with _session_for_write(): +- query = model_query(context, models.Device) +- query = add_identity_filter(query, uuid) +- try: +- ref = query.with_for_update().one() +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='Device', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.Device) ++ query = add_identity_filter(query, uuid) ++ try: ++ ref = query.with_for_update().one() ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='Device', ++ msg='with uuid=%s' % uuid) + +- ref.update(values) ++ ref.update(values) + return ref + + @oslo_db_api.retry_on_deadlock ++ @_writer + def device_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.Device) +- query = add_identity_filter(query, uuid) +- count = query.delete() +- if count != 1: +- raise exception.ResourceNotFound( +- resource='Device', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.Device) ++ query = add_identity_filter(query, uuid) ++ count = query.delete() ++ if count != 1: ++ raise exception.ResourceNotFound( ++ resource='Device', ++ msg='with uuid=%s' % uuid) + ++ @_writer + def device_profile_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -503,24 +522,24 @@ class Connection(api.Connection): + device_profile = models.DeviceProfile() + device_profile.update(values) + +- with _session_for_write() as session: +- try: +- session.add(device_profile) +- session.flush() +- except db_exc.DBDuplicateEntry as e: +- # mysql duplicate key error changed as reference link below: +- # https://review.opendev.org/c/openstack/oslo.db/+/792124 +- LOG.info('Duplicate columns are: ', e.columns) +- columns = [column.split('0')[1] if 'uniq_' in column else +- column for column in e.columns] +- if 'name' in columns: +- raise exception.DuplicateDeviceProfileName( +- name=values['name']) +- else: +- raise exception.DeviceProfileAlreadyExists( +- uuid=values['uuid']) +- return device_profile ++ try: ++ context.session.add(device_profile) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry as e: ++ # mysql duplicate key error changed as reference link below: ++ # https://review.opendev.org/c/openstack/oslo.db/+/792124 ++ LOG.info('Duplicate columns are: ', e.columns) ++ columns = [column.split('0')[1] if 'uniq_' in column else ++ column for column in e.columns] ++ if 'name' in columns: ++ raise exception.DuplicateDeviceProfileName( ++ name=values['name']) ++ else: ++ raise exception.DeviceProfileAlreadyExists( ++ uuid=values['uuid']) ++ return device_profile + ++ @_reader + def device_profile_get_by_uuid(self, context, uuid): + query = model_query( + context, +@@ -532,6 +551,7 @@ class Connection(api.Connection): + resource='Device Profile', + msg='with uuid=%s' % uuid) + ++ @_reader + def device_profile_get_by_id(self, context, id): + query = model_query( + context, +@@ -543,6 +563,7 @@ class Connection(api.Connection): + resource='Device Profile', + msg='with id=%s' % id) + ++ @_reader + def device_profile_get(self, context, name): + query = model_query( + context, models.DeviceProfile).filter_by(name=name) +@@ -553,6 +574,7 @@ class Connection(api.Connection): + resource='Device Profile', + msg='with name=%s' % name) + ++ @_reader + def device_profile_list_by_filters( + self, context, filters, sort_key='created_at', sort_dir='desc', + limit=None, marker=None, join_columns=None): +@@ -572,6 +594,7 @@ class Connection(api.Connection): + return _paginate_query(context, models.DeviceProfile, query_prefix, + limit, marker, sort_key, sort_dir) + ++ @_reader + def device_profile_list(self, context): + query = model_query(context, models.DeviceProfile) + return _paginate_query(context, models.DeviceProfile, query=query) +@@ -588,31 +611,32 @@ class Connection(api.Connection): + raise exception.DuplicateDeviceProfileName(name=values['name']) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_device_profile(self, context, uuid, values): +- with _session_for_write(): +- query = model_query(context, models.DeviceProfile) +- query = add_identity_filter(query, uuid) +- try: +- ref = query.with_for_update().one() +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='Device Profile', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.DeviceProfile) ++ query = add_identity_filter(query, uuid) ++ try: ++ ref = query.with_for_update().one() ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='Device Profile', ++ msg='with uuid=%s' % uuid) + +- ref.update(values) ++ ref.update(values) + return ref + + @oslo_db_api.retry_on_deadlock ++ @_writer + def device_profile_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.DeviceProfile) +- query = add_identity_filter(query, uuid) +- count = query.delete() +- if count != 1: +- raise exception.ResourceNotFound( +- resource='Device Profile', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.DeviceProfile) ++ query = add_identity_filter(query, uuid) ++ count = query.delete() ++ if count != 1: ++ raise exception.ResourceNotFound( ++ resource='Device Profile', ++ msg='with uuid=%s' % uuid) + ++ @_writer + def deployable_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -621,14 +645,14 @@ class Connection(api.Connection): + deployable = models.Deployable() + deployable.update(values) + +- with _session_for_write() as session: +- try: +- session.add(deployable) +- session.flush() +- except db_exc.DBDuplicateEntry: +- raise exception.DeployableAlreadyExists(uuid=values['uuid']) +- return deployable ++ try: ++ context.session.add(deployable) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry: ++ raise exception.DeployableAlreadyExists(uuid=values['uuid']) ++ return deployable + ++ @_reader + def deployable_get(self, context, uuid): + query = model_query( + context, +@@ -640,6 +664,7 @@ class Connection(api.Connection): + resource='Deployable', + msg='with uuid=%s' % uuid) + ++ @_reader + def deployable_get_by_rp_uuid(self, context, rp_uuid): + """Get a deployable by resource provider UUID.""" + query = model_query( +@@ -652,6 +677,7 @@ class Connection(api.Connection): + resource='Deployable', + msg='with resource provider uuid=%s' % rp_uuid) + ++ @_reader + def deployable_list(self, context): + query = model_query(context, models.Deployable) + return query.all() +@@ -668,32 +694,32 @@ class Connection(api.Connection): + raise exception.DuplicateDeployableName(name=values['name']) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_deployable(self, context, uuid, values): +- with _session_for_write(): +- query = model_query(context, models.Deployable) +- # query = add_identity_filter(query, uuid) +- query = query.filter_by(uuid=uuid) +- try: +- ref = query.with_for_update().one() +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='Deployable', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.Deployable) ++ # query = add_identity_filter(query, uuid) ++ query = query.filter_by(uuid=uuid) ++ try: ++ ref = query.with_for_update().one() ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='Deployable', ++ msg='with uuid=%s' % uuid) + +- ref.update(values) ++ ref.update(values) + return ref + + @oslo_db_api.retry_on_deadlock ++ @_writer + def deployable_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.Deployable) +- query = add_identity_filter(query, uuid) +- query.update({'root_id': None}) +- count = query.delete() +- if count != 1: +- raise exception.ResourceNotFound( +- resource='Deployable', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.Deployable) ++ query = add_identity_filter(query, uuid) ++ query.update({'root_id': None}) ++ count = query.delete() ++ if count != 1: ++ raise exception.ResourceNotFound( ++ resource='Deployable', ++ msg='with uuid=%s' % uuid) + + def deployable_get_by_filters(self, context, + filters, sort_key='created_at', +@@ -709,6 +735,7 @@ class Connection(api.Connection): + sort_key=sort_key, + sort_dir=sort_dir) + ++ @_reader + def deployable_get_by_filters_sort(self, context, filters, limit=None, + marker=None, join_columns=None, + sort_key=None, sort_dir=None): +@@ -736,6 +763,7 @@ class Connection(api.Connection): + return _paginate_query(context, models.Deployable, query_prefix, + limit, marker, sort_key, sort_dir) + ++ @_writer + def attribute_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -744,15 +772,15 @@ class Connection(api.Connection): + attribute = models.Attribute() + attribute.update(values) + +- with _session_for_write() as session: +- try: +- session.add(attribute) +- session.flush() +- except db_exc.DBDuplicateEntry: +- raise exception.AttributeAlreadyExists( +- uuid=values['uuid']) +- return attribute ++ try: ++ context.session.add(attribute) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry: ++ raise exception.AttributeAlreadyExists( ++ uuid=values['uuid']) ++ return attribute + ++ @_reader + def attribute_get(self, context, uuid): + query = model_query( + context, +@@ -764,12 +792,14 @@ class Connection(api.Connection): + resource='Attribute', + msg='with uuid=%s' % uuid) + ++ @_reader + def attribute_get_by_deployable_id(self, context, deployable_id): + query = model_query( + context, + models.Attribute).filter_by(deployable_id=deployable_id) + return query.all() + ++ @_reader + def attribute_get_by_filter(self, context, filters): + """Return attributes that matches the filters + """ +@@ -802,31 +832,32 @@ class Connection(api.Connection): + return self._do_update_attribute(context, uuid, key, value) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_attribute(self, context, uuid, key, value): + update_fields = {'key': key, 'value': value} +- with _session_for_write(): +- query = model_query(context, models.Attribute) +- query = add_identity_filter(query, uuid) +- try: +- ref = query.with_for_update().one() +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='Attribute', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.Attribute) ++ query = add_identity_filter(query, uuid) ++ try: ++ ref = query.with_for_update().one() ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='Attribute', ++ msg='with uuid=%s' % uuid) + +- ref.update(update_fields) ++ ref.update(update_fields) + return ref + ++ @_writer + def attribute_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.Attribute) +- query = add_identity_filter(query, uuid) +- count = query.delete() +- if count != 1: +- raise exception.ResourceNotFound( +- resource='Attribute', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.Attribute) ++ query = add_identity_filter(query, uuid) ++ count = query.delete() ++ if count != 1: ++ raise exception.ResourceNotFound( ++ resource='Attribute', ++ msg='with uuid=%s' % uuid) + ++ @_writer + def extarq_create(self, context, values): + if not values.get('uuid'): + values['uuid'] = uuidutils.generate_uuid() +@@ -845,24 +876,23 @@ class Connection(api.Connection): + extarq = models.ExtArq() + extarq.update(values) + +- with _session_for_write() as session: +- try: +- session.add(extarq) +- session.flush() +- except db_exc.DBDuplicateEntry: +- raise exception.ExtArqAlreadyExists(uuid=values['uuid']) +- return extarq ++ try: ++ context.session.add(extarq) ++ context.session.flush() ++ except db_exc.DBDuplicateEntry: ++ raise exception.ExtArqAlreadyExists(uuid=values['uuid']) ++ return extarq + + @oslo_db_api.retry_on_deadlock ++ @_writer + def extarq_delete(self, context, uuid): +- with _session_for_write(): +- query = model_query(context, models.ExtArq) +- query = add_identity_filter(query, uuid) +- count = query.delete() +- if count != 1: +- raise exception.ResourceNotFound( +- resource='ExtArq', +- msg='with uuid=%s' % uuid) ++ query = model_query(context, models.ExtArq) ++ query = add_identity_filter(query, uuid) ++ count = query.delete() ++ if count != 1: ++ raise exception.ResourceNotFound( ++ resource='ExtArq', ++ msg='with uuid=%s' % uuid) + + def extarq_update(self, context, uuid, values, state_scope=None): + if 'uuid' in values and values['uuid'] != uuid: +@@ -871,24 +901,25 @@ class Connection(api.Connection): + return self._do_update_extarq(context, uuid, values, state_scope) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def _do_update_extarq(self, context, uuid, values, state_scope=None): +- with _session_for_write(): +- query = model_query(context, models.ExtArq) +- query = query_update = query.filter_by( +- uuid=uuid).with_for_update() +- if type(state_scope) is list: +- query_update = query_update.filter( +- models.ExtArq.state.in_(state_scope)) +- try: +- query_update.update( +- values, synchronize_session="fetch") +- except NoResultFound: +- raise exception.ResourceNotFound( +- resource='ExtArq', +- msg='with uuid=%s' % uuid) +- ref = query.first() ++ query = model_query(context, models.ExtArq) ++ query = query_update = query.filter_by( ++ uuid=uuid).with_for_update() ++ if type(state_scope) is list: ++ query_update = query_update.filter( ++ models.ExtArq.state.in_(state_scope)) ++ try: ++ query_update.update( ++ values, synchronize_session="fetch") ++ except NoResultFound: ++ raise exception.ResourceNotFound( ++ resource='ExtArq', ++ msg='with uuid=%s' % uuid) ++ ref = query.first() + return ref + ++ @_reader + def extarq_list(self, context, uuid_range=None): + query = model_query(context, models.ExtArq) + if type(uuid_range) is list: +@@ -897,6 +928,7 @@ class Connection(api.Connection): + return _paginate_query(context, models.ExtArq, query) + + @oslo_db_api.retry_on_deadlock ++ @_writer + def extarq_get(self, context, uuid, lock=False): + query = model_query( + context, +@@ -911,6 +943,7 @@ class Connection(api.Connection): + resource='ExtArq', + msg='with uuid=%s' % uuid) + ++ @_writer + def _get_quota_usages(self, context, project_id, resources=None): + # Broken out for testability + query = model_query(context, models.QuotaUsage,).filter_by( +@@ -966,86 +999,86 @@ class Connection(api.Connection): + with_for_update(). \ + all() + ++ @_writer + def quota_reserve(self, context, resources, deltas, expire, + until_refresh, max_age, project_id=None, + is_allocated_reserve=False): + """Create reservation record in DB according to params""" +- with _session_for_write() as session: +- if project_id is None: +- project_id = context.project_id +- usages = self._get_quota_usages(context, project_id, +- resources=deltas.keys()) +- work = set(deltas.keys()) +- while work: +- resource = work.pop() +- +- # Do we need to refresh the usage? +- refresh = False +- # create quota usage in DB if there is no record of this type +- # of resource +- if resource not in usages: +- usages[resource] = self._quota_usage_create( +- project_id, resource, until_refresh or None, +- in_use=0, reserved=0, session=session) +- refresh = True +- elif usages[resource].in_use < 0: +- # Negative in_use count indicates a desync, so try to +- # heal from that... ++ if project_id is None: ++ project_id = context.project_id ++ usages = self._get_quota_usages(context, project_id, ++ resources=deltas.keys()) ++ work = set(deltas.keys()) ++ while work: ++ resource = work.pop() ++ ++ # Do we need to refresh the usage? ++ refresh = False ++ # create quota usage in DB if there is no record of this type ++ # of resource ++ if resource not in usages: ++ usages[resource] = self._quota_usage_create( ++ project_id, resource, until_refresh or None, ++ in_use=0, reserved=0, session=context.session) ++ refresh = True ++ elif usages[resource].in_use < 0: ++ # Negative in_use count indicates a desync, so try to ++ # heal from that... ++ refresh = True ++ elif usages[resource].until_refresh is not None: ++ usages[resource].until_refresh -= 1 ++ if usages[resource].until_refresh <= 0: + refresh = True +- elif usages[resource].until_refresh is not None: +- usages[resource].until_refresh -= 1 +- if usages[resource].until_refresh <= 0: +- refresh = True +- elif max_age and usages[resource].updated_at is not None and ( +- (timeutils.utcnow() - +- usages[resource].updated_at).total_seconds() >= +- max_age): +- refresh = True +- +- # refresh the usage +- if refresh: +- # Grab the sync routine +- updates = self._sync_acc_res(context, +- resource, project_id) +- for res, in_use in updates.items(): +- # Make sure we have a destination for the usage! +- if res not in usages: +- usages[res] = self._quota_usage_create( +- project_id, +- res, +- until_refresh or None, +- in_use=0, +- reserved=0, +- session=session +- ) +- +- # Update the usage +- usages[res].in_use = in_use +- usages[res].until_refresh = until_refresh or None +- +- # Because more than one resource may be refreshed +- # by the call to the sync routine, and we don't +- # want to double-sync, we make sure all refreshed +- # resources are dropped from the work set. +- work.discard(res) +- +- # NOTE(Vek): We make the assumption that the sync +- # routine actually refreshes the +- # resources that it is the sync routine +- # for. We don't check, because this is +- # a best-effort mechanism. +- +- unders = [r for r, delta in deltas.items() +- if delta < 0 and delta + usages[r].in_use < 0] +- reservations = [] +- for resource, delta in deltas.items(): +- usage = usages[resource] +- reservation = self._reservation_create( +- str(uuid.uuid4()), usage, project_id, resource, +- delta, expire, session=session) +- reservations.append(reservation.uuid) +- usages[resource].reserved += delta +- session.flush() ++ elif max_age and usages[resource].updated_at is not None and ( ++ (timeutils.utcnow() - ++ usages[resource].updated_at).total_seconds() >= ++ max_age): ++ refresh = True ++ ++ # refresh the usage ++ if refresh: ++ # Grab the sync routine ++ updates = self._sync_acc_res(context, ++ resource, project_id) ++ for res, in_use in updates.items(): ++ # Make sure we have a destination for the usage! ++ if res not in usages: ++ usages[res] = self._quota_usage_create( ++ project_id, ++ res, ++ until_refresh or None, ++ in_use=0, ++ reserved=0, ++ session=context.session ++ ) ++ ++ # Update the usage ++ usages[res].in_use = in_use ++ usages[res].until_refresh = until_refresh or None ++ ++ # Because more than one resource may be refreshed ++ # by the call to the sync routine, and we don't ++ # want to double-sync, we make sure all refreshed ++ # resources are dropped from the work set. ++ work.discard(res) ++ ++ # NOTE(Vek): We make the assumption that the sync ++ # routine actually refreshes the ++ # resources that it is the sync routine ++ # for. We don't check, because this is ++ # a best-effort mechanism. ++ ++ unders = [r for r, delta in deltas.items() ++ if delta < 0 and delta + usages[r].in_use < 0] ++ reservations = [] ++ for resource, delta in deltas.items(): ++ usage = usages[resource] ++ reservation = self._reservation_create( ++ str(uuid.uuid4()), usage, project_id, resource, ++ delta, expire, session=context.session) ++ reservations.append(reservation.uuid) ++ usages[resource].reserved += delta ++ context.session.flush() + if unders: + LOG.warning("Change will make usage less than 0 for the " + "following resources: %s", unders) +@@ -1057,6 +1090,7 @@ class Connection(api.Connection): + project_id) + return {resource: res_in_use} + ++ @_reader + def _device_data_get_for_project(self, context, resource, project_id): + """Return the number of resource which is being used by a project""" + query = model_query(context, models.Device).filter_by(type=resource) +@@ -1066,24 +1100,24 @@ class Connection(api.Connection): + def _dict_with_usage_id(self, usages): + return {row.id: row for row in usages.values()} + ++ @_writer + def reservation_commit(self, context, reservations, project_id=None): + """Commit quota reservation to quota usage table""" +- with _session_for_write() as session: +- quota_usage = self._get_quota_usages( +- context, project_id, +- resources=self._get_reservation_resources(context, +- reservations)) +- usages = self._dict_with_usage_id(quota_usage) +- +- for reservation in self._quota_reservations(session, context, +- reservations): +- +- usage = usages[reservation.usage_id] +- if reservation.delta >= 0: +- usage.reserved -= reservation.delta +- usage.in_use += reservation.delta +- session.flush() +- reservation.delete(session=session) ++ quota_usage = self._get_quota_usages( ++ context, project_id, ++ resources=self._get_reservation_resources(context, ++ reservations)) ++ usages = self._dict_with_usage_id(quota_usage) ++ ++ for reservation in self._quota_reservations(context.session, context, ++ reservations): ++ ++ usage = usages[reservation.usage_id] ++ if reservation.delta >= 0: ++ usage.reserved -= reservation.delta ++ usage.in_use += reservation.delta ++ context.session.flush() ++ reservation.delete(session=context.session) + + def process_sort_params(self, sort_keys, sort_dirs, + default_keys=['created_at', 'id'], +Index: cyborg/cyborg/tests/base.py +=================================================================== +--- cyborg.orig/cyborg/tests/base.py ++++ cyborg/cyborg/tests/base.py +@@ -18,7 +18,6 @@ from unittest import mock + + from oslo_config import cfg + from oslo_config import fixture as config_fixture +-from oslo_context import context + from oslo_db import options + from oslo_log import log + from oslo_utils import excutils +@@ -29,6 +28,7 @@ import eventlet + import testtools + + from cyborg.common import config as cyborg_config ++from cyborg import context as cyborg_context + from cyborg.tests import post_mortem_debug + from cyborg.tests.unit import policy_fixture + +@@ -46,7 +46,7 @@ class TestCase(base.BaseTestCase): + + def setUp(self): + super(TestCase, self).setUp() +- self.context = context.get_admin_context() ++ self.context = cyborg_context.get_admin_context() + self._set_config() + self.policy = self.useFixture(policy_fixture.PolicyFixture()) + +@@ -106,7 +106,7 @@ class DietTestCase(base.BaseTestCase): + + def setUp(self): + super(DietTestCase, self).setUp() +- self.context = context.get_admin_context() ++ self.context = cyborg_context.get_admin_context() + + options.set_defaults(cfg.CONF, connection='sqlite://') + +Index: cyborg/cyborg/tests/unit/agent/test_rpcapi.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/agent/test_rpcapi.py ++++ cyborg/cyborg/tests/unit/agent/test_rpcapi.py +@@ -17,9 +17,9 @@ import oslo_messaging as messaging + from cyborg.agent.rpcapi import AgentAPI + from cyborg.common import constants + from cyborg.common import rpc ++from cyborg import context as cyborg_context + from cyborg.objects import base as objects_base + from cyborg.tests import base +-from oslo_context import context as oslo_context + from unittest import mock + + +@@ -40,8 +40,8 @@ class TestRPCAPI(base.TestCase): + serializer=self.serializer) + + def _test_rpc_call(self, method): +- ctxt = oslo_context.RequestContext(user_id='fake_user', +- project_id='fake_project') ++ ctxt = cyborg_context.RequestContext(user_id='fake_user', ++ project_id='fake_project') + expect_val = True + with mock.patch.object(self.agent_rpcapi, + 'fpga_program') as mock_program: +Index: cyborg/cyborg/tests/unit/api/base.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/api/base.py ++++ cyborg/cyborg/tests/unit/api/base.py +@@ -16,10 +16,10 @@ + """Base classes for API tests.""" + + from oslo_config import cfg +-from oslo_context import context + import pecan + import pecan.testing + ++from cyborg import context as cyborg_context + from cyborg.tests.unit.db import base + + cfg.CONF.import_group('keystone_authtoken', 'keystonemiddleware.auth_token') +@@ -108,7 +108,7 @@ class BaseApiTest(base.DbTestCase): + status=status, method="post") + + def gen_context(self, value, **kwargs): +- ct = context.RequestContext.from_dict(value, **kwargs) ++ ct = cyborg_context.RequestContext.from_dict(value, **kwargs) + return ct + + def gen_headers(self, context, **kw): +Index: cyborg/cyborg/tests/unit/db/base.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/db/base.py ++++ cyborg/cyborg/tests/unit/db/base.py +@@ -17,9 +17,9 @@ + + import fixtures + from oslo_config import cfg +-from oslo_db.sqlalchemy import enginefacade + + from cyborg.db import api as dbapi ++from cyborg.db.sqlalchemy import api as sqlalchemy_api + from cyborg.db.sqlalchemy import migration + from cyborg.db.sqlalchemy import models + from cyborg.tests import base +@@ -65,7 +65,11 @@ class DbTestCase(base.TestCase): + + global _DB_CACHE + if not _DB_CACHE: +- engine = enginefacade.get_legacy_facade().get_engine() ++ engine = ( ++ sqlalchemy_api.main_context_manager ++ .get_legacy_facade() ++ .get_engine() ++ ) + _DB_CACHE = Database(engine, migration, + sql_connection=CONF.database.connection) + self.useFixture(_DB_CACHE) +Index: cyborg/cyborg/tests/unit/objects/test_objects.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/objects/test_objects.py ++++ cyborg/cyborg/tests/unit/objects/test_objects.py +@@ -16,8 +16,8 @@ import datetime + + from oslo_log import log + +-from oslo_context import context + ++from cyborg import context as cyborg_context + from cyborg.objects import base + from cyborg.objects import fields + from cyborg import tests as test +@@ -192,7 +192,8 @@ class _BaseTestCase(test.base.TestCase): + super(_BaseTestCase, self).setUp() + self.user_id = 'fake-user' + self.project_id = 'fake-project' +- self.context = context.RequestContext(self.user_id, self.project_id) ++ self.context = cyborg_context.RequestContext(self.user_id, ++ self.project_id) + + base.CyborgObjectRegistry.register(MyObj) + base.CyborgObjectRegistry.register(MyOwnedObject) diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_6_Add_project_id_backfill_for_existing_ARQs.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_6_Add_project_id_backfill_for_existing_ARQs.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_6_Add_project_id_backfill_for_existing_ARQs.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_6_Add_project_id_backfill_for_existing_ARQs.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,879 @@ +Author: Sean Mooney +Date: Sun, 26 Apr 2026 18:25:28 +0000 +Description: CVE-2026-40213 / CVE-2026-40214: Add project_id backfill for existing ARQs + All existing ARQs have project_id=NULL because it was never + set (see previous commit). Add two mechanisms to heal this: + . + 1. cyborg-dbsync online_data_migrations: operator-run bulk + backfill that queries Nova for each bound ARQ's instance + project_id. + 2. Conductor startup heal: automatic heal on service start + for any remaining NULL project_id ARQs. + . + Unbound ARQs without instance_uuid cannot be healed and + remain admin-only after enforcement is enabled. +Bug: https://launchpad.net/bugs/2144056 +Bug-Debian: https://bugs.debian.org/1136006 +Generated-By: Cursor claude-opus-4.6 +Signed-off-by: Sean Mooney +Change-Id: Id061dbc95d8421fc7f9a860ef5081d2361b02747 +Origin: https://review.opendev.org/c/openstack/cyborg/+/987701 +Last-Update: 2025-05-11 + +Index: cyborg/cyborg/agent/manager.py +=================================================================== +--- cyborg.orig/cyborg/agent/manager.py ++++ cyborg/cyborg/agent/manager.py +@@ -55,6 +55,10 @@ class AgentManager(periodic_task.Periodi + self.image_api = ImageAPI() + self._rt = ResourceTracker(host, self.cond_api) + ++ def init_host(self): ++ """Hook called by RPCService.start() after the RPC server is up.""" ++ pass ++ + def periodic_tasks(self, context, raise_on_error=False): + return self.run_periodic_tasks(context, raise_on_error=raise_on_error) + +Index: cyborg/cyborg/cmd/dbsync.py +=================================================================== +--- cyborg.orig/cyborg/cmd/dbsync.py ++++ cyborg/cyborg/cmd/dbsync.py +@@ -21,6 +21,7 @@ import sys + + from oslo_config import cfg + ++from cyborg.common import data_migrations + from cyborg.common.i18n import _ + from cyborg.common import service + from cyborg.conf import CONF +@@ -44,6 +45,10 @@ class DBCommand(object): + def create_schema(self): + migration.create_schema() + ++ def online_data_migrations(self): ++ count = data_migrations.heal_arq_project_ids() ++ print('Migrated %d ARQ(s)' % count) ++ + + def add_command_parsers(subparsers): + command_object = DBCommand() +@@ -78,6 +83,16 @@ def add_command_parsers(subparsers): + help=_("Create the database schema.")) + parser.set_defaults(func=command_object.create_schema) + ++ parser = subparsers.add_parser( ++ 'online_data_migrations', ++ help=_( ++ "Perform online data migrations. " ++ "Currently backfills project_id on existing ARQs " ++ "by querying Nova for instance details." ++ ), ++ ) ++ parser.set_defaults(func=command_object.online_data_migrations) ++ + + def main(): + command_opt = cfg.SubCommandOpt('command', +Index: cyborg/cyborg/common/data_migrations.py +=================================================================== +--- /dev/null ++++ cyborg/cyborg/common/data_migrations.py +@@ -0,0 +1,138 @@ ++# 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. ++ ++"""Online data migrations for Cyborg.""" ++ ++from openstack import exceptions as sdk_exc ++from oslo_log import log as logging ++ ++from cyborg.common import utils ++from cyborg import context as cyborg_context ++from cyborg.db import api as dbapi ++ ++ ++LOG = logging.getLogger(__name__) ++ ++HEAL_BATCH_SIZE = 100 ++ ++# Nova compute microversion for this module's GET /servers/{id} calls so the ++# response shape used here (``tenant_id``) is stable. 2.82 also introduced the ++# accelerator-request-bound external event Cyborg uses with Nova; deployments ++# that integrate Cyborg with Nova are expected to support at least this level ++# for those flows. This constant is not a general statement of the minimum ++# Nova microversion Cyborg supports across all API usage. ++NOVA_COMPUTE_MICROVERSION = '2.82' ++ ++ ++def _get_nova_adapter(): ++ """Build an openstacksdk adapter for the compute service.""" ++ # NOTE(sean-k-mooney): Pass ``microversion=`` on each Nova request rather ++ # than setting ``default_microversion`` on the adapter so every call's ++ # contract is explicit. Extend this pattern to other Nova callers. ++ return utils.get_sdk_adapter('compute') ++ ++ ++def heal_arq_project_ids(): ++ """Back-fill project_id on ARQs by querying Nova for instance details. ++ ++ Finds all ARQs where project_id IS NULL and instance_uuid IS NOT NULL, ++ looks up each instance in Nova to get its project_id, and updates ++ the ARQ row. Processes rows in batches of ``HEAL_BATCH_SIZE`` using ++ marker-based pagination to bound memory usage. ++ ++ :returns: number of ARQs successfully migrated. ++ """ ++ db = dbapi.get_instance() ++ context = cyborg_context.get_admin_context() ++ nova = _get_nova_adapter() ++ migrated = 0 ++ instance_cache = {} ++ marker = None ++ ++ while True: ++ batch = db.extarq_list( ++ context, ++ project_id=dbapi.NULL_FILTER, ++ instance_uuid=dbapi.NOT_NULL_FILTER, ++ limit=HEAL_BATCH_SIZE, ++ marker=marker, ++ ) ++ if not batch: ++ break ++ ++ LOG.info( ++ 'Processing batch of %d ARQ(s) with NULL project_id.', len(batch) ++ ) ++ ++ for arq in batch: ++ instance_uuid = arq['instance_uuid'] ++ if instance_uuid in instance_cache: ++ project_id = instance_cache[instance_uuid] ++ else: ++ try: ++ response = nova.get( ++ '/servers/%s' % instance_uuid, ++ microversion=NOVA_COMPUTE_MICROVERSION, ++ ) ++ server = response.json().get('server', {}) ++ # NOTE(sean-k-mooney): If Nova exposes project_id on the ++ # server representation at a stable microversion, prefer it ++ # over tenant_id. ++ project_id = server.get('tenant_id', None) ++ if not project_id: ++ LOG.warning( ++ 'Instance %s returned an unexpected response ' ++ 'shape for ARQ %s; skipping.', ++ instance_uuid, ++ arq['uuid'], ++ ) ++ continue ++ instance_cache[instance_uuid] = project_id ++ except sdk_exc.ResourceNotFound: ++ LOG.error( ++ 'Instance %s not found in Nova. ARQ %s ' ++ 'references a deleted instance and should be ' ++ 'cleaned up manually.', ++ instance_uuid, ++ arq['uuid'], ++ ) ++ continue ++ except sdk_exc.HttpException: ++ LOG.warning( ++ 'Failed to look up instance %s for ARQ %s; skipping.', ++ instance_uuid, ++ arq['uuid'], ++ ) ++ continue ++ ++ try: ++ db.extarq_update( ++ context, arq['uuid'], {'project_id': project_id} ++ ) ++ migrated += 1 ++ LOG.info( ++ 'Healed ARQ %s: set project_id=%s', arq['uuid'], project_id ++ ) ++ except Exception: ++ LOG.warning( ++ 'Failed to update ARQ %s with project_id=%s', ++ arq['uuid'], ++ project_id, ++ ) ++ ++ marker = batch[-1]['id'] ++ ++ if len(batch) < HEAL_BATCH_SIZE: ++ break ++ ++ LOG.info('Migrated %d ARQ(s).', migrated) ++ return migrated +Index: cyborg/cyborg/common/service.py +=================================================================== +--- cyborg.orig/cyborg/common/service.py ++++ cyborg/cyborg/common/service.py +@@ -53,6 +53,8 @@ class RPCService(service.Service): + self.rpcserver = rpc.get_server(target, endpoints, serializer) + self.rpcserver.start() + ++ self.manager.init_host() ++ + admin_context = context.get_admin_context() + self.tg.add_dynamic_timer( + self.manager.periodic_tasks, +Index: cyborg/cyborg/conductor/manager.py +=================================================================== +--- cyborg.orig/cyborg/conductor/manager.py ++++ cyborg/cyborg/conductor/manager.py +@@ -17,6 +17,7 @@ from oslo_log import log as logging + import oslo_messaging as messaging + import uuid + ++from cyborg.common import data_migrations + from cyborg.common import exception + from cyborg.common import placement_client + from cyborg.conf import CONF +@@ -44,6 +45,20 @@ class ConductorManager(object): + self.host = host or CONF.host + self.placement_client = placement_client.PlacementClient() + ++ def init_host(self): ++ """Hook called on service startup. Heals NULL project_id ARQs.""" ++ try: ++ count = data_migrations.heal_arq_project_ids() ++ if count: ++ LOG.info( ++ 'Conductor startup: healed project_id on %d ARQ(s).', count ++ ) ++ except Exception: ++ LOG.exception( ++ 'Conductor startup: failed to heal ARQ project_ids. ' ++ 'Run cyborg-dbsync online_data_migrations manually.' ++ ) ++ + def periodic_tasks(self, context, raise_on_error=False): + pass + +Index: cyborg/cyborg/db/api.py +=================================================================== +--- cyborg.orig/cyborg/db/api.py ++++ cyborg/cyborg/db/api.py +@@ -32,7 +32,29 @@ def get_instance(): + return IMPL + + +-class Connection(object, metaclass=abc.ABCMeta): ++class _FilterSentinel: ++ """Sentinel for NULL / NOT NULL database column filters. ++ ++ Use ``NULL_FILTER`` to match rows where a column IS NULL and ++ ``NOT_NULL_FILTER`` to match rows where a column IS NOT NULL, ++ distinguishing from ``None`` which means "no filter". ++ """ ++ ++ def __init__(self, name): ++ self._name = name ++ ++ def __repr__(self): ++ return self._name ++ ++ def __bool__(self): ++ return True ++ ++ ++NULL_FILTER = _FilterSentinel('NULL_FILTER') ++NOT_NULL_FILTER = _FilterSentinel('NOT_NULL_FILTER') ++ ++ ++class Connection(metaclass=abc.ABCMeta): + """Base class for storage system connections.""" + + @abc.abstractmethod +@@ -186,8 +208,27 @@ class Connection(object, metaclass=abc.A + """Update an extarq.""" + + @abc.abstractmethod +- def extarq_list(self, context, uuid_range=None): +- """Get requested list of extarqs.""" ++ def extarq_list( ++ self, ++ context, ++ uuid_range=None, ++ project_id=None, ++ instance_uuid=None, ++ limit=None, ++ marker=None, ++ ): ++ """Get requested list of extarqs. ++ ++ :param project_id: Filter by project_id. Pass ``NULL_FILTER`` ++ to match rows where project_id IS NULL, or ++ ``NOT_NULL_FILTER`` to match IS NOT NULL. ++ :param instance_uuid: Filter by instance_uuid. Pass ++ ``NULL_FILTER`` to match rows where instance_uuid IS NULL, ++ or ``NOT_NULL_FILTER`` to match IS NOT NULL. ++ :param limit: Maximum number of rows to return. ++ :param marker: The last-seen ``id`` value; rows after this ++ marker are returned (keyset pagination). ++ """ + + @abc.abstractmethod + def extarq_get(self, context, uuid, lock=False): +Index: cyborg/cyborg/db/sqlalchemy/api.py +=================================================================== +--- cyborg.orig/cyborg/db/sqlalchemy/api.py ++++ cyborg/cyborg/db/sqlalchemy/api.py +@@ -920,12 +920,37 @@ class Connection(api.Connection): + return ref + + @_reader +- def extarq_list(self, context, uuid_range=None): ++ def extarq_list( ++ self, ++ context, ++ uuid_range=None, ++ project_id=None, ++ instance_uuid=None, ++ limit=None, ++ marker=None, ++ ): + query = model_query(context, models.ExtArq) ++ if project_id is api.NULL_FILTER: ++ query = query.filter(models.ExtArq.project_id.is_(None)) ++ elif project_id is api.NOT_NULL_FILTER: ++ query = query.filter(models.ExtArq.project_id.isnot(None)) ++ elif project_id: ++ query = query.filter_by(project_id=project_id) ++ if instance_uuid is api.NULL_FILTER: ++ query = query.filter(models.ExtArq.instance_uuid.is_(None)) ++ elif instance_uuid is api.NOT_NULL_FILTER: ++ query = query.filter(models.ExtArq.instance_uuid.isnot(None)) ++ elif instance_uuid: ++ query = query.filter_by(instance_uuid=instance_uuid) + if type(uuid_range) is list: +- query = query.filter( +- models.ExtArq.uuid.in_(uuid_range)) +- return _paginate_query(context, models.ExtArq, query) ++ query = query.filter(models.ExtArq.uuid.in_(uuid_range)) ++ if marker is not None: ++ marker = ( ++ model_query(context, models.ExtArq).filter_by(id=marker).one() ++ ) ++ return _paginate_query( ++ context, models.ExtArq, query, limit=limit, marker=marker ++ ) + + @oslo_db_api.retry_on_deadlock + @_writer +Index: cyborg/cyborg/tests/unit/common/test_data_migrations.py +=================================================================== +--- /dev/null ++++ cyborg/cyborg/tests/unit/common/test_data_migrations.py +@@ -0,0 +1,336 @@ ++# 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. ++ ++from unittest import mock ++ ++from openstack import exceptions as sdk_exc ++from oslo_utils.fixture import uuidsentinel as uuids ++ ++from cyborg.common import data_migrations ++from cyborg.db import api as dbapi ++from cyborg.tests import base ++ ++ ++class TestHealArqProjectIds(base.TestCase): ++ """Tests for Bug #2144056: back-fill project_id on existing ARQs.""" ++ ++ _FILTER_KWARGS = dict( ++ project_id=dbapi.NULL_FILTER, ++ instance_uuid=dbapi.NOT_NULL_FILTER, ++ ) ++ ++ def _mock_nova_response(self, tenant_id): ++ """Build a mock openstacksdk response for GET /servers/{uuid}.""" ++ resp = mock.MagicMock() ++ resp.json.return_value = { ++ 'server': {'tenant_id': tenant_id}, ++ } ++ return resp ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_bound_arqs_sets_project_id(self, mock_get_db, mock_get_nova): ++ fake_arq = { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance1), ++ } ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = [fake_arq] ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.return_value = self._mock_nova_response( ++ str(uuids.project_a) ++ ) ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(1, count) ++ mock_nova.get.assert_called_once_with( ++ '/servers/%s' % str(uuids.instance1), ++ microversion=data_migrations.NOVA_COMPUTE_MICROVERSION, ++ ) ++ mock_db.extarq_list.assert_called_once_with( ++ mock.ANY, ++ limit=data_migrations.HEAL_BATCH_SIZE, ++ marker=None, ++ **self._FILTER_KWARGS, ++ ) ++ mock_db.extarq_update.assert_called_once_with( ++ mock.ANY, ++ str(uuids.arq1), ++ {'project_id': str(uuids.project_a)}, ++ ) ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_no_arqs_to_heal(self, mock_get_db, mock_get_nova): ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = [] ++ mock_get_db.return_value = mock_db ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(0, count) ++ mock_db.extarq_list.assert_called_once_with( ++ mock.ANY, ++ limit=data_migrations.HEAL_BATCH_SIZE, ++ marker=None, ++ **self._FILTER_KWARGS, ++ ) ++ mock_db.extarq_update.assert_not_called() ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_handles_deleted_instance(self, mock_get_db, mock_get_nova): ++ fake_arq = { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.deleted_instance), ++ } ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = [fake_arq] ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.side_effect = sdk_exc.ResourceNotFound ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(0, count) ++ mock_db.extarq_update.assert_not_called() ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_handles_nova_error(self, mock_get_db, mock_get_nova): ++ fake_arq = { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance1), ++ } ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = [fake_arq] ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.side_effect = sdk_exc.HttpException ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(0, count) ++ mock_db.extarq_update.assert_not_called() ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_handles_malformed_nova_response( ++ self, mock_get_db, mock_get_nova ++ ): ++ fake_arq = { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance1), ++ } ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = [fake_arq] ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.return_value = mock.MagicMock() ++ mock_nova.get.return_value.json.return_value = {'server': {}} ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(0, count) ++ mock_db.extarq_update.assert_not_called() ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_caches_instance_lookups(self, mock_get_db, mock_get_nova): ++ """Two ARQs for the same instance should only trigger one Nova call.""" ++ fake_arqs = [ ++ { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance1), ++ }, ++ { ++ 'id': 2, ++ 'uuid': str(uuids.arq2), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance1), ++ }, ++ ] ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = fake_arqs ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.return_value = self._mock_nova_response( ++ str(uuids.project_a) ++ ) ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(2, count) ++ mock_nova.get.assert_called_once_with( ++ '/servers/%s' % str(uuids.instance1), ++ microversion=data_migrations.NOVA_COMPUTE_MICROVERSION, ++ ) ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_returns_count(self, mock_get_db, mock_get_nova): ++ fake_arqs = [ ++ { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance1), ++ }, ++ { ++ 'id': 2, ++ 'uuid': str(uuids.arq2), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance2), ++ }, ++ ] ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = fake_arqs ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.side_effect = [ ++ self._mock_nova_response(str(uuids.project1)), ++ self._mock_nova_response(str(uuids.project2)), ++ ] ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(2, count) ++ self.assertEqual(2, mock_nova.get.call_count) ++ for call in mock_nova.get.call_args_list: ++ self.assertEqual( ++ data_migrations.NOVA_COMPUTE_MICROVERSION, ++ call.kwargs['microversion'], ++ ) ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_continues_on_partial_failure( ++ self, mock_get_db, mock_get_nova ++ ): ++ """One deleted instance should not prevent healing other ARQs.""" ++ fake_arqs = [ ++ { ++ 'id': 1, ++ 'uuid': str(uuids.arq1), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.deleted_instance), ++ }, ++ { ++ 'id': 2, ++ 'uuid': str(uuids.arq2), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance2), ++ }, ++ ] ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.return_value = fake_arqs ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.side_effect = [ ++ sdk_exc.ResourceNotFound, ++ self._mock_nova_response(str(uuids.project2)), ++ ] ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(1, count) ++ mock_db.extarq_update.assert_called_once_with( ++ mock.ANY, ++ str(uuids.arq2), ++ {'project_id': str(uuids.project2)}, ++ ) ++ ++ @mock.patch.object(data_migrations, '_get_nova_adapter', autospec=True) ++ @mock.patch('cyborg.db.api.get_instance', autospec=True) ++ def test_heal_paginates_in_batches(self, mock_get_db, mock_get_nova): ++ """Verify marker-based pagination across multiple batches.""" ++ batch1 = [ ++ { ++ 'id': i + 1, ++ 'uuid': 'arq-%d' % (i + 1), ++ 'project_id': None, ++ 'instance_uuid': 'inst-%d' % (i + 1), ++ } ++ for i in range(data_migrations.HEAL_BATCH_SIZE) ++ ] ++ batch2 = [ ++ { ++ 'id': data_migrations.HEAL_BATCH_SIZE + 1, ++ 'uuid': str(uuids.arq_last), ++ 'project_id': None, ++ 'instance_uuid': str(uuids.instance_last), ++ }, ++ ] ++ ++ mock_db = mock.MagicMock() ++ mock_db.extarq_list.side_effect = [batch1, batch2] ++ mock_get_db.return_value = mock_db ++ ++ mock_nova = mock.MagicMock() ++ mock_nova.get.return_value = self._mock_nova_response( ++ str(uuids.project_a) ++ ) ++ mock_get_nova.return_value = mock_nova ++ ++ count = data_migrations.heal_arq_project_ids() ++ ++ self.assertEqual(data_migrations.HEAL_BATCH_SIZE + 1, count) ++ for call in mock_nova.get.call_args_list: ++ self.assertEqual( ++ data_migrations.NOVA_COMPUTE_MICROVERSION, ++ call.kwargs['microversion'], ++ ) ++ self.assertEqual(2, mock_db.extarq_list.call_count) ++ first_call, second_call = mock_db.extarq_list.call_args_list ++ self.assertIsNone(first_call.kwargs.get('marker')) ++ self.assertEqual( ++ data_migrations.HEAL_BATCH_SIZE, ++ second_call.kwargs['marker'], ++ ) ++ ++ ++class TestNovaAdapterForHeal(base.TestCase): ++ """Nova adapter used for ARQ project_id backfill.""" ++ ++ @mock.patch('cyborg.common.data_migrations.utils.get_sdk_adapter') ++ def test_get_nova_adapter_returns_compute_sdk_adapter(self, mock_get): ++ mock_adapter = mock.MagicMock() ++ mock_get.return_value = mock_adapter ++ result = data_migrations._get_nova_adapter() ++ mock_get.assert_called_once_with('compute') ++ self.assertIs(result, mock_adapter) +Index: cyborg/cyborg/tests/unit/conductor/test_manager.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/conductor/test_manager.py ++++ cyborg/cyborg/tests/unit/conductor/test_manager.py +@@ -154,3 +154,19 @@ class ConductorManagerTest(base.TestCase + + mock_destroy_driver_deployable.assert_called_once() + mock_placement_delete.assert_called_once() ++ ++ @mock.patch( ++ 'cyborg.common.data_migrations.heal_arq_project_ids', autospec=True ++ ) ++ def test_init_host_heals_null_project_ids(self, mock_heal): ++ mock_heal.return_value = 3 ++ self.cm.init_host() ++ mock_heal.assert_called_once() ++ ++ @mock.patch( ++ 'cyborg.common.data_migrations.heal_arq_project_ids', autospec=True ++ ) ++ def test_init_host_heal_handles_failure(self, mock_heal): ++ mock_heal.side_effect = Exception('Nova unavailable') ++ self.cm.init_host() ++ mock_heal.assert_called_once() +Index: cyborg/cyborg/tests/unit/policies/test_deployables.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/policies/test_deployables.py ++++ cyborg/cyborg/tests/unit/policies/test_deployables.py +@@ -63,9 +63,11 @@ class DeployablePolicyTest(base.BasePoli + self.program_authorized_contexts = self.read_authorized_contexts + self.program_unauthorized_contexts = self.read_unauthorized_contexts + ++ @mock.patch('cyborg.objects.Attribute.get_by_filter', autospec=True) + @mock.patch('cyborg.objects.Deployable.list', autospec=True) +- def test_get_all_deployables_success(self, mock_list): ++ def test_get_all_deployables_success(self, mock_list, mock_attr): + mock_list.return_value = [self.fake_dep] ++ mock_attr.return_value = [] + for context in self.read_authorized_contexts: + headers = self.gen_headers(context) + response = self.get_json(DEPLOYABLE_URL, headers=headers) +Index: cyborg/doc/source/cli/cyborg-dbsync.rst +=================================================================== +--- /dev/null ++++ cyborg/doc/source/cli/cyborg-dbsync.rst +@@ -0,0 +1,80 @@ ++============= ++cyborg-dbsync ++============= ++ ++Synopsis ++======== ++ ++:: ++ ++ cyborg-dbsync [] ++ ++Description ++=========== ++ ++:program:`cyborg-dbsync` is a tool for managing the Cyborg database schema ++and performing data migrations. ++ ++Options ++======= ++ ++The standard pattern for executing a :program:`cyborg-dbsync` command is:: ++ ++ cyborg-dbsync [] ++ ++Run without arguments to see a list of available commands:: ++ ++ cyborg-dbsync ++ ++Commands are: ++ ++* ``upgrade`` ++* ``revision`` ++* ``stamp`` ++* ``version`` ++* ``create_schema`` ++* ``online_data_migrations`` ++ ++Detailed descriptions are below. ++ ++``cyborg-dbsync upgrade`` ++ Upgrade the database schema to the latest version. Optionally, use ++ ``--revision`` to specify an alembic revision string to upgrade to. ++ ++``cyborg-dbsync revision`` ++ Create a new alembic revision. Use ``-m`` or ``--message`` to set the ++ message string. ++ ++``cyborg-dbsync stamp`` ++ Stamp the database with a specific alembic revision. ++ ++``cyborg-dbsync version`` ++ Print the current database version information and exit. ++ ++``cyborg-dbsync create_schema`` ++ Create the database schema. ++ ++``cyborg-dbsync online_data_migrations`` ++ Perform online data migrations. Currently backfills the ``project_id`` ++ column on existing accelerator requests (ARQs) by querying Nova for ++ the project that owns each bound instance. ++ ++ This command should be run after upgrading Cyborg to ensure that all ++ ARQs have a correct ``project_id`` value. The cyborg-conductor service ++ also runs this migration automatically on startup, but the CLI command ++ allows operators to perform the migration at a convenient time and ++ monitor progress. ++ ++ **Prerequisites** ++ ++ * Cyborg must be configured with a valid OpenStack SDK compute ++ adapter (the ``[nova]`` group: auth URL, credentials, and region) ++ so ``cyborg-dbsync`` can call the Nova API to resolve instance ++ ownership. This is independent of ``[keystone_authtoken]``, which ++ only applies to validating incoming API requests to Cyborg. ++ * Nova must be running and accessible. ++ ++ **Return codes** ++ ++ The command prints the number of ARQs migrated and exits with code 0 ++ on success. +Index: cyborg/doc/source/cli/index.rst +=================================================================== +--- cyborg.orig/doc/source/cli/index.rst ++++ cyborg/doc/source/cli/index.rst +@@ -5,4 +5,5 @@ Command-Line Interface Reference + .. toctree:: + :maxdepth: 1 + ++ cyborg-dbsync + cyborg-status +Index: cyborg/releasenotes/notes/add-arq-project-id-backfill-a1b2c3d4e5f6.yaml +=================================================================== +--- /dev/null ++++ cyborg/releasenotes/notes/add-arq-project-id-backfill-a1b2c3d4e5f6.yaml +@@ -0,0 +1,20 @@ ++--- ++upgrade: ++ - | ++ A new ``cyborg-dbsync online_data_migrations`` subcommand backfills the ++ ``project_id`` column on existing accelerator requests (ARQs). Expected ++ operator order: ++ ++ 1. Upgrade the ``cyborg-dbsync`` package (and related shared code) so ++ ``cyborg-dbsync upgrade`` can apply pending schema migrations. ++ 2. Run ``cyborg-dbsync online_data_migrations`` to backfill ``project_id`` ++ on existing ARQ rows using Nova instance data. ++ 3. Upgrade Cyborg services, starting with **conductor** and **API**, then ++ **agents**. ++ ++ The cyborg-conductor service also heals remaining NULL ``project_id`` ++ values on startup as a safety net. ++ ++ Nova ``GET /servers/{id}`` calls for this migration pass microversion ++ ``2.82`` explicitly so the ``tenant_id`` field shape used for backfill ++ stays consistent. diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_7_Enforce_project-scoped_access_for_ARQs.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_7_Enforce_project-scoped_access_for_ARQs.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_7_Enforce_project-scoped_access_for_ARQs.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_7_Enforce_project-scoped_access_for_ARQs.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,513 @@ +Author: Sean Mooney +Date: Sun, 26 Apr 2026 18:25:29 +0000 +Description: CVE-2026-40213 / CVE-2026-40214: Enforce project-scoped access for ARQs + Add project_id filtering to all ARQ database queries so + non-admin users can only see and modify ARQs in their own + project. The object layer passes project_id to the DB layer + for non-admin contexts; admins bypass the filter. + . + ARQs with NULL project_id (not yet healed) are only visible + to admins. Fix get_one to use need_target=False so it works + for non-admin users (broken since 82fd155, 2019). + . +Bug: https://launchpad.net/bugs/2144056 +Bug-Debian: https://bugs.debian.org/1136006 +Generated-By: Cursor claude-opus-4.6 +Signed-off-by: Sean Mooney +Change-Id: If5f133f6ae245a4c22bd8781bad144894c7a5595 +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/987702 +Last-Update: 2026-05-11 + +Index: cyborg/cyborg/api/controllers/v2/arqs.py +=================================================================== +--- cyborg.orig/cyborg/api/controllers/v2/arqs.py ++++ cyborg/cyborg/api/controllers/v2/arqs.py +@@ -170,7 +170,7 @@ class ARQsController(base.CyborgControll + LOG.info('[arqs] post returned: %s', ret) + return ret + +- @authorize_wsgi.authorize_wsgi("cyborg:arq", "get_one") ++ @authorize_wsgi.authorize_wsgi("cyborg:arq", "get_one", False) + @expose.expose(ARQ, types.uuid) + def get_one(self, uuid): + """Get a single ARQ by UUID.""" +Index: cyborg/cyborg/db/api.py +=================================================================== +--- cyborg.orig/cyborg/db/api.py ++++ cyborg/cyborg/db/api.py +@@ -200,11 +200,13 @@ class Connection(metaclass=abc.ABCMeta): + """Create a new extarq.""" + + @abc.abstractmethod +- def extarq_delete(self, context, uuid): ++ def extarq_delete(self, context, uuid, project_id=None): + """Delete an extarq.""" + + @abc.abstractmethod +- def extarq_update(self, context, uuid, values, state_scope=None): ++ def extarq_update( ++ self, context, uuid, values, state_scope=None, project_id=None ++ ): + """Update an extarq.""" + + @abc.abstractmethod +@@ -231,7 +233,7 @@ class Connection(metaclass=abc.ABCMeta): + """ + + @abc.abstractmethod +- def extarq_get(self, context, uuid, lock=False): ++ def extarq_get(self, context, uuid, lock=False, project_id=None): + """Get requested extarq.""" + + # attach_handle +Index: cyborg/cyborg/db/sqlalchemy/api.py +=================================================================== +--- cyborg.orig/cyborg/db/sqlalchemy/api.py ++++ cyborg/cyborg/db/sqlalchemy/api.py +@@ -885,37 +885,47 @@ class Connection(api.Connection): + + @oslo_db_api.retry_on_deadlock + @_writer +- def extarq_delete(self, context, uuid): ++ def extarq_delete(self, context, uuid, project_id=None): + query = model_query(context, models.ExtArq) + query = add_identity_filter(query, uuid) ++ if project_id: ++ query = query.filter_by(project_id=project_id) + count = query.delete() + if count != 1: + raise exception.ResourceNotFound( +- resource='ExtArq', +- msg='with uuid=%s' % uuid) ++ resource='ExtArq', msg='with uuid=%s' % uuid ++ ) + +- def extarq_update(self, context, uuid, values, state_scope=None): ++ def extarq_update( ++ self, context, uuid, values, state_scope=None, project_id=None ++ ): + if 'uuid' in values and values['uuid'] != uuid: + msg = _("Cannot overwrite UUID for an existing ExtArq.") + raise exception.InvalidParameterValue(err=msg) +- return self._do_update_extarq(context, uuid, values, state_scope) ++ return self._do_update_extarq( ++ context, uuid, values, state_scope, project_id=project_id ++ ) + + @oslo_db_api.retry_on_deadlock + @_writer +- def _do_update_extarq(self, context, uuid, values, state_scope=None): ++ def _do_update_extarq( ++ self, context, uuid, values, state_scope=None, project_id=None ++ ): + query = model_query(context, models.ExtArq) +- query = query_update = query.filter_by( +- uuid=uuid).with_for_update() ++ query = query_update = query.filter_by(uuid=uuid).with_for_update() ++ if project_id: ++ query = query.filter_by(project_id=project_id) ++ query_update = query_update.filter_by(project_id=project_id) + if type(state_scope) is list: + query_update = query_update.filter( +- models.ExtArq.state.in_(state_scope)) ++ models.ExtArq.state.in_(state_scope) ++ ) + try: +- query_update.update( +- values, synchronize_session="fetch") ++ query_update.update(values, synchronize_session="fetch") + except NoResultFound: + raise exception.ResourceNotFound( +- resource='ExtArq', +- msg='with uuid=%s' % uuid) ++ resource='ExtArq', msg='with uuid=%s' % uuid ++ ) + ref = query.first() + return ref + +@@ -954,11 +964,10 @@ class Connection(api.Connection): + + @oslo_db_api.retry_on_deadlock + @_writer +- def extarq_get(self, context, uuid, lock=False): +- query = model_query( +- context, +- models.ExtArq).filter_by(uuid=uuid) +- # NOTE we will support aync bind, so get query by lock ++ def extarq_get(self, context, uuid, lock=False, project_id=None): ++ query = model_query(context, models.ExtArq).filter_by(uuid=uuid) ++ if project_id: ++ query = query.filter_by(project_id=project_id) + if lock: + query = query.with_for_update() + try: +Index: cyborg/cyborg/objects/ext_arq.py +=================================================================== +--- cyborg.orig/cyborg/objects/ext_arq.py ++++ cyborg/cyborg/objects/ext_arq.py +@@ -109,7 +109,8 @@ class ExtARQ(base.CyborgObject, object_b + @classmethod + def get(cls, context, uuid, lock=False): + """Find a DB ExtARQ and return an Obj ExtARQ.""" +- db_extarq = cls.dbapi.extarq_get(context, uuid) ++ target = {} if context.is_admin else {'project_id': context.project_id} ++ db_extarq = cls.dbapi.extarq_get(context, uuid, **target) + obj_arq = objects.ARQ(context) + obj_extarq = cls(context) + obj_extarq['arq'] = obj_arq +@@ -119,9 +120,9 @@ class ExtARQ(base.CyborgObject, object_b + @classmethod + def list(cls, context, uuid_range=None): + """Return a list of ExtARQ objects.""" +- db_extarqs = cls.dbapi.extarq_list(context, uuid_range) +- obj_extarq_list = cls._from_db_object_list( +- db_extarqs, context) ++ target = {} if context.is_admin else {'project_id': context.project_id} ++ db_extarqs = cls.dbapi.extarq_list(context, uuid_range, **target) ++ obj_extarq_list = cls._from_db_object_list(db_extarqs, context) + return obj_extarq_list + + def save(self, context): +@@ -162,7 +163,8 @@ class ExtARQ(base.CyborgObject, object_b + + def destroy(self, context): + """Delete an ExtARQ from the DB.""" +- self.dbapi.extarq_delete(context, self.arq.uuid) ++ target = {} if context.is_admin else {'project_id': context.project_id} ++ self.dbapi.extarq_delete(context, self.arq.uuid, **target) + self.obj_reset_changes() + + @classmethod +Index: cyborg/cyborg/tests/unit/api/controllers/v2/test_arqs.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/api/controllers/v2/test_arqs.py ++++ cyborg/cyborg/tests/unit/api/controllers/v2/test_arqs.py +@@ -568,3 +568,88 @@ class TestARQProjectIdOnCreate(v2_test.A + controller._validate_arq_patch, + patch, + ) ++ ++ ++class TestARQProjectIsolation(v2_test.APITestV2): ++ """Tests for Bug #2144056: project-scoped ARQ access control. ++ ++ The authorize_wsgi decorator uses need_target=False for ARQ ++ endpoints because ARQsController has no _get_resource method. ++ With need_target=True and no _get_resource the policy target is ++ an empty dict, causing ``project_id:%(project_id)s`` to never ++ match — which blocks ALL non-admin users, even for their own ++ ARQs. With need_target=False the target is populated from the ++ request context so the role check passes; cross-project ++ isolation is then enforced in the object/DB layer via ++ project_id filtering. ++ """ ++ ++ ARQ_URL = '/accelerator_requests' ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_extarqs = fake_extarq.get_fake_extarq_objs() ++ ++ def _member_headers(self, project_id): ++ headers = self.gen_headers( ++ cyborg_context.RequestContext( ++ user_id=str(uuids.member_user), ++ project_id=project_id, ++ is_admin=False, ++ ) ++ ) ++ headers['X-Roles'] = 'member' ++ return headers ++ ++ def _admin_headers(self): ++ return self.gen_headers(self.context) ++ ++ @mock.patch('cyborg.objects.ExtARQ.list') ++ def test_list_as_member_returns_only_own_project(self, mock_list): ++ mock_list.return_value = self.fake_extarqs[:2] ++ headers = self._member_headers(str(uuids.project_a)) ++ data = self.get_json(self.ARQ_URL, headers=headers) ++ out_arqs = data['arqs'] ++ self.assertEqual(2, len(out_arqs)) ++ mock_list.assert_called_once() ++ ++ @mock.patch('cyborg.objects.ExtARQ.list') ++ def test_list_as_admin_returns_all_projects(self, mock_list): ++ mock_list.return_value = self.fake_extarqs ++ headers = self._admin_headers() ++ data = self.get_json(self.ARQ_URL, headers=headers) ++ out_arqs = data['arqs'] ++ self.assertEqual(len(self.fake_extarqs), len(out_arqs)) ++ ++ @mock.patch('cyborg.objects.ExtARQ.get') ++ def test_get_one_own_project_succeeds(self, mock_get): ++ extarq = self.fake_extarqs[0] ++ mock_get.return_value = extarq ++ headers = self._member_headers(str(uuids.project_a)) ++ uuid = extarq.arq['uuid'] ++ out = self.get_json(self.ARQ_URL + '/%s' % uuid, headers=headers) ++ self.assertEqual(uuid, out['uuid']) ++ ++ @mock.patch('cyborg.objects.ExtARQ.get') ++ def test_get_one_other_project_returns_404(self, mock_get): ++ mock_get.side_effect = exception.ResourceNotFound( ++ resource='ExtArq', msg='not found' ++ ) ++ headers = self._member_headers(str(uuids.project_b)) ++ uuid = self.fake_extarqs[0].arq['uuid'] ++ response = self.get_json( ++ self.ARQ_URL + '/%s' % uuid, ++ headers=headers, ++ expect_errors=True, ++ return_json=False, ++ ) ++ self.assertEqual(404, response.status_int) ++ ++ @mock.patch('cyborg.objects.ExtARQ.get') ++ def test_get_one_as_admin_any_project(self, mock_get): ++ extarq = self.fake_extarqs[0] ++ mock_get.return_value = extarq ++ headers = self._admin_headers() ++ uuid = extarq.arq['uuid'] ++ out = self.get_json(self.ARQ_URL + '/%s' % uuid, headers=headers) ++ self.assertEqual(uuid, out['uuid']) +Index: cyborg/cyborg/tests/unit/db/test_db_extarq.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/db/test_db_extarq.py ++++ cyborg/cyborg/tests/unit/db/test_db_extarq.py +@@ -15,6 +15,7 @@ + + """Tests for manipulating ExtArq via the DB API""" + ++from oslo_utils.fixture import uuidsentinel + from oslo_utils import uuidutils + + from cyborg.common import exception +@@ -69,6 +70,95 @@ class TestDbExtArq(base.DbTestCase): + + def test_delete_by_uuid_not_exist(self): + random_uuid = uuidutils.generate_uuid() +- self.assertRaises(exception.ResourceNotFound, +- self.dbapi.extarq_delete, +- self.context, random_uuid) ++ self.assertRaises( ++ exception.ResourceNotFound, ++ self.dbapi.extarq_delete, ++ self.context, ++ random_uuid, ++ ) ++ ++ def test_get_by_uuid_with_matching_project_id(self): ++ pid = str(uuidsentinel.project_a) ++ created = utils.create_test_extarq(self.context, project_id=pid) ++ result = self.dbapi.extarq_get( ++ self.context, created['uuid'], project_id=pid ++ ) ++ self.assertEqual(created['uuid'], result['uuid']) ++ ++ def test_get_by_uuid_with_wrong_project_id(self): ++ pid = str(uuidsentinel.project_a) ++ other = str(uuidsentinel.project_b) ++ created = utils.create_test_extarq(self.context, project_id=pid) ++ self.assertRaises( ++ exception.ResourceNotFound, ++ self.dbapi.extarq_get, ++ self.context, ++ created['uuid'], ++ project_id=other, ++ ) ++ ++ def test_get_by_uuid_no_project_filter_returns_any(self): ++ pid = str(uuidsentinel.project_a) ++ created = utils.create_test_extarq(self.context, project_id=pid) ++ result = self.dbapi.extarq_get(self.context, created['uuid']) ++ self.assertEqual(created['uuid'], result['uuid']) ++ ++ def test_delete_with_matching_project_id(self): ++ pid = str(uuidsentinel.project_a) ++ created = utils.create_test_extarq(self.context, project_id=pid) ++ self.dbapi.extarq_delete(self.context, created['uuid'], project_id=pid) ++ self.assertRaises( ++ exception.ResourceNotFound, ++ self.dbapi.extarq_get, ++ self.context, ++ created['uuid'], ++ ) ++ ++ def test_delete_with_wrong_project_id(self): ++ pid = str(uuidsentinel.project_a) ++ other = str(uuidsentinel.project_b) ++ created = utils.create_test_extarq(self.context, project_id=pid) ++ self.assertRaises( ++ exception.ResourceNotFound, ++ self.dbapi.extarq_delete, ++ self.context, ++ created['uuid'], ++ project_id=other, ++ ) ++ ++ def test_list_with_project_id_filter(self): ++ pid_a = str(uuidsentinel.project_a) ++ pid_b = str(uuidsentinel.project_b) ++ arq_a = utils.create_test_extarq( ++ self.context, ++ id=1, ++ uuid=uuidutils.generate_uuid(), ++ project_id=pid_a, ++ ) ++ utils.create_test_extarq( ++ self.context, ++ id=2, ++ uuid=uuidutils.generate_uuid(), ++ project_id=pid_b, ++ ) ++ results = self.dbapi.extarq_list(self.context, project_id=pid_a) ++ result_uuids = [r.uuid for r in results] ++ self.assertEqual([arq_a['uuid']], result_uuids) ++ ++ def test_list_without_project_id_filter_returns_all(self): ++ pid_a = str(uuidsentinel.project_a) ++ pid_b = str(uuidsentinel.project_b) ++ utils.create_test_extarq( ++ self.context, ++ id=1, ++ uuid=uuidutils.generate_uuid(), ++ project_id=pid_a, ++ ) ++ utils.create_test_extarq( ++ self.context, ++ id=2, ++ uuid=uuidutils.generate_uuid(), ++ project_id=pid_b, ++ ) ++ results = self.dbapi.extarq_list(self.context) ++ self.assertEqual(2, len(results)) +Index: cyborg/cyborg/tests/unit/db/utils.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/db/utils.py ++++ cyborg/cyborg/tests/unit/db/utils.py +@@ -81,7 +81,7 @@ def create_test_device(context, **kwargs + + + def get_test_extarq(**kwargs): +- return { ++ result = { + 'uuid': kwargs.get('uuid', '10efe63d-dfea-4a37-ad94-4116fba50986'), + 'id': kwargs.get('id', 1), + 'state': kwargs.get('state', 'Bound'), +@@ -96,6 +96,9 @@ def get_test_extarq(**kwargs): + 'created_at': kwargs.get('created_at', None), + 'updated_at': kwargs.get('updated_at', None) + } ++ if 'project_id' in kwargs: ++ result['project_id'] = kwargs['project_id'] ++ return result + + + def create_test_extarq(context, **kwargs): +Index: cyborg/cyborg/tests/unit/objects/test_extarq.py +=================================================================== +--- cyborg.orig/cyborg/tests/unit/objects/test_extarq.py ++++ cyborg/cyborg/tests/unit/objects/test_extarq.py +@@ -20,6 +20,7 @@ from testtools.matchers import HasLength + + from cyborg.common import constants + from cyborg.common import exception ++from cyborg import context as cyborg_context + from cyborg import objects + from cyborg.tests.unit.db import base + from cyborg.tests.unit import fake_attach_handle +@@ -557,3 +558,93 @@ class TestExtARQProjectId(base.DbTestCas + extarq.start_bind_job(member_ctx, valid_fields) + + self.assertEqual(explicit_pid, extarq.arq.project_id) ++ ++ ++class TestExtARQProjectIsolation(base.DbTestCase): ++ """Tests for Bug #2144056: project-scoped access in object layer.""" ++ ++ def setUp(self): ++ super().setUp() ++ self.fake_obj_extarqs = fake_extarq.get_fake_extarq_objs() ++ ++ def _make_member_context(self, project_id=None): ++ return cyborg_context.RequestContext( ++ user_id=str(uuids.member_user), ++ project_id=project_id or str(uuids.member_project), ++ is_admin=False, ++ roles=['member'], ++ ) ++ ++ def _make_admin_context(self): ++ return cyborg_context.RequestContext( ++ user_id=str(uuids.admin_user), ++ project_id=str(uuids.admin_project), ++ is_admin=True, ++ roles=['admin'], ++ ) ++ ++ @mock.patch('cyborg.objects.ExtARQ.dbapi') ++ def test_get_member_passes_project_id_filter(self, mock_dbapi): ++ """Non-admin get() should filter by project_id.""" ++ member_ctx = self._make_member_context() ++ fake_db_extarq = self.fake_obj_extarqs[0] ++ mock_dbapi.extarq_get.return_value = fake_db_extarq ++ uuid = fake_db_extarq.arq['uuid'] ++ with mock.patch.object( ++ objects.ExtARQ, '_from_db_object', return_value=fake_db_extarq ++ ): ++ objects.ExtARQ.get(member_ctx, uuid) ++ mock_dbapi.extarq_get.assert_called_once_with( ++ member_ctx, uuid, project_id=member_ctx.project_id ++ ) ++ ++ @mock.patch('cyborg.objects.ExtARQ.dbapi') ++ def test_get_admin_no_project_id_filter(self, mock_dbapi): ++ """Admin get() should not filter by project_id.""" ++ admin_ctx = self._make_admin_context() ++ fake_db_extarq = self.fake_obj_extarqs[0] ++ mock_dbapi.extarq_get.return_value = fake_db_extarq ++ uuid = fake_db_extarq.arq['uuid'] ++ with mock.patch.object( ++ objects.ExtARQ, '_from_db_object', return_value=fake_db_extarq ++ ): ++ objects.ExtARQ.get(admin_ctx, uuid) ++ mock_dbapi.extarq_get.assert_called_once_with(admin_ctx, uuid) ++ ++ @mock.patch('cyborg.objects.ExtARQ.dbapi') ++ def test_destroy_member_passes_project_id_filter(self, mock_dbapi): ++ """Non-admin destroy() should filter by project_id.""" ++ member_ctx = self._make_member_context() ++ extarq = self.fake_obj_extarqs[0] ++ uuid = extarq.arq['uuid'] ++ extarq.destroy(member_ctx) ++ mock_dbapi.extarq_delete.assert_called_once_with( ++ member_ctx, uuid, project_id=member_ctx.project_id ++ ) ++ ++ @mock.patch('cyborg.objects.ExtARQ.dbapi') ++ def test_destroy_admin_no_project_id_filter(self, mock_dbapi): ++ """Admin destroy() should not filter by project_id.""" ++ admin_ctx = self._make_admin_context() ++ extarq = self.fake_obj_extarqs[0] ++ uuid = extarq.arq['uuid'] ++ extarq.destroy(admin_ctx) ++ mock_dbapi.extarq_delete.assert_called_once_with(admin_ctx, uuid) ++ ++ @mock.patch('cyborg.objects.ExtARQ.dbapi') ++ def test_list_member_passes_project_id_filter(self, mock_dbapi): ++ """Non-admin list() should filter by project_id.""" ++ member_ctx = self._make_member_context() ++ mock_dbapi.extarq_list.return_value = [] ++ objects.ExtARQ.list(member_ctx) ++ mock_dbapi.extarq_list.assert_called_once_with( ++ member_ctx, None, project_id=member_ctx.project_id ++ ) ++ ++ @mock.patch('cyborg.objects.ExtARQ.dbapi') ++ def test_list_admin_no_project_id_filter(self, mock_dbapi): ++ """Admin list() should not filter by project_id.""" ++ admin_ctx = self._make_admin_context() ++ mock_dbapi.extarq_list.return_value = [] ++ objects.ExtARQ.list(admin_ctx) ++ mock_dbapi.extarq_list.assert_called_once_with(admin_ctx, None) diff -Nru cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_8_Require_service_token_for_bound_ARQ_operations.patch cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_8_Require_service_token_for_bound_ARQ_operations.patch --- cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_8_Require_service_token_for_bound_ARQ_operations.patch 1970-01-01 00:00:00.000000000 +0000 +++ cyborg-14.0.0/debian/patches/CVE-2026-40213_CVE-2026-40214_8_Require_service_token_for_bound_ARQ_operations.patch 2026-05-11 08:00:13.000000000 +0000 @@ -0,0 +1,617 @@ +Author: Sean Mooney +Date: Sun, 26 Apr 2026 18:25:29 +0000 +Description: CVE-2026-40213 / CVE-2026-40214: 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 +Bug: https://launchpad.net/bugs/2144056 +Bug-Debian: https://bugs.debian.org/1136006 +Generated-By: Cursor claude-opus-4.6 +Signed-off-by: Sean Mooney +Change-Id: I7ee74b61de044845756443fe7e2dc806b7a14f86 +Origin: upstream, https://review.opendev.org/c/openstack/cyborg/+/987703 +Last-Update: 2026-05-11 + +diff --git a/cyborg/api/controllers/v2/arqs.py b/cyborg/api/controllers/v2/arqs.py +index 955714d..acd0713 100644 +--- a/cyborg/api/controllers/v2/arqs.py ++++ b/cyborg/api/controllers/v2/arqs.py +@@ -30,6 +30,7 @@ + from cyborg.common import constants + from cyborg.common import exception + from cyborg.common.i18n import _ ++from cyborg.common import service_token_utils + from cyborg import objects + + LOG = log.getLogger(__name__) +@@ -97,6 +98,39 @@ + 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. + +@@ -245,11 +279,15 @@ + reason='Provide either an ARQ uuid list or an instance UUID') + 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. +@@ -368,7 +406,10 @@ + # 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) +diff --git a/cyborg/common/config.py b/cyborg/common/config.py +index 954e36d..7ab3501 100644 +--- a/cyborg/common/config.py ++++ b/cyborg/common/config.py +@@ -21,6 +21,9 @@ + + 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:], + project='cyborg', +diff --git a/cyborg/common/service_token_utils.py b/cyborg/common/service_token_utils.py +new file mode 100644 +index 0000000..5ce5ad2 +--- /dev/null ++++ b/cyborg/common/service_token_utils.py +@@ -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)) +diff --git a/cyborg/tests/unit/api/controllers/v2/test_arqs.py b/cyborg/tests/unit/api/controllers/v2/test_arqs.py +index 2ac98fe..865b170 100644 +--- a/cyborg/tests/unit/api/controllers/v2/test_arqs.py ++++ b/cyborg/tests/unit/api/controllers/v2/test_arqs.py +@@ -243,20 +243,25 @@ + "Device Profile not found with " + "name=wrong_device_profile_name", 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) +@@ -278,10 +283,16 @@ + # 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] +@@ -302,10 +313,15 @@ + valid_fields) + 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(): +@@ -653,3 +669,268 @@ + 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) +diff --git a/cyborg/tests/unit/common/test_service_token_utils.py b/cyborg/tests/unit/common/test_service_token_utils.py +new file mode 100644 +index 0000000..814c448 +--- /dev/null ++++ b/cyborg/tests/unit/common/test_service_token_utils.py +@@ -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)) +diff --git a/releasenotes/notes/fix-arq-cross-tenant-access-b7c8d9e0f1a2.yaml b/releasenotes/notes/fix-arq-cross-tenant-access-b7c8d9e0f1a2.yaml +new file mode 100644 +index 0000000..4cbb434 +--- /dev/null ++++ b/releasenotes/notes/fix-arq-cross-tenant-access-b7c8d9e0f1a2.yaml +@@ -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. diff -Nru cyborg-14.0.0/debian/patches/series cyborg-14.0.0/debian/patches/series --- cyborg-14.0.0/debian/patches/series 2025-07-12 08:23:11.000000000 +0000 +++ cyborg-14.0.0/debian/patches/series 2026-05-11 08:00:13.000000000 +0000 @@ -1 +1,9 @@ install-missing-files.patch +CVE-2026-40213_CVE-2026-40214_1_Use_common_checks.check_policy_json_from_oslo.upgradecheck.patch +CVE-2026-40213_CVE-2026-40214_2_Fix_cyborg-status_upgrade_check_tests.patch +CVE-2026-40213_CVE-2026-40214_3_Fix_rule-allow_policy_bypass_on_device_deployable_attribute_APIs.patch +CVE-2026-40213_CVE-2026-40214_4_Set_project_id_on_ARQ_creation_and_binding.patch +CVE-2026-40213_CVE-2026-40214_5_Refactor_session_handling_and_align_test_contexts.patch +CVE-2026-40213_CVE-2026-40214_6_Add_project_id_backfill_for_existing_ARQs.patch +CVE-2026-40213_CVE-2026-40214_7_Enforce_project-scoped_access_for_ARQs.patch +CVE-2026-40213_CVE-2026-40214_8_Require_service_token_for_bound_ARQ_operations.patch