From 1f6ba62cf8ffde989ffedc644b806c256e6aa814 Mon Sep 17 00:00:00 2001 From: Sebastian Krott Date: Mon, 14 Sep 2026 15:09:55 +0200 Subject: [PATCH] compute: test flavor permission rules Nova introduced flavor permission rules for restricting access to public flavors. Test both the new flavor permission rule endpoints and the additional filter parameters and returned attributes of the flavor endpoints. --- .../admin/test_flavor_permission_rules.py | 189 ++++++++++++++++++ .../test_flavor_permission_rules_negative.py | 170 ++++++++++++++++ tempest/api/compute/admin/test_flavors.py | 67 +++++++ tempest/api/compute/base.py | 23 +++ .../test_flavor_permission_rules_negative.py | 43 ++++ tempest/api/compute/flavors/test_flavors.py | 5 + .../compute/flavors/test_flavors_negative.py | 18 ++ tempest/clients.py | 2 + .../compute/v2_1/flavor_permission_rules.py | 72 +++++++ .../response/compute/v2_1/flavors.py | 19 +- .../response/compute/v2_100/__init__.py | 0 .../response/compute/v2_100/flavors.py | 49 +++++ .../response/compute/v2_55/flavors.py | 6 +- .../response/compute/v2_61/flavors.py | 7 +- tempest/lib/services/compute/__init__.py | 5 + .../compute/flavor_permission_rules_client.py | 87 ++++++++ .../lib/services/compute/flavors_client.py | 11 +- 17 files changed, 763 insertions(+), 10 deletions(-) create mode 100644 tempest/api/compute/admin/test_flavor_permission_rules.py create mode 100644 tempest/api/compute/admin/test_flavor_permission_rules_negative.py create mode 100644 tempest/api/compute/flavors/test_flavor_permission_rules_negative.py create mode 100644 tempest/lib/api_schema/response/compute/v2_1/flavor_permission_rules.py create mode 100644 tempest/lib/api_schema/response/compute/v2_100/__init__.py create mode 100644 tempest/lib/api_schema/response/compute/v2_100/flavors.py create mode 100644 tempest/lib/services/compute/flavor_permission_rules_client.py diff --git a/tempest/api/compute/admin/test_flavor_permission_rules.py b/tempest/api/compute/admin/test_flavor_permission_rules.py new file mode 100644 index 0000000000..474cf6da57 --- /dev/null +++ b/tempest/api/compute/admin/test_flavor_permission_rules.py @@ -0,0 +1,189 @@ +# Copyright (c) 2026 SAP SE +# 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 oslo_utils import uuidutils + +from tempest.api.compute import base +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions as lib_exc + +CONF = config.CONF + + +class FlavorPermissionRulesAdminTest(base.BaseV2ComputeAdminTest): + """Tests the flavor permission rules API with admin privileges. + + All policies default to admin, whose check_str carries no caller + domain or project match, so the admin may manage rules for any domain_id + and project_id. The endpoint does not validate them against keystone, so + the management tests use synthetic random ids for isolating flavor + permission rule testing. + """ + + min_microversion = '2.100' + + @classmethod + def resource_setup(cls): + super(FlavorPermissionRulesAdminTest, cls).resource_setup() + cls.client = cls.admin_flavor_permission_rules_client + # Shared fixtures with synthetic domain and project ids for list tests + cls.flavor_ref = CONF.compute.flavor_ref + cls.flavor_ref_alt = CONF.compute.flavor_ref_alt + cls.domain_id = data_utils.rand_uuid() + cls.domain_id_alt = data_utils.rand_uuid() + cls.project_id = data_utils.rand_uuid() + cls.project_id_alt = data_utils.rand_uuid() + cls.domain_deny = cls.create_flavor_permission_rule( + domain_id=cls.domain_id, effect='deny') + cls.domain_allow_flavor = cls.create_flavor_permission_rule( + domain_id=cls.domain_id, effect='allow', + flavor_id=cls.flavor_ref) + cls.project_allow = cls.create_flavor_permission_rule( + domain_id=cls.domain_id, project_id=cls.project_id, effect='allow') + cls.other_project_deny_flavor = cls.create_flavor_permission_rule( + domain_id=cls.domain_id, project_id=cls.project_id_alt, + effect='deny', flavor_id=cls.flavor_ref_alt) + cls.other_domain_allow = cls.create_flavor_permission_rule( + domain_id=cls.domain_id_alt, effect='allow') + + def _list_ids(self, **filters): + return {r['id'] for r in self.client.list_flavor_permission_rules( + **filters)['flavor_permission_rules']} + + def _assert_rule(self, expected, actual): + """Assert a returned rule matches the expected values.""" + self.assertTrue(uuidutils.is_uuid_like(actual['id']), + f"rule id {actual['id']} is not a uuid") + self.assertEqual(expected['domain_id'], actual['domain_id']) + self.assertEqual(expected.get('project_id'), actual['project_id']) + self.assertEqual(expected.get('flavor_id'), actual['flavor_id']) + self.assertEqual(expected['effect'], actual['effect']) + expected_scope = 'project' if expected.get('project_id') else 'domain' + self.assertEqual(expected_scope, actual['scope']) + + @decorators.idempotent_id('2d6b935a-ec9e-437e-8a8b-0f9cefefccff') + def test_create_show_delete_domain_rule(self): + """Create, show and delete a domain-scoped flavor permission rule.""" + params = dict(domain_id=data_utils.rand_uuid(), effect='deny') + rule = self.create_flavor_permission_rule(**params) + self._assert_rule(params, rule) + + shown = self.client.show_flavor_permission_rule( + rule['id'])['flavor_permission_rule'] + self._assert_rule(params, shown) + + self.client.delete_flavor_permission_rule(rule['id']) + self.assertRaises(lib_exc.NotFound, + self.client.show_flavor_permission_rule, rule['id']) + + @decorators.idempotent_id('5abdefbd-d953-4aa8-a6c5-304b286547f2') + def test_create_project_rule(self): + """A rule with a project_id is project-scoped.""" + params = dict(domain_id=data_utils.rand_uuid(), + project_id=data_utils.rand_uuid(), effect='deny') + rule = self.create_flavor_permission_rule(**params) + self._assert_rule(params, rule) + + @decorators.idempotent_id('501f9c21-137e-46de-b28b-a83e5f6372c5') + def test_create_flavor_scoped_rule(self): + """A flavor-scoped rule echoes the public flavor ref.""" + params = dict(domain_id=data_utils.rand_uuid(), effect='allow', + flavor_id=self.flavor_ref) + rule = self.create_flavor_permission_rule(**params) + self._assert_rule(params, rule) + + @decorators.idempotent_id('e138b518-0da7-45b0-b5c0-57de59bb41a0') + def test_update_show_rule_effect(self): + """The effect of a rule can be updated.""" + rule = self.create_flavor_permission_rule( + domain_id=data_utils.rand_uuid(), effect='deny') + + updated = self.client.update_flavor_permission_rule( + rule['id'], effect='allow')['flavor_permission_rule'] + self.assertEqual('allow', updated['effect']) + + shown = self.client.show_flavor_permission_rule( + rule['id'])['flavor_permission_rule'] + self.assertEqual('allow', shown['effect']) + + @decorators.idempotent_id('b409a77f-1d21-4fa9-9913-b623803483ab') + def test_list_filter_by_domain_id(self): + """Filter by domain_id""" + self.assertEqual( + {self.domain_deny['id'], self.domain_allow_flavor['id'], + self.project_allow['id'], self.other_project_deny_flavor['id']}, + self._list_ids(domain_id=self.domain_id)) + self.assertEqual({self.other_domain_allow['id']}, + self._list_ids(domain_id=self.domain_id_alt)) + + @decorators.idempotent_id('90a8a227-4084-4e02-893c-b3c1b0ea768e') + def test_list_filter_by_project_id(self): + """Filter by project_id""" + self.assertEqual({self.project_allow['id']}, + self._list_ids(project_id=self.project_id)) + + @decorators.idempotent_id('95668109-c57f-4565-8e4a-c4eba8465f7d') + def test_list_filter_by_effect(self): + """Filter by effect (allow/deny)""" + self.assertEqual( + {self.domain_allow_flavor['id'], self.project_allow['id']}, + self._list_ids(domain_id=self.domain_id, effect='allow')) + self.assertEqual( + {self.domain_deny['id'], self.other_project_deny_flavor['id']}, + self._list_ids(domain_id=self.domain_id, effect='deny')) + + @decorators.idempotent_id('3ca050ad-52d7-4d28-acaa-9f555d0db6fb') + def test_list_filter_by_scope(self): + """Filter by scope (domain/project)""" + self.assertEqual( + {self.project_allow['id'], self.other_project_deny_flavor['id']}, + self._list_ids(domain_id=self.domain_id, scope='project')) + self.assertEqual( + {self.domain_deny['id'], self.domain_allow_flavor['id']}, + self._list_ids(domain_id=self.domain_id, scope='domain')) + + @decorators.idempotent_id('50e0b13f-c340-4171-b96d-c917db1d106a') + def test_list_filter_by_flavor_id(self): + """Filter by flavor_id""" + self.assertEqual( + {self.domain_allow_flavor['id']}, + self._list_ids( + domain_id=self.domain_id, flavor_id=self.flavor_ref)) + + @decorators.idempotent_id('3291f21c-66e5-4b1a-8342-a8422b97c76f') + def test_list_filter_by_has_flavor(self): + """Filter by has_flavor (True/False)""" + self.assertEqual( + {self.domain_deny['id'], self.project_allow['id']}, + self._list_ids(domain_id=self.domain_id, has_flavor=False)) + self.assertEqual( + {self.domain_allow_flavor['id'], + self.other_project_deny_flavor['id']}, + self._list_ids(domain_id=self.domain_id, has_flavor=True)) + + @decorators.idempotent_id('788b53aa-9013-487b-95e8-6ea68a8e9b69') + def test_list_pagination(self): + """Pagination with limit and marker""" + resp = self.client.list_flavor_permission_rules( + domain_id=self.domain_id, limit=2) + page = resp['flavor_permission_rules'] + self.assertEqual(2, len(page)) + self.assertIn('flavor_permission_rules_links', resp) + page_ids = {r['id'] for r in page} + self.assertEqual( + self._list_ids(domain_id=self.domain_id) - page_ids, + self._list_ids(domain_id=self.domain_id, marker=page[-1]['id'])) diff --git a/tempest/api/compute/admin/test_flavor_permission_rules_negative.py b/tempest/api/compute/admin/test_flavor_permission_rules_negative.py new file mode 100644 index 0000000000..bee89544aa --- /dev/null +++ b/tempest/api/compute/admin/test_flavor_permission_rules_negative.py @@ -0,0 +1,170 @@ +# Copyright (c) 2026 SAP SE +# 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 tempest.api.compute import base +from tempest import config +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions as lib_exc + +CONF = config.CONF + + +class FlavorPermissionRulesNegativeTest(base.BaseV2ComputeAdminTest): + """ + Negative tests for the flavor permission rules API with admin privileges + """ + + min_microversion = '2.100' + + @classmethod + def resource_setup(cls): + super(FlavorPermissionRulesNegativeTest, cls).resource_setup() + cls.client = cls.admin_flavor_permission_rules_client + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('51973116-514d-4e0a-a96c-9ce1212bd5c0') + def test_create_duplicate_rule(self): + """Creating duplicate rules raises conflict""" + domain_id = data_utils.rand_uuid() + self.create_flavor_permission_rule(domain_id=domain_id, effect='deny') + self.assertRaises( + lib_exc.Conflict, self.client.create_flavor_permission_rule, + domain_id=domain_id, effect='deny') + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('3f7ad1e1-77c5-4b55-a349-aeaad40cffa2') + def test_create_with_nonexistent_flavor(self): + """Creating rules for non-existent flavors raises not found""" + self.assertRaises( + lib_exc.NotFound, self.client.create_flavor_permission_rule, + domain_id=data_utils.rand_uuid(), effect='allow', + flavor_id=data_utils.rand_uuid()) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('95d87ee7-4370-4aa7-a727-ea9779ed28e6') + def test_create_rule_for_non_public_flavor(self): + """Creating rules for private flavors raises bad request""" + flavor = self.create_flavor(ram=512, vcpus=1, disk=1, + is_public='False') + self.assertRaises( + lib_exc.BadRequest, self.client.create_flavor_permission_rule, + domain_id=data_utils.rand_uuid(), effect='allow', + flavor_id=flavor['id']) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('124deaa1-8178-47b2-9e29-940b6ececd6e') + def test_create_missing_domain_id(self): + """Creating rules without domain_id raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.create_flavor_permission_rule, + effect='deny') + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('fd263ad5-c671-4783-b00e-c748e22e89cf') + def test_create_missing_effect(self): + """Creating rules without effect raises bad request.""" + self.assertRaises( + lib_exc.BadRequest, self.client.create_flavor_permission_rule, + domain_id=data_utils.rand_uuid()) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('49adf92b-28f9-4af1-8b38-239ca2096993') + def test_create_invalid_effect(self): + """Creating rules with invalid effect raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.create_flavor_permission_rule, + domain_id=data_utils.rand_uuid(), effect='unsupported') + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('918c52e9-86d7-4a15-8765-06c741c35d37') + def test_create_additional_property(self): + """Creating rules with unsupported properties raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.create_flavor_permission_rule, + domain_id=data_utils.rand_uuid(), effect='deny', unsupported='x') + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('ed625fb4-642e-43d4-9dcd-47b0f780db45') + def test_show_update_delete_nonexistent_rule(self): + """Show/update/delete of non-existing rules raises not found""" + rule_id = data_utils.rand_uuid() + self.assertRaises(lib_exc.NotFound, + self.client.show_flavor_permission_rule, rule_id) + self.assertRaises(lib_exc.NotFound, + self.client.update_flavor_permission_rule, + rule_id, effect='allow') + self.assertRaises(lib_exc.NotFound, + self.client.delete_flavor_permission_rule, rule_id) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('e6a295b5-3942-41e5-af14-65bb6071e035') + def test_update_invalid_effect(self): + """Updating a rule with an invalid effect raises bad request""" + rule = self.create_flavor_permission_rule( + domain_id=data_utils.rand_uuid(), effect='deny') + self.assertRaises( + lib_exc.BadRequest, self.client.update_flavor_permission_rule, + rule['id'], effect='unsupported') + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('e0856298-bc49-4cc8-b36c-69377613d1b1') + def test_update_missing_effect(self): + """Updating a rule without an effect raises bad request""" + rule = self.create_flavor_permission_rule( + domain_id=data_utils.rand_uuid(), effect='deny') + self.assertRaises( + lib_exc.BadRequest, self.client.update_flavor_permission_rule, + rule['id']) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('930edc2d-c834-464a-828d-bf07bedbdfc5') + def test_list_flavor_id_and_has_flavor_mutually_exclusive(self): + """Listing with both flavor_id and has_flavor raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.list_flavor_permission_rules, + flavor_id=CONF.compute.flavor_ref, has_flavor=True) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('529a41b5-0417-49d7-b1e3-01318e0444f3') + def test_list_with_unknown_marker(self): + """Listing with unknown marker raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.list_flavor_permission_rules, + marker=data_utils.rand_uuid()) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('a9924860-e5d0-4db4-a61e-759c363428a0') + def test_list_with_nonexistent_flavor(self): + """Listing with a non-existent flavor raises not found""" + self.assertRaises( + lib_exc.NotFound, self.client.list_flavor_permission_rules, + flavor_id=data_utils.rand_uuid()) + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('9fb1910c-83cf-47e1-9b77-613dcfe6a05f') + def test_list_with_invalid_scope(self): + """Listing with an invalid scope filter raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.list_flavor_permission_rules, + scope='unsupported') + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('eb7bb63e-7e6d-4087-8bf8-e50ee4938030') + def test_list_with_invalid_has_flavor(self): + """Listing with a non-boolean has_flavor value raises bad request""" + self.assertRaises( + lib_exc.BadRequest, self.client.list_flavor_permission_rules, + has_flavor='maybe') diff --git a/tempest/api/compute/admin/test_flavors.py b/tempest/api/compute/admin/test_flavors.py index 294b1ab4b1..17f33aca8b 100644 --- a/tempest/api/compute/admin/test_flavors.py +++ b/tempest/api/compute/admin/test_flavors.py @@ -232,3 +232,70 @@ def test_create_flavor_using_string_ram(self): id=new_flavor_id) self.assertEqual(flavor['ram'], int(ram)) self.assertEqual(int(flavor['id']), new_flavor_id) + + +class FlavorPermissionsAdminTestJSON(base.BaseV2ComputeAdminTest): + + min_microversion = '2.100' + + def setUp(self): + super().setUp() + self.flavor = self.create_flavor( + ram=512, vcpus=1, disk=10, is_public='True') + + @decorators.idempotent_id('56ca1d2d-c430-4431-a046-09368029a648') + def test_list_flavor_permission_filter(self): + """Test filtering flavor list by permissions""" + domain_id = self.os_admin.credentials.project_domain_id + project_id = self.os_admin.credentials.project_id + # Deny flavor at project scope + self.create_flavor_permission_rule( + domain_id=domain_id, project_id=project_id, + effect='deny', flavor_id=self.flavor['id']) + # Flavor appears in the list of denied flavors + denied = self.admin_flavors_client.list_flavors( + detail=True, project_permission='deny')['flavors'] + self.assertIn(self.flavor['id'], {f['id'] for f in denied}) + # Flavor is not in the list of allowed flavors + allowed = self.admin_flavors_client.list_flavors( + detail=True, project_permission='allow')['flavors'] + self.assertNotIn(self.flavor['id'], {f['id'] for f in allowed}) + + @decorators.idempotent_id('a90933ae-cfb3-4d7a-a3cc-59b89cf0f605') + def test_show_list_flavor_permissions_annotation(self): + """Test showing and listing permissions annotation""" + domain_id = self.os_admin.credentials.project_domain_id + project_id = self.os_admin.credentials.project_id + # Show reflects default 'allow' permissions + shown = self.admin_flavors_client.show_flavor( + self.flavor['id'])['flavor'] + self.assertIn('permissions', shown) + self.assertEqual('allow', shown['permissions']['project']) + self.assertEqual('allow', shown['permissions']['domain']) + # Show reflects project 'deny' rule + self.create_flavor_permission_rule( + domain_id=domain_id, project_id=project_id, + effect='deny', flavor_id=self.flavor['id']) + shown = self.admin_flavors_client.show_flavor( + self.flavor['id'])['flavor'] + self.assertEqual('deny', shown['permissions']['project']) + self.assertEqual('allow', shown['permissions']['domain']) + # Details reflect project 'deny' rule + detailed = self.admin_flavors_client.list_flavors( + detail=True)['flavors'] + detailed = [f for f in detailed if f['id'] == self.flavor['id']] + self.assertEqual(1, len(detailed)) + self.assertEqual('deny', detailed[0]['permissions']['project']) + + @decorators.idempotent_id('d4e5fa02-004e-4b3e-8705-22b65e460ab9') + def test_show_list_denied_flavor_non_admin(self): + """Deny rules hide flavors from list and show for regular users""" + self.create_flavor_permission_rule( + domain_id=self.os_primary.credentials.project_domain_id, + project_id=self.os_primary.credentials.project_id, + effect='deny', flavor_id=self.flavor['id']) + flavor_ids = {f['id'] for f in self.flavors_client.list_flavors( + detail=True)['flavors']} + self.assertNotIn(self.flavor['id'], flavor_ids) + self.assertRaises(lib_exc.NotFound, + self.flavors_client.show_flavor, self.flavor['id']) diff --git a/tempest/api/compute/base.py b/tempest/api/compute/base.py index 75df5ae7cb..7006038e87 100644 --- a/tempest/api/compute/base.py +++ b/tempest/api/compute/base.py @@ -77,6 +77,8 @@ def setup_clients(cls): cls.servers_client = cls.os_primary.servers_client cls.server_groups_client = cls.os_primary.server_groups_client cls.flavors_client = cls.os_primary.flavors_client + cls.flavor_permission_rules_client = ( + cls.os_primary.flavor_permission_rules_client) cls.compute_images_client = cls.os_primary.compute_images_client cls.extensions_client = cls.os_primary.extensions_client cls.floating_ip_pools_client = cls.os_primary.floating_ip_pools_client @@ -668,6 +670,8 @@ def setup_clients(cls): cls.availability_zone_admin_client = ( cls.os_admin.availability_zone_client) cls.admin_flavors_client = cls.os_admin.flavors_client + cls.admin_flavor_permission_rules_client = ( + cls.os_admin.flavor_permission_rules_client) cls.admin_servers_client = cls.os_admin.servers_client cls.admin_image_client = cls.os_admin.image_client_v2 cls.admin_assisted_volume_snapshots_client = \ @@ -686,6 +690,25 @@ def create_flavor(self, ram, vcpus, disk, name=None, self.addCleanup(client.delete_flavor, flavor['id']) return flavor + @classmethod + def create_flavor_permission_rule(cls, domain_id, effect, + project_id=None, flavor_id=None): + kwargs = {} + if project_id is not None: + kwargs['project_id'] = project_id + if flavor_id is not None: + kwargs['flavor_id'] = flavor_id + client = cls.admin_flavor_permission_rules_client + rule = client.create_flavor_permission_rule( + domain_id=domain_id, effect=effect, + **kwargs)['flavor_permission_rule'] + # Deletion is synchronous (204), so wait_for_resource_deletion is not + # needed + cls.addClassResourceCleanup( + test_utils.call_and_ignore_notfound_exc, + client.delete_flavor_permission_rule, rule['id']) + return rule + @classmethod def get_host_for_server(cls, server_id): server_details = cls.admin_servers_client.show_server(server_id) diff --git a/tempest/api/compute/flavors/test_flavor_permission_rules_negative.py b/tempest/api/compute/flavors/test_flavor_permission_rules_negative.py new file mode 100644 index 0000000000..95e60178fa --- /dev/null +++ b/tempest/api/compute/flavors/test_flavor_permission_rules_negative.py @@ -0,0 +1,43 @@ +# Copyright (c) 2026 SAP SE +# 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 tempest.api.compute import base +from tempest.lib.common.utils import data_utils +from tempest.lib import decorators +from tempest.lib import exceptions as lib_exc + + +class FlavorPermissionRulesNegativeTest(base.BaseV2ComputeTest): + + min_microversion = '2.100' + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('aa09f353-33f4-44b4-912e-f4fc1d8d95f9') + def test_unauthorized(self): + """Requests raise Forbidden or NotFound""" + rule_id = data_utils.rand_uuid() + nc = self.flavor_permission_rules_client + self.assertRaises(lib_exc.Forbidden, + nc.list_flavor_permission_rules) + self.assertRaises(lib_exc.Forbidden, + nc.create_flavor_permission_rule, + domain_id=data_utils.rand_uuid(), effect='deny') + self.assertRaises(lib_exc.NotFound, + nc.show_flavor_permission_rule, rule_id) + self.assertRaises(lib_exc.NotFound, + nc.update_flavor_permission_rule, rule_id, + effect='allow') + self.assertRaises(lib_exc.NotFound, + nc.delete_flavor_permission_rule, rule_id) diff --git a/tempest/api/compute/flavors/test_flavors.py b/tempest/api/compute/flavors/test_flavors.py index 9ab75c5251..a3eebe4e15 100644 --- a/tempest/api/compute/flavors/test_flavors.py +++ b/tempest/api/compute/flavors/test_flavors.py @@ -39,6 +39,9 @@ def test_list_flavors_with_detail(self): flavors = self.flavors_client.list_flavors(detail=True)['flavors'] flavor = self.flavors_client.show_flavor(self.flavor_ref)['flavor'] self.assertIn(flavor, flavors) + # Only admin users see permissions + for f in flavors: + self.assertNotIn('permissions', f) @decorators.attr(type='smoke') @decorators.idempotent_id('1f12046b-753d-40d2-abb6-d8eb8b30cb2f') @@ -46,6 +49,8 @@ def test_get_flavor(self): """The expected flavor details should be returned""" flavor = self.flavors_client.show_flavor(self.flavor_ref)['flavor'] self.assertEqual(self.flavor_ref, flavor['id']) + # Only admin users see permissions + self.assertNotIn('permissions', flavor) @decorators.idempotent_id('8d7691b3-6ed4-411a-abc9-2839a765adab') def test_list_flavors_limit_results(self): diff --git a/tempest/api/compute/flavors/test_flavors_negative.py b/tempest/api/compute/flavors/test_flavors_negative.py index 5d6a7d7a8d..f0bcddf78f 100644 --- a/tempest/api/compute/flavors/test_flavors_negative.py +++ b/tempest/api/compute/flavors/test_flavors_negative.py @@ -73,3 +73,21 @@ def test_boot_with_low_ram(self): self.create_test_server, image_id=image['id'], flavor=flavor['id']) + + +class FlavorPermissionsV2NegativeTest(base.BaseV2ComputeTest): + + min_microversion = '2.100' + + @decorators.attr(type=['negative']) + @decorators.idempotent_id('97f91e41-ef1c-438e-82c8-15463bcfc035') + def test_flavor_filter_param_unauthorized(self): + """Listing with permission filter raises Forbidden""" + self.assertRaises( + lib_exc.Forbidden, + self.flavors_client.list_flavors, + detail=True, domain_permission='allow') + self.assertRaises( + lib_exc.Forbidden, + self.flavors_client.list_flavors, + detail=True, project_permission='allow') diff --git a/tempest/clients.py b/tempest/clients.py index a65c43b7bf..1c8bcd5f1e 100644 --- a/tempest/clients.py +++ b/tempest/clients.py @@ -123,6 +123,8 @@ def _set_compute_clients(self): self.quotas_client = self.compute.QuotasClient() self.quota_classes_client = self.compute.QuotaClassesClient() self.flavors_client = self.compute.FlavorsClient() + self.flavor_permission_rules_client = ( + self.compute.FlavorPermissionRulesClient()) self.extensions_client = self.compute.ExtensionsClient() self.floating_ip_pools_client = self.compute.FloatingIPPoolsClient() self.floating_ips_bulk_client = self.compute.FloatingIPsBulkClient() diff --git a/tempest/lib/api_schema/response/compute/v2_1/flavor_permission_rules.py b/tempest/lib/api_schema/response/compute/v2_1/flavor_permission_rules.py new file mode 100644 index 0000000000..1d1c415196 --- /dev/null +++ b/tempest/lib/api_schema/response/compute/v2_1/flavor_permission_rules.py @@ -0,0 +1,72 @@ +# Copyright (c) 2026 SAP SE +# 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 tempest.lib.api_schema.response.compute.v2_1 import parameter_types + +flavor_permission_rule = { + 'type': 'object', + 'properties': { + 'id': {'type': 'string'}, + 'domain_id': {'type': 'string'}, + 'project_id': {'type': ['string', 'null']}, + 'flavor_id': {'type': ['string', 'null']}, + 'effect': {'type': 'string', 'enum': ['allow', 'deny']}, + 'scope': {'type': 'string', 'enum': ['domain', 'project']}, + 'links': parameter_types.links, + }, + 'additionalProperties': False, + 'required': ['id', 'domain_id', 'project_id', 'flavor_id', 'effect', + 'scope', 'links'], +} + +show_update_flavor_permission_rule = { + 'status_code': [200], + 'response_body': { + 'type': 'object', + 'properties': { + 'flavor_permission_rule': flavor_permission_rule, + }, + 'additionalProperties': False, + 'required': ['flavor_permission_rule'], + } +} + +# POST returns 201 with the same body shape as show/update. +create_flavor_permission_rule = { + 'status_code': [201], + 'response_body': show_update_flavor_permission_rule['response_body'], +} + +list_flavor_permission_rules = { + 'status_code': [200], + 'response_body': { + 'type': 'object', + 'properties': { + 'flavor_permission_rules': { + 'type': 'array', + 'items': flavor_permission_rule, + }, + # flavor_permission_rules_links is only present when the response + # is paginated, so it is not 'required'. + 'flavor_permission_rules_links': parameter_types.links, + }, + 'additionalProperties': False, + 'required': ['flavor_permission_rules'], + } +} + +delete_flavor_permission_rule = { + 'status_code': [204], +} diff --git a/tempest/lib/api_schema/response/compute/v2_1/flavors.py b/tempest/lib/api_schema/response/compute/v2_1/flavors.py index bd5e3d6361..f145e4a14e 100644 --- a/tempest/lib/api_schema/response/compute/v2_1/flavors.py +++ b/tempest/lib/api_schema/response/compute/v2_1/flavors.py @@ -14,6 +14,19 @@ from tempest.lib.api_schema.response.compute.v2_1 import parameter_types +# SAP flavor permission rules annotation, emitted on flavor detail/show for +# callers holding the os-flavor-permission-rules index:domain / index:project +# policy. The key is present (possibly empty) for such callers, absent +# otherwise, so it is optional here. +flavor_permissions = { + 'type': 'object', + 'properties': { + 'domain': {'type': 'string', 'enum': ['allow', 'deny']}, + 'project': {'type': 'string', 'enum': ['allow', 'deny']}, + }, + 'additionalProperties': False, +} + list_flavors = { 'status_code': [200], 'response_body': { @@ -56,7 +69,7 @@ 'OS-FLV-DISABLED:disabled': {'type': 'boolean'}, 'os-flavor-access:is_public': {'type': 'boolean'}, 'rxtx_factor': {'type': 'number'}, - 'OS-FLV-EXT-DATA:ephemeral': {'type': 'integer'} + 'OS-FLV-EXT-DATA:ephemeral': {'type': 'integer'}, }, 'additionalProperties': False, # 'OS-FLV-DISABLED', 'os-flavor-access', 'rxtx_factor' and @@ -82,7 +95,7 @@ } } -create_update_get_flavor_details = { +create_update_flavor_details = { 'status_code': [200], 'response_body': { 'type': 'object', @@ -94,6 +107,8 @@ } } +show_flavor_details = create_update_flavor_details + delete_flavor = { 'status_code': [202] } diff --git a/tempest/lib/api_schema/response/compute/v2_100/__init__.py b/tempest/lib/api_schema/response/compute/v2_100/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tempest/lib/api_schema/response/compute/v2_100/flavors.py b/tempest/lib/api_schema/response/compute/v2_100/flavors.py new file mode 100644 index 0000000000..00f5716f02 --- /dev/null +++ b/tempest/lib/api_schema/response/compute/v2_100/flavors.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026 SAP SE +# 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. + +import copy + +from tempest.lib.api_schema.response.compute.v2_61 import flavors \ + as flavorsv261 + + +# SAP flavor permission rules annotation, emitted on flavor detail/show for +# callers holding the os-flavor-permission-rules index:domain / index:project +# policy. The key is present (possibly empty) for such callers, absent +# otherwise, so it is optional here. +flavor_permissions = { + 'type': 'object', + 'properties': { + 'domain': {'type': 'string', 'enum': ['allow', 'deny']}, + 'project': {'type': 'string', 'enum': ['allow', 'deny']}, + }, + 'additionalProperties': False, +} + +common_flavor_info = copy.deepcopy(flavorsv261.common_flavor_info) +common_flavor_info['properties']['permissions'] = flavor_permissions +list_flavors_details = copy.deepcopy(flavorsv261.list_flavors_details) +list_flavors_details['response_body']['properties']['flavors'][ + 'items'] = common_flavor_info + +create_update_flavor_details = copy.deepcopy( + flavorsv261.create_update_flavor_details) +show_flavor_details = copy.deepcopy(create_update_flavor_details) +show_flavor_details['response_body']['properties'][ + 'flavor'] = common_flavor_info + +# Unchanged since 2.61 +list_flavors = copy.deepcopy(flavorsv261.list_flavors) +delete_flavor = copy.deepcopy(flavorsv261.delete_flavor) diff --git a/tempest/lib/api_schema/response/compute/v2_55/flavors.py b/tempest/lib/api_schema/response/compute/v2_55/flavors.py index 554f43b4ca..31e4a620a4 100644 --- a/tempest/lib/api_schema/response/compute/v2_55/flavors.py +++ b/tempest/lib/api_schema/response/compute/v2_55/flavors.py @@ -74,7 +74,7 @@ 'os-flavor-access:is_public': {'type': 'boolean'}, 'rxtx_factor': {'type': 'number'}, 'OS-FLV-EXT-DATA:ephemeral': {'type': 'integer'}, - 'description': flavor_description + 'description': flavor_description, }, 'additionalProperties': False, # 'OS-FLV-DISABLED', 'os-flavor-access', 'rxtx_factor' and @@ -101,7 +101,7 @@ } } -create_update_get_flavor_details = { +create_update_flavor_details = { 'status_code': [200], 'response_body': { 'type': 'object', @@ -113,6 +113,8 @@ } } +show_flavor_details = create_update_flavor_details + # Note(zhufl): Below are the unchanged schema in this microversion. We need # to keep this schema in this file to have the generic way to select the # right schema based on self.schema_versions_info mapping in service client. diff --git a/tempest/lib/api_schema/response/compute/v2_61/flavors.py b/tempest/lib/api_schema/response/compute/v2_61/flavors.py index 5119466ba0..4141b194f2 100644 --- a/tempest/lib/api_schema/response/compute/v2_61/flavors.py +++ b/tempest/lib/api_schema/response/compute/v2_61/flavors.py @@ -14,6 +14,7 @@ import copy +from tempest.lib.api_schema.response.compute.v2_1 import flavors as flavorsv21 from tempest.lib.api_schema.response.compute.v2_1 import parameter_types from tempest.lib.api_schema.response.compute.v2_55 import flavors \ as flavorsv255 @@ -56,7 +57,7 @@ 'rxtx_factor': {'type': 'number'}, 'OS-FLV-EXT-DATA:ephemeral': {'type': 'integer'}, 'description': flavor_description, - 'extra_specs': flavor_extra_specs + 'extra_specs': flavor_extra_specs, }, 'additionalProperties': False, # 'OS-FLV-DISABLED', 'os-flavor-access', 'rxtx_factor' and @@ -83,7 +84,7 @@ } } -create_update_get_flavor_details = { +create_update_flavor_details = { 'status_code': [200], 'response_body': { 'type': 'object', @@ -95,6 +96,8 @@ } } +show_flavor_details = create_update_flavor_details + # ****** Schemas unchanged in microversion 2.61 since microversion 2.55 *** # Note(gmann): Below are the unchanged schema in this microversion. We need # to keep this schema in this file to have the generic way to select the diff --git a/tempest/lib/services/compute/__init__.py b/tempest/lib/services/compute/__init__.py index 8d07a45675..1b36365701 100644 --- a/tempest/lib/services/compute/__init__.py +++ b/tempest/lib/services/compute/__init__.py @@ -25,6 +25,8 @@ from tempest.lib.services.compute.extensions_client import \ ExtensionsClient from tempest.lib.services.compute.fixed_ips_client import FixedIPsClient +from tempest.lib.services.compute.flavor_permission_rules_client import \ + FlavorPermissionRulesClient from tempest.lib.services.compute.flavors_client import FlavorsClient from tempest.lib.services.compute.floating_ip_pools_client import \ FloatingIPPoolsClient @@ -78,3 +80,6 @@ 'ServerGroupsClient', 'ServersClient', 'ServicesClient', 'SnapshotsClient', 'TenantNetworksClient', 'TenantUsagesClient', 'VersionsClient', 'VolumesClient'] + +# separated SAP additions to reduce merge conflicts with upstream +__all__ += ['FlavorPermissionRulesClient'] diff --git a/tempest/lib/services/compute/flavor_permission_rules_client.py b/tempest/lib/services/compute/flavor_permission_rules_client.py new file mode 100644 index 0000000000..466d9d2888 --- /dev/null +++ b/tempest/lib/services/compute/flavor_permission_rules_client.py @@ -0,0 +1,87 @@ +# Copyright (c) 2026 SAP SE +# 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 urllib import parse as urllib + +from oslo_serialization import jsonutils as json + +from tempest.lib.api_schema.response.compute.v2_1 import \ + flavor_permission_rules as schema +from tempest.lib.common import rest_client +from tempest.lib import exceptions as lib_exc +from tempest.lib.services.compute import base_compute_client + + +class FlavorPermissionRulesClient(base_compute_client.BaseComputeClient): + """Client for the flavor permission rules compute API extension.""" + + def _get_url(self, rule_id=None, **params): + url = 'flavor-permission-rules' + if rule_id is not None: + url += '/%s' % rule_id + if params: + url += '?%s' % urllib.urlencode(params) + return url + + def _validated_body(self, response_schema, resp, body): + # DELETE returns 204 with an empty body; only parse when present. + if body: + body = json.loads(body) + self.validate_response(response_schema, resp, body) + return rest_client.ResponseBody(resp, body) + + def list_flavor_permission_rules(self, **params): + """List flavor permission rules.""" + resp, body = self.get(self._get_url(**params)) + return self._validated_body( + schema.list_flavor_permission_rules, resp, body) + + def show_flavor_permission_rule(self, rule_id): + """Show details for a flavor permission rule.""" + resp, body = self.get(self._get_url(rule_id)) + return self._validated_body( + schema.show_update_flavor_permission_rule, resp, body) + + def create_flavor_permission_rule(self, **kwargs): + """Create a flavor permission rule.""" + post_body = json.dumps({'flavor_permission_rule': kwargs}) + resp, body = self.post(self._get_url(), post_body) + return self._validated_body( + schema.create_flavor_permission_rule, resp, body) + + def update_flavor_permission_rule(self, rule_id, **kwargs): + """Update a flavor permission rule (only `effect` is updatable).""" + put_body = json.dumps({'flavor_permission_rule': kwargs}) + resp, body = self.put(self._get_url(rule_id), put_body) + return self._validated_body( + schema.show_update_flavor_permission_rule, resp, body) + + def delete_flavor_permission_rule(self, rule_id): + """Delete the given flavor permission rule.""" + resp, body = self.delete(self._get_url(rule_id)) + return self._validated_body( + schema.delete_flavor_permission_rule, resp, body) + + @property + def resource_type(self): + """Return the primary type of resource this client works with.""" + return 'flavor_permission_rule' + + def is_resource_deleted(self, id): + try: + self.show_flavor_permission_rule(id) + except lib_exc.NotFound: + return True + return False diff --git a/tempest/lib/services/compute/flavors_client.py b/tempest/lib/services/compute/flavors_client.py index 5282405b6f..1ac934c887 100644 --- a/tempest/lib/services/compute/flavors_client.py +++ b/tempest/lib/services/compute/flavors_client.py @@ -26,6 +26,8 @@ as schemav255 from tempest.lib.api_schema.response.compute.v2_61 import flavors \ as schemav261 +from tempest.lib.api_schema.response.compute.v2_100 import flavors \ + as schemav2100 from tempest.lib.common import rest_client from tempest.lib.services.compute import base_compute_client @@ -35,7 +37,8 @@ class FlavorsClient(base_compute_client.BaseComputeClient): schema_versions_info = [ {'min': None, 'max': '2.54', 'schema': schema}, {'min': '2.55', 'max': '2.60', 'schema': schemav255}, - {'min': '2.61', 'max': None, 'schema': schemav261}] + {'min': '2.61', 'max': '2.99', 'schema': schemav261}, + {'min': '2.100', 'max': None, 'schema': schemav2100}] def list_flavors(self, detail=False, **params): """Lists flavors. @@ -70,7 +73,7 @@ def show_flavor(self, flavor_id): resp, body = self.get("flavors/%s" % flavor_id) body = json.loads(body) schema = self.get_schema(self.schema_versions_info) - self.validate_response(schema.create_update_get_flavor_details, + self.validate_response(schema.show_flavor_details, resp, body) return rest_client.ResponseBody(resp, body) @@ -91,7 +94,7 @@ def create_flavor(self, **kwargs): body = json.loads(body) schema = self.get_schema(self.schema_versions_info) - self.validate_response(schema.create_update_get_flavor_details, + self.validate_response(schema.create_update_flavor_details, resp, body) return rest_client.ResponseBody(resp, body) @@ -107,7 +110,7 @@ def update_flavor(self, flavor_id, **kwargs): body = json.loads(body) schema = self.get_schema(self.schema_versions_info) - self.validate_response(schema.create_update_get_flavor_details, + self.validate_response(schema.create_update_flavor_details, resp, body) return rest_client.ResponseBody(resp, body)