diff --git a/awx/main/fields.py b/awx/main/fields.py index 3d541d524a..a4519610c3 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -48,7 +48,9 @@ from awx.main.models.rbac import batch_role_ancestor_rebuilding, Role from awx.main import utils -__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField', 'SmartFilterField'] +__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField', + 'SmartFilterField', 'update_role_parentage_for_instance', + 'is_implicit_parent'] # Provide a (better) custom error message for enum jsonschema validation @@ -181,6 +183,23 @@ def is_implicit_parent(parent_role, child_role): return False +def update_role_parentage_for_instance(instance): + '''update_role_parentage_for_instance + updates the parents listing for all the roles + of a given instance if they have changed + ''' + for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): + cur_role = getattr(instance, implicit_role_field.name) + new_parents = implicit_role_field._resolve_parent_roles(instance) + cur_role.parents.set(new_parents) + new_parents_list = list(new_parents) + new_parents_list.sort() + new_parents_json = json.dumps(new_parents_list) + if cur_role.implicit_parents != new_parents_json: + cur_role.implicit_parents = new_parents_json + cur_role.save() + + class ImplicitRoleDescriptor(ForwardManyToOneDescriptor): pass @@ -303,19 +322,7 @@ class ImplicitRoleField(models.ForeignKey): type(latest_instance).objects.filter(pk=latest_instance.pk).update(**updates) Role.rebuild_role_ancestor_list(role_ids, []) - # Update parentage if necessary - for implicit_role_field in getattr(latest_instance.__class__, '__implicit_role_fields'): - cur_role = getattr(latest_instance, implicit_role_field.name) - original_parents = set(json.loads(cur_role.implicit_parents)) - new_parents = implicit_role_field._resolve_parent_roles(latest_instance) - cur_role.parents.remove(*list(original_parents - new_parents)) - cur_role.parents.add(*list(new_parents - original_parents)) - new_parents_list = list(new_parents) - new_parents_list.sort() - new_parents_json = json.dumps(new_parents_list) - if cur_role.implicit_parents != new_parents_json: - cur_role.implicit_parents = new_parents_json - cur_role.save() + update_role_parentage_for_instance(latest_instance) instance.refresh_from_db() diff --git a/awx/main/signals.py b/awx/main/signals.py index c79b1742aa..ea4a5ae927 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -9,7 +9,13 @@ import json # Django from django.conf import settings -from django.db.models.signals import post_save, pre_delete, post_delete, m2m_changed +from django.db.models.signals import ( + post_init, + post_save, + pre_delete, + post_delete, + m2m_changed, +) from django.dispatch import receiver from django.contrib.auth import SESSION_KEY from django.utils import timezone @@ -28,7 +34,10 @@ from awx.api.serializers import * # noqa from awx.main.utils import model_instance_diff, model_to_dict, camelcase_to_underscore from awx.main.utils import ignore_inventory_computed_fields, ignore_inventory_group_removal, _inventory_updates from awx.main.tasks import update_inventory_computed_fields -from awx.main.fields import is_implicit_parent +from awx.main.fields import ( + is_implicit_parent, + update_role_parentage_for_instance, +) from awx.main import consumers @@ -230,6 +239,29 @@ def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs): l.delete() +def set_original_organization(sender, instance, **kwargs): + '''set_original_organization is used to set the original, or + pre-save organization, so we can later determine if the organization + field is dirty. + ''' + instance.__original_org = instance.organization + + +def save_related_job_templates(sender, instance, **kwargs): + '''save_related_job_templates loops through all of the + job templates that use an Inventory or Project that have had their + Organization updated. This triggers the rebuilding of the RBAC hierarchy + and ensures the proper access restrictions. + ''' + if sender not in (Project, Inventory): + raise ValueError('This signal callback is only intended for use with Project or Inventory') + + if instance.__original_org != instance.organization: + jtq = JobTemplate.objects.filter(**{sender.__name__.lower(): instance}) + for jt in jtq: + update_role_parentage_for_instance(jt) + + def connect_computed_field_signals(): post_save.connect(emit_update_inventory_on_created_or_deleted, sender=Host) post_delete.connect(emit_update_inventory_on_created_or_deleted, sender=Host) @@ -247,7 +279,10 @@ def connect_computed_field_signals(): connect_computed_field_signals() - +post_init.connect(set_original_organization, sender=Project) +post_init.connect(set_original_organization, sender=Inventory) +post_save.connect(save_related_job_templates, sender=Project) +post_save.connect(save_related_job_templates, sender=Inventory) post_save.connect(emit_job_event_detail, sender=JobEvent) post_save.connect(emit_ad_hoc_command_event_detail, sender=AdHocCommandEvent) post_save.connect(emit_project_update_event_detail, sender=ProjectUpdateEvent) diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index a12bc31b70..0b9d0c46bd 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -11,6 +11,7 @@ from awx.main.access import ( ScheduleAccess ) from awx.main.models.jobs import JobTemplate +from awx.main.models.organization import Organization from awx.main.models.schedules import Schedule @@ -296,3 +297,30 @@ class TestJobTemplateSchedules: mock_change.return_value = True assert access.can_change(schedule, {'inventory': 42}) mock_change.assert_called_once_with(schedule, {'inventory': 42}) + + +@pytest.mark.django_db +def test_jt_org_ownership_change(user, jt_linked): + admin1 = user('admin1') + org1 = jt_linked.project.organization + org1.admin_role.members.add(admin1) + a1_access = JobTemplateAccess(admin1) + + assert a1_access.can_read(jt_linked) + + + admin2 = user('admin2') + org2 = Organization.objects.create(name='mrroboto', description='domo') + org2.admin_role.members.add(admin2) + a2_access = JobTemplateAccess(admin2) + + assert not a2_access.can_read(jt_linked) + + + jt_linked.project.organization = org2 + jt_linked.project.save() + jt_linked.inventory.organization = org2 + jt_linked.inventory.save() + + assert a2_access.can_read(jt_linked) + assert not a1_access.can_read(jt_linked)