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..6cfffc114b --- /dev/null +++ b/tempest/api/compute/admin/test_flavor_permission_rules.py @@ -0,0 +1,187 @@ +# 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. + """ + + @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..f701e6cedf --- /dev/null +++ b/tempest/api/compute/admin/test_flavor_permission_rules_negative.py @@ -0,0 +1,168 @@ +# 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 + """ + + @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..6374ff876e 100644 --- a/tempest/api/compute/admin/test_flavors.py +++ b/tempest/api/compute/admin/test_flavors.py @@ -232,3 +232,64 @@ 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) + + @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 + flavor = self.create_flavor(ram=self.ram, vcpus=self.vcpus, + disk=self.disk, is_public='True') + # Deny flavor at project scope + self.create_flavor_permission_rule( + domain_id=domain_id, project_id=project_id, + effect='deny', flavor_id=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(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(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 + flavor = self.create_flavor(ram=self.ram, vcpus=self.vcpus, + disk=self.disk, is_public='True') + # Show reflects default 'allow' permissions + shown = self.admin_flavors_client.show_flavor(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=flavor['id']) + shown = self.admin_flavors_client.show_flavor(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'] == 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""" + flavor = self.create_flavor(ram=self.ram, vcpus=self.vcpus, + disk=self.disk, is_public='True') + 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=flavor['id']) + flavor_ids = {f['id'] for f in self.flavors_client.list_flavors( + detail=True)['flavors']} + self.assertNotIn(flavor['id'], flavor_ids) + self.assertRaises(lib_exc.NotFound, + self.flavors_client.show_flavor, 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..9c52435ab1 --- /dev/null +++ b/tempest/api/compute/flavors/test_flavor_permission_rules_negative.py @@ -0,0 +1,41 @@ +# 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): + + @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..3a9076ec58 100644 --- a/tempest/api/compute/flavors/test_flavors_negative.py +++ b/tempest/api/compute/flavors/test_flavors_negative.py @@ -73,3 +73,16 @@ def test_boot_with_low_ram(self): self.create_test_server, image_id=image['id'], flavor=flavor['id']) + + @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..84148c827e 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,8 @@ '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'}, + 'permissions': flavor_permissions }, 'additionalProperties': False, # 'OS-FLV-DISABLED', 'os-flavor-access', 'rxtx_factor' and 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..3bde6c15f6 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,8 @@ 'os-flavor-access:is_public': {'type': 'boolean'}, 'rxtx_factor': {'type': 'number'}, 'OS-FLV-EXT-DATA:ephemeral': {'type': 'integer'}, - 'description': flavor_description + 'description': flavor_description, + 'permissions': flavorsv21.flavor_permissions }, 'additionalProperties': False, # 'OS-FLV-DISABLED', 'os-flavor-access', 'rxtx_factor' and 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..93929eed8f 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,8 @@ 'rxtx_factor': {'type': 'number'}, 'OS-FLV-EXT-DATA:ephemeral': {'type': 'integer'}, 'description': flavor_description, - 'extra_specs': flavor_extra_specs + 'extra_specs': flavor_extra_specs, + 'permissions': flavorsv21.flavor_permissions }, 'additionalProperties': False, # 'OS-FLV-DISABLED', 'os-flavor-access', 'rxtx_factor' and 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