Fixes #22821: Prevent Tenant Group deletion from creating duplicate ungrouped Tenant names or slugs (#22830)

This commit is contained in:
Martin Hauser 2026-08-06 22:08:41 +02:00 committed by GitHub
parent 80231a9706
commit d61528e464
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 173 additions and 4 deletions

View File

@ -4,6 +4,8 @@
Tenant groups may be nested recursively to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team.
A tenant group cannot be deleted if ungrouping its tenants, including those of any nested groups, would result in duplicate tenant names or slugs among ungrouped tenants.
## Fields
### Parent

View File

@ -1,5 +1,5 @@
from django.db import models
from django.db.models import Q
from django.db.models import Count, ProtectedError, Q
from django.utils.translation import gettext_lazy as _
from netbox.models import NestedGroupModel, PrimaryModel
@ -35,6 +35,24 @@ class TenantGroup(NestedGroupModel):
verbose_name = _('tenant group')
verbose_name_plural = _('tenant groups')
def delete(self, *args, **kwargs):
# Ungrouping the tenants of this group and its descendants can violate tenant name and slug uniqueness.
ungrouped = Tenant.objects.filter(
Q(group__isnull=True) | Q(group__in=self.get_descendants(include_self=True))
)
duplicate_names = ungrouped.values('name').annotate(count=Count('pk')).filter(count__gt=1).values('name')
duplicate_slugs = ungrouped.values('slug').annotate(count=Count('pk')).filter(count__gt=1).values('slug')
if conflicts := set(ungrouped.filter(Q(name__in=duplicate_names) | Q(slug__in=duplicate_slugs))):
raise ProtectedError(
_(
"Unable to delete tenant group {tenant_group}. Ungrouping its tenants, including those of any "
"nested groups, would create duplicate tenant names or slugs."
).format(tenant_group=self),
conflicts,
)
return super().delete(*args, **kwargs)
class Tenant(ContactsMixin, PrimaryModel):
"""

View File

@ -1,9 +1,14 @@
import json
import logging
from django.test import tag
from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from tenancy.choices import *
from tenancy.models import *
from utilities.testing import APITestCase, APIViewTestCases
from utilities.testing import APITestCase, APIViewTestCases, disable_logging
class AppTestCase(APITestCase):
@ -60,6 +65,52 @@ class TenantGroupTestCase(APIViewTestCases.APIViewTestCase):
},
]
@tag('regression') # Ref: #22821
def test_delete_tenant_group_with_conflicting_tenants(self):
"""
Attempt and fail to delete a tenant group whose tenants cannot be ungrouped.
"""
group = TenantGroup.objects.create(name='Tenant Group 7', slug='tenant-group-7')
Tenant.objects.create(name='Tenant 1', slug='tenant-1a', group=group)
Tenant.objects.create(name='Tenant 1', slug='tenant-1b')
self.add_permissions('tenancy.delete_tenantgroup')
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': group.pk})
with disable_logging(level=logging.WARNING):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
content = json.loads(response.content.decode('utf-8'))
self.assertIn('detail', content)
self.assertTrue(content['detail'].startswith('Unable to delete object.'))
self.assertTrue(TenantGroup.objects.filter(pk=group.pk).exists())
@tag('regression') # Ref: #22821
def test_bulk_delete_tenant_groups_with_conflicting_tenants(self):
"""
Attempt and fail to bulk delete two tenant groups whose tenants conflict only once both are
ungrouped, leaving every group and assignment intact.
"""
group1 = TenantGroup.objects.create(name='Tenant Group 8', slug='tenant-group-8')
group2 = TenantGroup.objects.create(name='Tenant Group 9', slug='tenant-group-9')
tenant1 = Tenant.objects.create(name='Tenant 2', slug='tenant-2a', group=group1)
tenant2 = Tenant.objects.create(name='Tenant 2', slug='tenant-2b', group=group2)
self.add_permissions('tenancy.delete_tenantgroup')
data = [{'id': group1.pk}, {'id': group2.pk}]
with disable_logging(level=logging.WARNING):
response = self.client.delete(self._get_list_url(), data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
# The rolled back batch must not leave the first group deleted
self.assertEqual(TenantGroup.objects.filter(pk__in=(group1.pk, group2.pk)).count(), 2)
tenant1.refresh_from_db()
tenant2.refresh_from_db()
self.assertEqual(tenant1.group, group1)
self.assertEqual(tenant2.group, group2)
class TenantTestCase(APIViewTestCases.APIViewTestCase):
model = Tenant

View File

@ -1,6 +1,104 @@
from django.test import TestCase
from django.db.models import ProtectedError
from django.test import TestCase, tag
from tenancy.models import Contact, ContactGroup
from tenancy.models import Contact, ContactGroup, Tenant, TenantGroup
class TenantGroupTestCase(TestCase):
@tag('regression') # Ref: #22821
def test_tenantgroup_deletion_blocked_by_duplicate_ungrouped_name(self):
"""
Deleting a tenant group must raise ProtectedError when ungrouping its tenant would duplicate
the name of an already ungrouped tenant.
"""
group = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1')
tenant1 = Tenant.objects.create(name='Tenant 1', slug='tenant-1a', group=group)
tenant2 = Tenant.objects.create(name='Tenant 1', slug='tenant-1b')
with self.assertRaises(ProtectedError) as cm:
group.delete()
self.assertEqual(
cm.exception.args[0],
'Unable to delete tenant group Tenant Group 1. Ungrouping its tenants, including those of any nested '
'groups, would create duplicate tenant names or slugs.'
)
self.assertEqual(set(cm.exception.protected_objects), {tenant1, tenant2})
# The failed deletion must leave the group and its tenant assignment intact
self.assertTrue(TenantGroup.objects.filter(pk=group.pk).exists())
tenant1.refresh_from_db()
self.assertEqual(tenant1.group, group)
@tag('regression') # Ref: #22821
def test_tenantgroup_deletion_blocked_by_duplicate_ungrouped_slug(self):
"""
Deleting a tenant group must raise ProtectedError for a slug collision alone, when the
colliding tenants have differing names.
"""
group = TenantGroup.objects.create(name='Tenant Group 2', slug='tenant-group-2')
tenant1 = Tenant.objects.create(name='Tenant 2', slug='duplicate-slug', group=group)
tenant2 = Tenant.objects.create(name='Tenant 3', slug='duplicate-slug')
with self.assertRaises(ProtectedError) as cm:
group.delete()
self.assertEqual(set(cm.exception.protected_objects), {tenant1, tenant2})
self.assertTrue(TenantGroup.objects.filter(pk=group.pk).exists())
@tag('regression') # Ref: #22821
def test_tenantgroup_deletion_blocked_by_duplicate_name_in_descendants(self):
"""
Deleting a parent tenant group must raise ProtectedError when ungrouping the tenants of its
descendant groups would duplicate a name.
"""
parent = TenantGroup.objects.create(name='Parent Group', slug='parent-group')
child1 = TenantGroup.objects.create(name='Child Group 1', slug='child-group-1', parent=parent)
child2 = TenantGroup.objects.create(name='Child Group 2', slug='child-group-2', parent=parent)
tenant1 = Tenant.objects.create(name='Tenant 4', slug='tenant-4a', group=child1)
tenant2 = Tenant.objects.create(name='Tenant 4', slug='tenant-4b', group=child2)
with self.assertRaises(ProtectedError) as cm:
parent.delete()
self.assertEqual(set(cm.exception.protected_objects), {tenant1, tenant2})
self.assertTrue(TenantGroup.objects.filter(pk=parent.pk).exists())
self.assertEqual(TenantGroup.objects.filter(pk__in=(child1.pk, child2.pk)).count(), 2)
def test_tenantgroup_deletion_ungroups_tenants(self):
"""
Deleting a tenant group whose tenants can be ungrouped safely must succeed and clear the group
assignment across the whole subtree.
"""
parent = TenantGroup.objects.create(name='Parent Group', slug='parent-group')
child = TenantGroup.objects.create(name='Child Group', slug='child-group', parent=parent)
tenant1 = Tenant.objects.create(name='Tenant 5', slug='tenant-5', group=parent)
tenant2 = Tenant.objects.create(name='Tenant 6', slug='tenant-6', group=child)
parent.delete()
self.assertFalse(TenantGroup.objects.filter(pk__in=(parent.pk, child.pk)).exists())
tenant1.refresh_from_db()
tenant2.refresh_from_db()
self.assertIsNone(tenant1.group)
self.assertIsNone(tenant2.group)
def test_tenantgroup_deletion_ignores_tenants_in_unrelated_groups(self):
"""
Deleting a tenant group must succeed when a tenant outside its subtree shares a name and slug
with one of its own tenants, as that tenant remains grouped.
"""
group = TenantGroup.objects.create(name='Tenant Group 3', slug='tenant-group-3')
unrelated = TenantGroup.objects.create(name='Unrelated Group', slug='unrelated-group')
tenant = Tenant.objects.create(name='Tenant 7', slug='tenant-7', group=group)
Tenant.objects.create(name='Tenant 7', slug='tenant-7', group=unrelated)
group.delete()
self.assertFalse(TenantGroup.objects.filter(pk=group.pk).exists())
tenant.refresh_from_db()
self.assertIsNone(tenant.group)
class ContactGroupTestCase(TestCase):