diff --git a/awx/main/access.py b/awx/main/access.py index 4ecc0c1fd1..674e04ce39 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -20,6 +20,7 @@ from rest_framework.exceptions import ParseError, PermissionDenied # django-ansible-base from ansible_base.lib.utils.validation import to_python_boolean from ansible_base.rbac.models import RoleEvaluation +from ansible_base.rbac.policies import visible_users from ansible_base.rbac import permission_registry # AWX @@ -643,6 +644,8 @@ class UserAccess(BaseAccess): Organization.access_qs(self.user, 'change').exists() or Organization.access_qs(self.user, 'audit').exists() ): qs = User.objects.all() + elif settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: + qs = visible_users(self.user) else: qs = ( User.objects.filter(pk__in=Organization.access_qs(self.user, 'view').values('member_role__members')) @@ -706,12 +709,13 @@ class UserAccess(BaseAccess): # in these cases only superusers can modify orphan users return False if settings.ANSIBLE_BASE_ROLE_SYSTEM_ACTIVATED: - # Permission granted if the user has all permissions that the target user has target_perms = set( - RoleEvaluation.objects.filter(role__in=obj.has_roles.all()).values_list('object_id', 'content_type_id', 'codename').distinct() + RoleEvaluation.objects.filter(**RoleEvaluation._actor_role_filter(obj)).values_list('object_id', 'content_type_id', 'codename').distinct() ) user_perms = set( - RoleEvaluation.objects.filter(role__in=self.user.has_roles.all()).values_list('object_id', 'content_type_id', 'codename').distinct() + RoleEvaluation.objects.filter(**RoleEvaluation._actor_role_filter(self.user)) + .values_list('object_id', 'content_type_id', 'codename') + .distinct() ) return not (target_perms - user_perms) return not obj.roles.all().exclude(ancestors__in=self.user.roles.all()).exists() diff --git a/awx/main/tasks/system.py b/awx/main/tasks/system.py index a0e2a36b82..3d669c9699 100644 --- a/awx/main/tasks/system.py +++ b/awx/main/tasks/system.py @@ -1045,8 +1045,9 @@ def _batched_delete_inventory(inventory, batch_size=500): logger.debug('Batch-deleted %d hosts from inventory %d (%d total so far)', len(pks), inventory.id, total_deleted) # then delete the inventory itself + inv_id = inventory.id inventory.delete() - logger.info('Batched deletion of inventory %d complete (%d hosts removed)', inventory.id, total_deleted) + logger.info('Batched deletion of inventory %d complete (%d hosts removed)', inv_id, total_deleted) @task(queue=get_task_queuename, timeout=3600 * 5) @@ -1062,13 +1063,12 @@ def delete_inventory(inventory_id, user_id, retries=5): with ignore_inventory_computed_fields(), ignore_inventory_group_removal(), impersonate(user): try: inv = Inventory.objects.get(id=inventory_id) - except Inventory.DoesNotExist: - logger.exception("Delete Inventory failed due to missing inventory: " + str(inventory_id)) - return - try: _batched_delete_inventory(inv) emit_channel_notification('inventories-status_changed', {'group_name': 'inventories', 'inventory_id': inventory_id, 'status': 'deleted'}) logger.debug('Deleted inventory {} as user {}.'.format(inventory_id, user_id)) + except Inventory.DoesNotExist: + logger.warning("Delete Inventory failed due to missing inventory: " + str(inventory_id)) + return except DatabaseError: logger.exception('Database error deleting inventory {}, but will retry.'.format(inventory_id)) if retries > 0: diff --git a/awx/main/tests/functional/tasks/test_tasks_system.py b/awx/main/tests/functional/tasks/test_tasks_system.py index 2b9c6ad084..94a1ebedc7 100644 --- a/awx/main/tests/functional/tasks/test_tasks_system.py +++ b/awx/main/tests/functional/tasks/test_tasks_system.py @@ -16,6 +16,8 @@ from awx.main.tasks.system import ( _batched_delete_inventory, ) from awx.main.management.commands.dispatcherd import Command +from django.db import DatabaseError + from awx.main.models import Instance, Inventory, Job, Organization, ReceptorAddress, InstanceLink from awx.main.models.inventory import Group, Host @@ -115,10 +117,13 @@ def test_folder_cleanup_multiple_running_jobs(job_folder_factory, me_inst): @pytest.mark.django_db class TestBatchedDeleteInventory: def _make_inventory_with_hosts(self, count): + from django.utils import timezone + + now = timezone.now() org = Organization.objects.create(name='test-org') inv = Inventory.objects.create(name='test-inv', organization=org) group = Group.objects.create(name='test-group', inventory=inv) - hosts = [Host(name=f'host-{i}', inventory=inv) for i in range(count)] + hosts = [Host(name=f'host-{i}', inventory=inv, created=now, modified=now) for i in range(count)] Host.objects.bulk_create(hosts) group.hosts.set(Host.objects.filter(inventory=inv)) return inv @@ -144,6 +149,47 @@ class TestBatchedDeleteInventory: assert not Host.objects.filter(inventory_id=inv_id).exists() assert not Inventory.objects.filter(id=inv_id).exists() + def test_idempotent_after_partial_delete(self): + """Simulate a crash mid-way: delete some hosts manually, then run + _batched_delete_inventory — it should finish the job cleanly.""" + inv = self._make_inventory_with_hosts(10) + inv_id = inv.id + + # Simulate a partial deletion (as if the task crashed after 4 hosts) + partial_pks = list(Host.objects.filter(inventory=inv).values_list('pk', flat=True)[:4]) + Host.objects.filter(pk__in=partial_pks).delete() + assert Host.objects.filter(inventory_id=inv_id).count() == 6 + + # Re-running should delete the remaining hosts and the inventory + inv.refresh_from_db() + _batched_delete_inventory(inv, batch_size=3) + assert not Host.objects.filter(inventory_id=inv_id).exists() + assert not Inventory.objects.filter(id=inv_id).exists() + + def test_delete_inventory_retries_on_database_error(self): + """DatabaseError during deletion triggers a retry.""" + from awx.main.tasks.system import delete_inventory + + inv = self._make_inventory_with_hosts(3) + inv_id = inv.id + + call_count = {'n': 0} + original = _batched_delete_inventory.__wrapped__ if hasattr(_batched_delete_inventory, '__wrapped__') else _batched_delete_inventory + + def flaky_delete(inventory, batch_size=500): + call_count['n'] += 1 + if call_count['n'] == 1: + raise DatabaseError('connection reset') + return original(inventory, batch_size=batch_size) + + with mock.patch('awx.main.tasks.system._batched_delete_inventory', side_effect=flaky_delete): + with mock.patch('awx.main.tasks.system.emit_channel_notification'): + with mock.patch('awx.main.tasks.system.time.sleep'): + delete_inventory(inv_id, None, retries=2) + + assert call_count['n'] == 2 + assert not Inventory.objects.filter(id=inv_id).exists() + @pytest.mark.django_db def test_clear_setting_cache_log_level_branch(settings):