diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 1240d00291..3e1ea7702f 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -72,6 +72,7 @@ from awx.main.utils import ( prefetch_page_capabilities, get_external_account, truncate_stdout, ) from awx.main.utils.filters import SmartFilter +from awx.main.utils.named_url_graph import reset_counters from awx.main.redact import UriCleaner, REPLACE_STR from awx.main.validators import vars_validate_or_raise @@ -347,6 +348,7 @@ class BaseSerializer(serializers.ModelSerializer, metaclass=BaseSerializerMetacl def _generate_named_url(self, url_path, obj, node): url_units = url_path.split('/') + reset_counters() named_url = node.generate_named_url(obj) url_units[4] = named_url return '/'.join(url_units) @@ -700,18 +702,6 @@ class UnifiedJobTemplateSerializer(BaseSerializer): else: return super(UnifiedJobTemplateSerializer, self).to_representation(obj) - def validate(self, attrs): - if 'organization' in self.fields: - # Do not allow setting template organization to null - # otherwise be as non-restrictive as possible for PATCH or PUT, even with orphans - # does not correspond with any REST framework field construct - if self.instance is None and attrs.get('organization', None) is None: - raise serializers.ValidationError({'organization': _('Organization required for new object.')}) - if self.instance and self.instance.organization_id and attrs.get('organization', 'blank') is None: - raise serializers.ValidationError({'organization': _('Organization can not be set to null.')}) - - return super(UnifiedJobTemplateSerializer, self).validate(attrs) - class UnifiedJobSerializer(BaseSerializer): show_capabilities = ['start', 'delete'] @@ -2741,6 +2731,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task', 'timeout', 'use_fact_cache', 'organization',) + read_only_fields = ('organization',) def get_related(self, obj): res = super(JobOptionsSerializer, self).get_related(obj) diff --git a/awx/main/access.py b/awx/main/access.py index d6ae9c0082..d4c830f108 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1465,10 +1465,6 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess): if self.user not in inventory.use_role: return False - organization = get_value(Organization, 'organization') - if (not organization) or (self.user not in organization.job_template_admin_role): - return False - project = get_value(Project, 'project') # If the user has admin access to the project (as an org admin), should # be able to proceed without additional checks. @@ -1651,7 +1647,7 @@ class JobAccess(BaseAccess): except JobLaunchConfig.DoesNotExist: config = None - # Standard permissions model (1) + # Standard permissions model if obj.job_template and (self.user not in obj.job_template.execute_role): return False @@ -1666,13 +1662,15 @@ class JobAccess(BaseAccess): if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): return True - # Standard permissions model (2) + # Standard permissions model without job template involved if obj.organization and self.user in obj.organization.execute_role: - # Respect organization ownership of orphaned jobs return True elif not (obj.job_template or obj.organization): - if self.save_messages: - self.messages['detail'] = _('Job has been orphaned from its job template and organization.') + raise PermissionDenied(_('Job has been orphaned from its job template and organization.')) + elif obj.job_template and config is not None: + raise PermissionDenied(_('Job was launched with prompted fields you do not have access to.')) + elif obj.job_template and config is None: + raise PermissionDenied(_('Job was launched with unknown prompted fields. Organization admin permissions required.')) return False diff --git a/awx/main/analytics/collectors.py b/awx/main/analytics/collectors.py index d633e41aeb..173b19fe7b 100644 --- a/awx/main/analytics/collectors.py +++ b/awx/main/analytics/collectors.py @@ -257,7 +257,7 @@ def copy_tables(since, full_path): unified_job_query = '''COPY (SELECT main_unifiedjob.id, main_unifiedjob.polymorphic_ctype_id, django_content_type.model, - main_project.organization_id, + main_unifiedjob.organization_id, main_organization.name as organization_name, main_unifiedjob.created, main_unifiedjob.name, @@ -275,10 +275,8 @@ def copy_tables(since, full_path): main_unifiedjob.job_explanation, main_unifiedjob.instance_group_id FROM main_unifiedjob - JOIN main_job ON main_unifiedjob.id = main_job.unifiedjob_ptr_id JOIN django_content_type ON main_unifiedjob.polymorphic_ctype_id = django_content_type.id - JOIN main_project ON main_project.unifiedjobtemplate_ptr_id = main_job.project_id - JOIN main_organization ON main_organization.id = main_project.organization_id + JOIN main_organization ON main_organization.id = main_unifiedjob.organization_id WHERE main_unifiedjob.created > {} AND main_unifiedjob.launch_type != 'sync' ORDER BY main_unifiedjob.id ASC) TO STDOUT WITH CSV HEADER'''.format(since.strftime("'%Y-%m-%d %H:%M:%S'")) diff --git a/awx/main/fields.py b/awx/main/fields.py index 1038673eb2..36ba0c8394 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -200,29 +200,27 @@ def update_role_parentage_for_instance(instance): of a given instance if they have changed ''' changed_ct = 0 + parents_removed = set() + parents_added = set() for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): - changed = False cur_role = getattr(instance, implicit_role_field.name) original_parents = set(json.loads(cur_role.implicit_parents)) new_parents = implicit_role_field._resolve_parent_roles(instance) removals = original_parents - new_parents if removals: - changed = True cur_role.parents.remove(*list(removals)) + parents_removed.add(cur_role.pk) additions = new_parents - original_parents if additions: - changed = True cur_role.parents.add(*list(additions)) + parents_added.add(cur_role.pk) 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: - changed = True cur_role.implicit_parents = new_parents_json - cur_role.save() - if changed: - changed_ct += 1 - return changed_ct + cur_role.save(update_fields=['implicit_parents']) + return (parents_added, parents_removed) class ImplicitRoleDescriptor(ForwardManyToOneDescriptor): diff --git a/awx/main/migrations/0085_v360_job_template_organization_field.py b/awx/main/migrations/0107_v370_job_template_organization_field.py similarity index 82% rename from awx/main/migrations/0085_v360_job_template_organization_field.py rename to awx/main/migrations/0107_v370_job_template_organization_field.py index a454083e98..11fd247f5c 100644 --- a/awx/main/migrations/0085_v360_job_template_organization_field.py +++ b/awx/main/migrations/0107_v370_job_template_organization_field.py @@ -5,18 +5,26 @@ import awx.main.fields from django.db import migrations, models import django.db.models.deletion -from awx.main.migrations._rbac import rebuild_role_parentage, migrate_ujt_organization, migrate_ujt_organization_backward +from awx.main.migrations._rbac import ( + rebuild_role_parentage, rebuild_role_hierarchy, + migrate_ujt_organization, migrate_ujt_organization_backward, + restore_inventory_admins, restore_inventory_admins_backward +) + + +def rebuild_jt_parents(apps, schema_editor): + rebuild_role_parentage(apps, schema_editor, models=('jobtemplate',)) class Migration(migrations.Migration): dependencies = [ - ('main', '0084_v360_token_description'), + ('main', '0106_v370_remove_inventory_groups_with_active_failures'), ] operations = [ # backwards parents and ancestors caching - migrations.RunPython(migrations.RunPython.noop, rebuild_role_parentage), + migrations.RunPython(migrations.RunPython.noop, rebuild_jt_parents), # add new organization field for JT and all other unified jobs migrations.AddField( model_name='unifiedjob', @@ -67,6 +75,7 @@ class Migration(migrations.Migration): field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'], related_name='+', to='main.Role'), ), # Re-compute the role parents and ancestors caching - # this may be a no-op because field post_save hooks from migrate_jt_organization - migrations.RunPython(rebuild_role_parentage, migrations.RunPython.noop), + migrations.RunPython(rebuild_jt_parents, migrations.RunPython.noop), + # for all permissions that will be removed, make them explicit + migrations.RunPython(restore_inventory_admins, restore_inventory_admins_backward), ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 0b052c2350..4a1798176c 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -1,7 +1,7 @@ import logging from time import time -from django.db.models import Subquery, OuterRef +from django.db.models import Subquery, OuterRef, F from awx.main.fields import update_role_parentage_for_instance from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding @@ -115,7 +115,7 @@ def _migrate_unified_organization(apps, unified_cls_name, backward=False): if backward and UNIFIED_ORG_LOOKUPS.get(cls_name, 'not-found') is not None: logger.debug('Not reverse migrating {}, existing data should remain valid'.format(cls_name)) continue - logger.debug('Migrating {} to new organization field'.format(cls_name)) + logger.debug('{}Migrating {} to new organization field'.format('Reverse ' if backward else '', cls_name)) sub_qs = implicit_org_subquery(UnifiedClass, cls, backward=backward) if sub_qs is None: @@ -129,7 +129,7 @@ def _migrate_unified_organization(apps, unified_cls_name, backward=False): r = UnifiedClass.objects.order_by().filter(polymorphic_ctype=this_ct).update(tmp_organization=sub_qs) if r: logger.info('Organization migration on {} affected {} rows.'.format(cls_name, r)) - logger.info('Unified organization migration completed in %f seconds' % (time() - start)) + logger.info('Unified organization migration completed in {:.4f} seconds'.format(time() - start)) def migrate_ujt_organization(apps, schema_editor): @@ -144,6 +144,74 @@ def migrate_ujt_organization_backward(apps, schema_editor): _migrate_unified_organization(apps, 'UnifiedJob', backward=True) +def _restore_inventory_admins(apps, schema_editor, backward=False): + """With the JT.organization changes, admins of organizations connected to + job templates via inventory will have their permissions demoted. + This maintains current permissions over the migration by granting the + permissions they used to have explicitly on the JT itself. + """ + start = time() + JobTemplate = apps.get_model('main', 'JobTemplate') + User = apps.get_model('auth', 'User') + changed_ct = 0 + jt_qs = JobTemplate.objects.filter(inventory__isnull=False) + jt_qs = jt_qs.exclude(inventory__organization=F('project__organization')) + jt_qs = jt_qs.only('id', 'admin_role_id', 'execute_role_id', 'inventory_id') + for jt in jt_qs.iterator(): + org = jt.inventory.organization + for role_name in ('admin_role', 'execute_role'): + role_id = getattr(jt, '{}_id'.format(role_name)) + + user_qs = User.objects + if not backward: + # In this specific case, the name for the org role and JT roles were the same + org_role_id = getattr(org, '{}_id'.format(role_name)) + user_qs = user_qs.filter(roles=org_role_id) + # bizarre migration behavior - ancestors / descendents of + # migration version of Role model is reversed, using current model briefly + ancestor_ids = list( + Role.objects.filter(descendents=role_id).values_list('id', flat=True) + ) + # same as Role.__contains__, filter for "user in jt.admin_role" + user_qs = user_qs.exclude(roles__in=ancestor_ids) + else: + # use the database to filter intersection of users without access + # to the JT role and either organization role + user_qs = user_qs.filter(roles__in=[org.admin_role_id, org.execute_role_id]) + # in reverse, intersection of users who have both + user_qs = user_qs.filter(roles=role_id) + + user_ids = list(user_qs.values_list('id', flat=True)) + if not user_ids: + continue + + role = getattr(jt, role_name) + logger.debug('{} {} on jt {} for users {} via inventory.organization {}'.format( + 'Removing' if backward else 'Setting', + role_name, jt.pk, user_ids, org.pk + )) + if not backward: + # in reverse, explit role becomes redundant + role.members.add(*user_ids) + else: + role.members.remove(*user_ids) + changed_ct += len(user_ids) + + if changed_ct: + logger.info('{} explicit JT permission for {} users in {:.4f} seconds'.format( + 'Removed' if backward else 'Added', + changed_ct, time() - start + )) + + +def restore_inventory_admins(apps, schema_editor): + _restore_inventory_admins(apps, schema_editor) + + +def restore_inventory_admins_backward(apps, schema_editor): + _restore_inventory_admins(apps, schema_editor, backward=True) + + def rebuild_role_hierarchy(apps, schema_editor): ''' This should be called in any migration when ownerships are changed. @@ -164,7 +232,7 @@ def rebuild_role_hierarchy(apps, schema_editor): logger.info('Done.') -def rebuild_role_parentage(apps, schema_editor): +def rebuild_role_parentage(apps, schema_editor, models=None): ''' This should be called in any migration when any parent_role entry is modified so that the cached parent fields will be updated. Ex: @@ -177,13 +245,23 @@ def rebuild_role_parentage(apps, schema_editor): ''' start = time() seen_models = set() - updated_ct = 0 model_ct = 0 noop_ct = 0 - Role = apps.get_model('main', "Role") - for role in Role.objects.iterator(): + ContentType = apps.get_model('contenttypes', "ContentType") + additions = set() + removals = set() + + role_qs = Role.objects + if models: + # update_role_parentage_for_instance is expensive + # if the models have been downselected, ignore those which are not in the list + ct_ids = list(ContentType.objects.filter( + model__in=[name.lower() for name in models] + ).values_list('id', flat=True)) + role_qs = role_qs.filter(content_type__in=ct_ids) + + for role in role_qs.iterator(): if not role.object_id: - noop_ct += 1 continue model_tuple = (role.content_type_id, role.object_id) if model_tuple in seen_models: @@ -198,19 +276,26 @@ def rebuild_role_parentage(apps, schema_editor): ct_model = apps.get_model(app, ct.model) content_object = ct_model.objects.get(pk=role.object_id) - updated = update_role_parentage_for_instance(content_object) - if updated: + parents_added, parents_removed = update_role_parentage_for_instance(content_object) + additions.update(parents_added) + removals.update(parents_removed) + if parents_added: model_ct += 1 - logger.debug('Updated parents of {} roles of {}'.format(updated, content_object)) + logger.debug('Added to parents of roles {} of {}'.format(parents_added, content_object)) + if parents_removed: + model_ct += 1 + logger.debug('Removed from parents of roles {} of {}'.format(parents_removed, content_object)) else: noop_ct += 1 - updated_ct += updated - logger.debug('No changes to role parents for {} roles'.format(noop_ct)) - if updated_ct: - logger.info('Updated parentage for {} roles of {} resources'.format(updated_ct, model_ct)) + logger.debug('No changes to role parents for {} resources'.format(noop_ct)) + logger.debug('Added parents to {} roles'.format(len(additions))) + logger.debug('Removed parents from {} roles'.format(len(removals))) + if model_ct: + logger.info('Updated implicit parents of {} resources'.format(model_ct)) logger.info('Rebuild parentage completed in %f seconds' % (time() - start)) - if updated_ct: - rebuild_role_hierarchy(apps, schema_editor) + # this is ran because the ordinary signals for + # Role.parents.add and Role.parents.remove not called in migration + Role.rebuild_role_ancestor_list(list(additions), list(removals)) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 829720afa1..e67478a8e8 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -323,6 +323,41 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour else: return self.job_slice_count + def save(self, *args, **kwargs): + update_fields = kwargs.get('update_fields', []) + # if project is deleted for some reason, then keep the old organization + # to retain ownership for organization admins + if self.project and self.project.organization_id != self.organization_id: + self.organization_id = self.project.organization_id + if 'organization' not in update_fields and 'organization_id' not in update_fields: + update_fields.append('organization_id') + return super(JobTemplate, self).save(*args, **kwargs) + + def validate_unique(self, exclude=None): + """Custom over-ride for JT specifically + because organization is inferred from project after full_clean is finished + thus the organization field is not yet set when validation happens + """ + errors = [] + for ut in JobTemplate.SOFT_UNIQUE_TOGETHER: + kwargs = {'name': self.name} + if self.project: + kwargs['organization'] = self.project.organization_id + else: + kwargs['organization'] = None + qs = JobTemplate.objects.filter(**kwargs) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + errors.append( + '%s with this (%s) combination already exists.' % ( + JobTemplate.__name__, + ', '.join(set(ut) - {'polymorphic_ctype'}) + ) + ) + if errors: + raise ValidationError(errors) + def create_unified_job(self, **kwargs): prevent_slicing = kwargs.pop('_prevent_slicing', False) slice_ct = self.get_effective_slice_ct(kwargs) diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index c9bf9762dd..aad612ebd8 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -325,6 +325,13 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn ['name', 'description', 'organization'] ) + def clean_organization(self): + if self.pk: + old_org_id = getattr(self, '_prior_values_store', {}).get('organization_id', None) + if self.organization_id != old_org_id and self.jobtemplates.exists(): + raise ValidationError({'organization': _('Organization cannot be changed when in use by job templates.')}) + return self.organization + def save(self, *args, **kwargs): new_instance = not bool(self.pk) pre_save_vals = getattr(self, '_prior_values_store', {}) diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 1c2c6ffeed..253eb7b57f 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -102,7 +102,7 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio ordering = ('name',) # unique_together here is intentionally commented out. Please make sure sub-classes of this model # contain at least this uniqueness restriction: SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] - #unique_together = [('polymorphic_ctype', 'name')] + #unique_together = [('polymorphic_ctype', 'name', 'organization')] old_pk = models.PositiveIntegerField( null=True, diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index df4772a20d..87888b9f92 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -376,7 +376,7 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] FIELDS_TO_PRESERVE_AT_COPY = [ - 'labels', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec' + 'labels', 'organization', 'instance_groups', 'workflow_job_template_nodes', 'credentials', 'survey_spec' ] class Meta: diff --git a/awx/main/signals.py b/awx/main/signals.py index 27a6426eba..64a35c1f1d 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -157,17 +157,26 @@ def cleanup_detached_labels_on_deleted_parent(sender, instance, **kwargs): 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 + job templates that use an Inventory 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): + if sender is not Inventory: raise ValueError('This signal callback is only intended for use with Project or Inventory') + update_fields = kwargs.get('update_fields', None) + if ((update_fields and not ('organization' in update_fields or 'organization_id' in update_fields)) or + kwargs.get('created', False)): + return + if instance._prior_values_store.get('organization_id') != instance.organization_id: jtq = JobTemplate.objects.filter(**{sender.__name__.lower(): instance}) for jt in jtq: - update_role_parentage_for_instance(jt) + parents_added, parents_removed = update_role_parentage_for_instance(jt) + if parents_added or parents_removed: + logger.info('Permissions on JT {} changed due to inventory {} organization change from {} to {}.'.format( + jt.pk, instance.pk, instance._prior_values_store.get('organization_id'), instance.organization_id + )) def connect_computed_field_signals(): diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index 0952738174..2f8cbe6934 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -159,8 +159,7 @@ def mk_job_template(name, job_type='run', extra_vars = json.dumps(extra_vars) jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars, - webhook_service=webhook_service, playbook='helloworld.yml', - organization=organization) + webhook_service=webhook_service, playbook='helloworld.yml') jt.inventory = inventory if jt.inventory is None: diff --git a/awx/main/tests/factories/tower.py b/awx/main/tests/factories/tower.py index dd412571e1..bfa7f9fc1b 100644 --- a/awx/main/tests/factories/tower.py +++ b/awx/main/tests/factories/tower.py @@ -255,7 +255,7 @@ def create_job_template(name, roles=None, persisted=True, webhook_service='', ** jt = mk_job_template(name, project=proj, inventory=inv, credential=cred, network_credential=net_cred, cloud_credential=cloud_cred, job_type=job_type, spec=spec, extra_vars=extra_vars, - persisted=persisted, webhook_service=webhook_service, organization=org) + persisted=persisted, webhook_service=webhook_service) if 'jobs' in kwargs: for i in kwargs['jobs']: diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index 683f62dc43..db32c0dd7f 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -53,7 +53,7 @@ def test_job_relaunch_permission_denied_response( # Job has prompted extra_credential, launch denied w/ message job.launch_config.credentials.add(net_credential) r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403) - assert 'launched with prompted fields which you do not have access to' in r.data['detail'] + assert 'launched with prompted fields you do not have access to' in r.data['detail'] @pytest.mark.django_db @@ -73,7 +73,6 @@ def test_job_relaunch_prompts_not_accepted_response( # Job has prompted extra_credential, launch denied w/ message job.launch_config.credentials.add(net_credential) r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403) - assert 'no longer accepts the prompts provided for this job' in r.data['detail'] @pytest.mark.django_db @@ -220,8 +219,7 @@ def test_block_unprocessed_events(delete, admin_user, mocker): def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user): job_template = JobTemplate.objects.create( project=project, - playbook='helloworld.yml', - organization=organization + playbook='helloworld.yml' ) time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500") Job.objects.create( @@ -230,7 +228,7 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete, finished=time_of_finish, job_template=job_template, project=project, - organization=organization + organization=project.organization ) view = RelatedJobsPreventDeleteMixin() time_of_request = time_of_finish + relativedelta(seconds=2) diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index 2e56d8253b..6ad0271042 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -6,7 +6,7 @@ import pytest # AWX from awx.api.serializers import JobTemplateSerializer from awx.api.versioning import reverse -from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization +from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization, Project from awx.main.migrations import _save_password_keys as save_password_keys # Django @@ -32,50 +32,16 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje inventory.use_role.members.add(alice) project.organization.job_template_admin_role.members.add(alice) - r = post(reverse('api:job_template_list'), { - 'name': 'Some name', - 'project': project.id, - 'inventory': inventory.id, - 'playbook': 'helloworld.yml', - 'organization': project.organization_id - }, alice) - assert r.status_code == expect - - -@pytest.mark.django_db -def test_creation_uniqueness_rules(post, project, inventory, admin_user): - orgA = Organization.objects.create(name='orga') - orgB = Organization.objects.create(name='orgb') - create_data = { - 'name': 'this_unique_name', - 'project': project.pk, - 'inventory': inventory.pk, - 'playbook': 'helloworld.yml', - 'organization': orgA.pk - } post( url=reverse('api:job_template_list'), - data=create_data, - user=admin_user, - expect=201 - ) - r = post( - url=reverse('api:job_template_list'), - data=create_data, - user=admin_user, - expect=400 - ) - msg = str(r.data['__all__'][0]) - assert "JobTemplate with this (" in msg - assert ") combination already exists" in msg - - # can create JT with same name, only if it is in different org - create_data['organization'] = orgB.pk - post( - url=reverse('api:job_template_list'), - data=create_data, - user=admin_user, - expect=201 + data={ + 'name': 'Some name', + 'project': project.id, + 'inventory': inventory.id, + 'playbook': 'helloworld.yml' + }, + user=alice, + expect=expect ) @@ -162,14 +128,18 @@ def test_create_with_forks_exceeding_maximum_xfail(alice, post, project, invento project.use_role.members.add(alice) inventory.use_role.members.add(alice) settings.MAX_FORKS = 10 - response = post(reverse('api:job_template_list'), { - 'name': 'Some name', - 'project': project.id, - 'inventory': inventory.id, - 'playbook': 'helloworld.yml', - 'forks': 11, - }, alice) - assert response.status_code == 400 + response = post( + url=reverse('api:job_template_list'), + data={ + 'name': 'Some name', + 'project': project.id, + 'inventory': inventory.id, + 'playbook': 'helloworld.yml', + 'forks': 11, + }, + user=alice, + expect=400 + ) assert 'Maximum number of forks (10) exceeded' in str(response.data) @@ -549,6 +519,72 @@ def test_job_template_unset_custom_virtualenv(get, patch, organization_factory, assert resp.data['custom_virtualenv'] is None +@pytest.mark.django_db +def test_jt_organization_follows_project(post, patch, admin_user): + org1 = Organization.objects.create(name='foo1') + org2 = Organization.objects.create(name='foo2') + project_common = dict(scm_type='git', playbook_files=['helloworld.yml']) + project1 = Project.objects.create(name='proj1', organization=org1, **project_common) + project2 = Project.objects.create(name='proj2', organization=org2, **project_common) + r = post( + url=reverse('api:job_template_list'), + data={ + "name": "fooo", + "ask_inventory_on_launch": True, + "project": project1.pk, + "playbook": "helloworld.yml" + }, + user=admin_user, + expect=201 + ) + data = r.data + assert data['organization'] == project1.organization_id + data['project'] = project2.id + jt = JobTemplate.objects.get(pk=data['id']) + r = patch( + url=jt.get_absolute_url(), + data=data, + user=admin_user, + expect=200 + ) + assert r.data['organization'] == project2.organization_id + + +@pytest.mark.django_db +def test_jt_organization_field_is_read_only(patch, post, project, admin_user): + org = project.organization + jt = JobTemplate.objects.create( + name='foo_jt', + ask_inventory_on_launch=True, + project=project, playbook='helloworld.yml' + ) + org2 = Organization.objects.create(name='foo2') + r = patch( + url=jt.get_absolute_url(), + data={'organization': org2.id}, + user=admin_user, + expect=200 + ) + assert r.data['organization'] == org.id + assert JobTemplate.objects.get(pk=jt.pk).organization == org + + # similar test, but on creation + r = post( + url=reverse('api:job_template_list'), + data={ + 'name': 'foobar', + 'project': project.id, + 'organization': org2.id, + 'ask_inventory_on_launch': True, + 'playbook': 'helloworld.yml' + }, + user=admin_user, + expect=201 + ) + assert r.data['organization'] == org.id + assert JobTemplate.objects.get(pk=r.data['id']).organization == org + + @pytest.mark.django_db def test_callback_disallowed_null_inventory(project): jt = JobTemplate.objects.create( @@ -563,14 +599,13 @@ def test_callback_disallowed_null_inventory(project): @pytest.mark.django_db -def test_job_template_branch_error(project, inventory, organization, post, admin_user): +def test_job_template_branch_error(project, inventory, post, admin_user): r = post( url=reverse('api:job_template_list'), data={ "name": "fooo", "inventory": inventory.pk, "project": project.pk, - "organization": organization.pk, "playbook": "helloworld.yml", "scm_branch": "foobar" }, @@ -581,14 +616,13 @@ def test_job_template_branch_error(project, inventory, organization, post, admin @pytest.mark.django_db -def test_job_template_branch_prompt_error(project, inventory, post, organization, admin_user): +def test_job_template_branch_prompt_error(project, inventory, post, admin_user): r = post( url=reverse('api:job_template_list'), data={ "name": "fooo", "inventory": inventory.pk, "project": project.pk, - "organization": organization.pk, "playbook": "helloworld.yml", "ask_scm_branch_on_launch": True }, diff --git a/awx/main/tests/functional/api/test_rbac_displays.py b/awx/main/tests/functional/api/test_rbac_displays.py index c3dd65d9c4..4180647d44 100644 --- a/awx/main/tests/functional/api/test_rbac_displays.py +++ b/awx/main/tests/functional/api/test_rbac_displays.py @@ -61,7 +61,7 @@ class TestJobTemplateCopyEdit: def jt_copy_edit(self, job_template_factory, project): objects = job_template_factory( 'copy-edit-job-template', - project=project, organization=project.organization) + project=project) return objects.job_template def fake_context(self, user): @@ -129,8 +129,9 @@ class TestJobTemplateCopyEdit: # random user given JT and project admin abilities jt_copy_edit.admin_role.members.add(rando) + jt_copy_edit.save() jt_copy_edit.project.admin_role.members.add(rando) - jt_copy_edit.organization.job_template_admin_role.members.add(rando) + jt_copy_edit.project.save() serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando)) response = serializer.to_representation(jt_copy_edit) diff --git a/awx/main/tests/functional/api/test_unified_job_template.py b/awx/main/tests/functional/api/test_unified_job_template.py index 1febd2f50e..c2df65b49f 100644 --- a/awx/main/tests/functional/api/test_unified_job_template.py +++ b/awx/main/tests/functional/api/test_unified_job_template.py @@ -39,29 +39,6 @@ class TestUnifiedOrganization: data['ask_inventory_on_launch'] = True return data - def test_organization_required_on_creation(self, model, admin_user, post): - cls = getattr(models, model) - data = self.data_for_model(model) - r = post( - url=reverse('api:{}_list'.format(get_type_for_model(cls))), - data=data, - user=admin_user, - expect=400 - ) - assert 'organization' in r.data - assert 'required for new object' in r.data['organization'][0] - # Surprising behavior - not providing the key can often give - # different behavior from giving it as null on create - data.pop('organization') - r = post( - url=reverse('api:{}_list'.format(get_type_for_model(cls))), - data=data, - user=admin_user, - expect=400 - ) - assert 'organization' in r.data - assert 'required' in r.data['organization'][0] - def test_organization_blank_on_edit_of_orphan(self, model, admin_user, patch): cls = getattr(models, model) data = self.data_for_model(model, orm_style=True) @@ -107,15 +84,3 @@ class TestUnifiedOrganization: ) obj.refresh_from_db() assert obj.name == 'foooooo' - - def test_organization_cannot_change_to_null(self, model, admin_user, patch, organization): - cls = getattr(models, model) - data = self.data_for_model(model, orm_style=True) - data['organization'] = organization - obj = cls.objects.create(**data) - patch( - url=obj.get_absolute_url(), - data={'organization': None}, - user=admin_user, - expect=400 - ) diff --git a/awx/main/tests/functional/conftest.py b/awx/main/tests/functional/conftest.py index cae55c8562..54149a6419 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -75,26 +75,24 @@ def user(): @pytest.fixture -def check_jobtemplate(project, inventory, credential, organization): +def check_jobtemplate(project, inventory, credential): jt = JobTemplate.objects.create( job_type='check', project=project, inventory=inventory, - name='check-job-template', - organization=organization + name='check-job-template' ) jt.credentials.add(credential) return jt @pytest.fixture -def deploy_jobtemplate(project, inventory, credential, organization): +def deploy_jobtemplate(project, inventory, credential): jt = JobTemplate.objects.create( job_type='run', project=project, inventory=inventory, - name='deploy-job-template', - organization=organization + name='deploy-job-template' ) jt.credentials.add(credential) return jt diff --git a/awx/main/tests/functional/models/test_job.py b/awx/main/tests/functional/models/test_job.py index b097f85548..ac8912506f 100644 --- a/awx/main/tests/functional/models/test_job.py +++ b/awx/main/tests/functional/models/test_job.py @@ -1,6 +1,9 @@ import pytest -from awx.main.models import JobTemplate, Job, JobHostSummary, WorkflowJob, Inventory +from awx.main.models import ( + JobTemplate, Job, JobHostSummary, + WorkflowJob, Inventory, Project, Organization +) @pytest.mark.django_db @@ -79,6 +82,22 @@ def test_job_host_summary_representation(host): assert 'N/A changed=1 dark=2 failures=3 ignored=4 ok=5 processed=6 rescued=7 skipped=8' == str(jhs) +@pytest.mark.django_db +def test_jt_organization_follows_project(): + org1 = Organization.objects.create(name='foo1') + org2 = Organization.objects.create(name='foo2') + project1 = Project.objects.create(name='proj1', organization=org1) + project2 = Project.objects.create(name='proj2', organization=org2) + jt = JobTemplate.objects.create( + name='foo', playbook='helloworld.yml', + project=project1 + ) + assert jt.organization == org1 + jt.project = project2 + jt.save() + assert JobTemplate.objects.get(pk=jt.id).organization == org2 + + @pytest.mark.django_db class TestSlicingModels: diff --git a/awx/main/tests/functional/models/test_unified_job.py b/awx/main/tests/functional/models/test_unified_job.py index 90fc4ce37e..7b5f6d432b 100644 --- a/awx/main/tests/functional/models/test_unified_job.py +++ b/awx/main/tests/functional/models/test_unified_job.py @@ -13,6 +13,7 @@ from awx.main.models import ( WorkflowApprovalTemplate, Project, WorkflowJob, Schedule, Credential ) +from awx.api.versioning import reverse @pytest.mark.django_db @@ -26,6 +27,29 @@ def test_subclass_types(rando): ]) +@pytest.mark.django_db +def test_soft_unique_together(post, project, admin_user): + """This tests that SOFT_UNIQUE_TOGETHER restrictions are applied correctly. + """ + jt1 = JobTemplate.objects.create( + name='foo_jt', + project=project + ) + assert jt1.organization == project.organization + r = post( + url=reverse('api:job_template_list'), + data=dict( + name='foo_jt', # same as first + project=project.id, + ask_inventory_on_launch=True, + playbook='helloworld.yml' + ), + user=admin_user, + expect=400 + ) + assert 'combination already exists' in str(r.data) + + @pytest.mark.django_db class TestCreateUnifiedJob: ''' diff --git a/awx/main/tests/functional/test_copy.py b/awx/main/tests/functional/test_copy.py index 747f7754c6..7be582d6c8 100644 --- a/awx/main/tests/functional/test_copy.py +++ b/awx/main/tests/functional/test_copy.py @@ -11,11 +11,10 @@ from awx.main.tasks import deep_copy_model_obj @pytest.mark.django_db -def test_job_template_copy(post, get, project, inventory, organization, machine_credential, vault_credential, +def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential, credential, alice, job_template_with_survey_passwords, admin): job_template_with_survey_passwords.project = project job_template_with_survey_passwords.inventory = inventory - job_template_with_survey_passwords.organization = organization job_template_with_survey_passwords.save() job_template_with_survey_passwords.credentials.add(credential) job_template_with_survey_passwords.credentials.add(machine_credential) @@ -23,7 +22,6 @@ def test_job_template_copy(post, get, project, inventory, organization, machine_ job_template_with_survey_passwords.admin_role.members.add(alice) project.admin_role.members.add(alice) inventory.admin_role.members.add(alice) - organization.job_template_admin_role.members.add(alice) assert get( reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}), alice, expect=200 diff --git a/awx/main/tests/functional/test_instances.py b/awx/main/tests/functional/test_instances.py index a2a0a646ec..67331a6973 100644 --- a/awx/main/tests/functional/test_instances.py +++ b/awx/main/tests/functional/test_instances.py @@ -1,7 +1,7 @@ import pytest from unittest import mock -from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate, Organization +from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate from awx.main.models.ha import Instance, InstanceGroup from awx.main.tasks import apply_cluster_membership_policies from awx.api.versioning import reverse @@ -253,7 +253,7 @@ def test_inherited_instance_group_membership(instance_group_factory, default_ins j.inventory = inventory ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()]) ig_inv = instance_group_factory("basicB", [default_instance_group.instances.first()]) - j.organization.instance_groups.add(ig_org) + j.project.organization.instance_groups.add(ig_org) j.inventory.instance_groups.add(ig_inv) assert ig_org in j.preferred_instance_groups assert ig_inv in j.preferred_instance_groups @@ -320,14 +320,13 @@ class TestInstanceGroupOrdering: assert pu.preferred_instance_groups == [ig_tmp, ig_org] def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group): - org = Organization.objects.create(name='foo') - jt = JobTemplate.objects.create(inventory=inventory, project=project, organization=org) - job = Job.objects.create(inventory=inventory, job_template=jt, project=project, organization=org) + jt = JobTemplate.objects.create(inventory=inventory, project=project) + job = jt.create_unified_job() assert job.preferred_instance_groups == [default_instance_group] ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()]) ig_inv = instance_group_factory("InvIstGrp", [default_instance_group.instances.first()]) ig_tmp = instance_group_factory("TmpIstGrp", [default_instance_group.instances.first()]) - jt.organization.instance_groups.add(ig_org) + project.organization.instance_groups.add(ig_org) inventory.instance_groups.add(ig_inv) assert job.preferred_instance_groups == [ig_inv, ig_org] job.job_template.instance_groups.add(ig_tmp) diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index 16ff68e0ef..1c096e74c7 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -1,5 +1,7 @@ import pytest +from rest_framework.exceptions import PermissionDenied + from awx.main.access import ( JobAccess, JobLaunchConfigAccess, @@ -171,9 +173,11 @@ class TestJobRelaunchAccess: machine_credential.use_role.members.add(u) access = JobAccess(u) - assert access.can_start(job_with_links, validate_license=False) == can_start, ( - "Inventory access: {}\nCredential access: {}\n Expected access: {}".format(inv_access, cred_access, can_start) - ) + if can_start: + assert access.can_start(job_with_links, validate_license=False) + else: + with pytest.raises(PermissionDenied): + access.can_start(job_with_links, validate_license=False) def test_job_relaunch_credential_access( self, inventory, project, credential, net_credential): @@ -188,7 +192,8 @@ class TestJobRelaunchAccess: # Job has prompted net credential, launch denied w/ message job = jt.create_unified_job(credentials=[net_credential]) - assert not jt_user.can_access(Job, 'start', job, validate_license=False) + with pytest.raises(PermissionDenied): + jt_user.can_access(Job, 'start', job, validate_license=False) def test_prompted_credential_relaunch_denied( self, inventory, project, net_credential, rando): @@ -201,7 +206,8 @@ class TestJobRelaunchAccess: # Job has prompted net credential, rando lacks permission to use it job = jt.create_unified_job(credentials=[net_credential]) - assert not rando.can_access(Job, 'start', job, validate_license=False) + with pytest.raises(PermissionDenied): + rando.can_access(Job, 'start', job, validate_license=False) def test_prompted_credential_relaunch_allowed( self, inventory, project, net_credential, rando): diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index 3c6d74a0a8..6fa34cc874 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -1,5 +1,7 @@ import pytest +from rest_framework.exceptions import PermissionDenied + from awx.main.models.inventory import Inventory from awx.main.models.credential import Credential from awx.main.models.jobs import JobTemplate, Job @@ -121,6 +123,7 @@ class TestJobRelaunchAccess: def test_orphan_relaunch_via_organization(self, job_no_prompts, rando, organization): "JT for job has been deleted, relevant organization roles will allow management" + assert job_no_prompts.organization == organization organization.execute_role.members.add(rando) job_no_prompts.job_template.delete() job_no_prompts.job_template = None # Django should do this for us, but it does not @@ -129,7 +132,9 @@ class TestJobRelaunchAccess: def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando): "Has JT execute_role but no use_role on inventory & credential - deny relaunch" job_with_prompts.job_template.execute_role.members.add(rando) - assert not rando.can_access(Job, 'start', job_with_prompts) + with pytest.raises(PermissionDenied) as exc: + rando.can_access(Job, 'start', job_with_prompts) + assert 'Job was launched with prompted fields you do not have access to' in str(exc) def test_can_relaunch_with_prompted_fields_access(self, job_with_prompts, rando): "Has use_role on the prompted inventory & credential - allow relaunch" @@ -148,11 +153,15 @@ class TestJobRelaunchAccess: jt.ask_limit_on_launch = False jt.save() jt.execute_role.members.add(rando) - assert not rando.can_access(Job, 'start', job_with_prompts) + with pytest.raises(PermissionDenied): + rando.can_access(Job, 'start', job_with_prompts) def test_can_relaunch_if_limit_was_prompt(self, job_with_prompts, rando): "Job state differs from JT, but only on prompted fields - allow relaunch" job_with_prompts.job_template.execute_role.members.add(rando) job_with_prompts.limit = 'webservers' job_with_prompts.save() - assert not rando.can_access(Job, 'start', job_with_prompts) + job_with_prompts.inventory.use_role.members.add(rando) + for cred in job_with_prompts.credentials.all(): + cred.use_role.members.add(rando) + assert rando.can_access(Job, 'start', job_with_prompts) diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index 29910db3ae..a4fbcbaf36 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -8,8 +8,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 +from awx.main.models import Project, Organization, Inventory, Schedule, User @mock.patch.object(BaseAccess, 'check_license', return_value=None) @@ -126,11 +125,11 @@ def test_job_template_extra_credentials_prompts_access( ) jt.credentials.add(machine_credential) jt.execute_role.members.add(rando) - r = post( + post( reverse('api:job_template_launch', kwargs={'pk': jt.id}), - {'credentials': [machine_credential.pk, vault_credential.pk]}, rando + {'credentials': [machine_credential.pk, vault_credential.pk]}, rando, + expect=403 ) - assert r.status_code == 403 @pytest.mark.django_db @@ -188,16 +187,12 @@ def test_job_template_creator_access(project, organization, rando, post): @pytest.mark.django_db @pytest.mark.job_permissions -@pytest.mark.parametrize('lacking', ['project', 'inventory', 'organization']) +@pytest.mark.parametrize('lacking', ['project', 'inventory']) def test_job_template_insufficient_creator_permissions(lacking, project, inventory, organization, rando, post): if lacking != 'project': project.use_role.members.add(rando) else: project.read_role.members.add(rando) - if lacking != 'organization': - organization.job_template_admin_role.members.add(rando) - else: - organization.member_role.members.add(rando) if lacking != 'inventory': inventory.use_role.members.add(rando) else: @@ -206,7 +201,6 @@ def test_job_template_insufficient_creator_permissions(lacking, project, invento name='newly-created-jt', inventory=inventory.id, project=project.pk, - organization=organization.id, playbook='helloworld.yml' ), user=rando, expect=403) @@ -278,25 +272,104 @@ class TestJobTemplateSchedules: @pytest.mark.django_db -def test_jt_org_ownership_change(user, jt_linked): - admin1 = user('admin1') - org1 = jt_linked.organization - org1.admin_role.members.add(admin1) - a1_access = JobTemplateAccess(admin1) +class TestProjectOrganization: + """Tests stories related to management of JT organization via its project + which have some bearing on RBAC integrity + """ - assert a1_access.can_read(jt_linked) + def test_new_project_org_change(self, project, patch, admin_user): + org2 = Organization.objects.create(name='bar') + patch( + url=project.get_absolute_url(), + data={'organization': org2.id}, + user=admin_user, + expect=200 + ) + assert Project.objects.get(pk=project.id).organization_id == org2.id + def test_jt_org_cannot_change(self, project, post, patch, admin_user): + post( + url=reverse('api:job_template_list'), + data={ + 'name': 'foo_template', + 'project': project.id, + 'playbook': 'helloworld.yml', + 'ask_inventory_on_launch': True + }, + user=admin_user, + expect=201 + ) + org2 = Organization.objects.create(name='bar') + r = patch( + url=project.get_absolute_url(), + data={'organization': org2.id}, + user=admin_user, + expect=400 + ) + assert 'Organization cannot be changed' in str(r.data) - admin2 = user('admin2') - org2 = Organization.objects.create(name='mrroboto', description='domo') - org2.admin_role.members.add(admin2) - a2_access = JobTemplateAccess(admin2) + def test_orphan_JT_adoption(self, project, patch, admin_user, org_admin): + jt = JobTemplate.objects.create( + name='bar', + ask_inventory_on_launch=True, + playbook='helloworld.yml' + ) + assert org_admin not in jt.admin_role + patch( + url=jt.get_absolute_url(), + data={'project': project.id}, + user=admin_user, + expect=200 + ) + assert org_admin in jt.admin_role - assert not a2_access.can_read(jt_linked) + def test_inventory_read_transfer_direct(self, patch): + orgs = [] + invs = [] + admins = [] + for i in range(2): + org = Organization.objects.create(name='org{}'.format(i)) + org_admin = User.objects.create(username='user{}'.format(i)) + inv = Inventory.objects.create( + organization=org, + name='inv{}'.format(i) + ) + org.auditor_role.members.add(org_admin) + orgs.append(org) + admins.append(org_admin) + invs.append(inv) - jt_linked.organization = org2 - jt_linked.save() + jt = JobTemplate.objects.create(name='foo', inventory=invs[0]) + assert admins[0] in jt.read_role + assert admins[1] not in jt.read_role - assert a2_access.can_read(jt_linked) - assert not a1_access.can_read(jt_linked) + jt.inventory = invs[1] + jt.save(update_fields=['inventory']) + assert admins[0] not in jt.read_role + assert admins[1] in jt.read_role + + def test_inventory_read_transfer_indirect(self, patch): + orgs = [] + admins = [] + for i in range(2): + org = Organization.objects.create(name='org{}'.format(i)) + org_admin = User.objects.create(username='user{}'.format(i)) + org.auditor_role.members.add(org_admin) + + orgs.append(org) + admins.append(org_admin) + + inv = Inventory.objects.create( + organization=orgs[0], + name='inv{}'.format(i) + ) + + jt = JobTemplate.objects.create(name='foo', inventory=inv) + assert admins[0] in jt.read_role + assert admins[1] not in jt.read_role + + inv.organization = orgs[1] + inv.save(update_fields=['organization']) + assert admins[0] not in jt.read_role + assert admins[1] in jt.read_role diff --git a/awx/main/tests/functional/test_rbac_migration.py b/awx/main/tests/functional/test_rbac_migration.py index 19693ed5e7..48a757f5ae 100644 --- a/awx/main/tests/functional/test_rbac_migration.py +++ b/awx/main/tests/functional/test_rbac_migration.py @@ -1,11 +1,14 @@ import pytest +from django.apps import apps + from awx.main.migrations import _rbac as rbac from awx.main.models import ( UnifiedJobTemplate, InventorySource, Inventory, JobTemplate, Project, - Organization + Organization, + User ) @@ -62,3 +65,37 @@ def test_implied_organization_subquery_job_template(): assert jt.test_field is None else: assert jt.test_field == jt.project.organization_id + + +@pytest.mark.django_db +def test_give_explicit_inventory_permission(): + dual_admin = User.objects.create(username='alice') + inv_admin = User.objects.create(username='bob') + inv_org = Organization.objects.create(name='inv-org') + proj_org = Organization.objects.create(name='proj-org') + + inv_org.admin_role.members.add(inv_admin, dual_admin) + proj_org.admin_role.members.add(dual_admin) + + proj = Project.objects.create( + name="test-proj", + organization=proj_org + ) + inv = Inventory.objects.create( + name='test-inv', + organization=inv_org + ) + + jt = JobTemplate.objects.create( + name='foo', + project=proj, + inventory=inv + ) + + assert dual_admin in jt.admin_role + + rbac.restore_inventory_admins(apps, None) + + assert inv_admin in jt.admin_role.members.all() + assert dual_admin not in jt.admin_role.members.all() + assert dual_admin in jt.admin_role diff --git a/awx/main/tests/unit/models/test_unified_job_unit.py b/awx/main/tests/unit/models/test_unified_job_unit.py index 4442770188..a3f9123f37 100644 --- a/awx/main/tests/unit/models/test_unified_job_unit.py +++ b/awx/main/tests/unit/models/test_unified_job_unit.py @@ -6,6 +6,7 @@ from awx.main.models import ( UnifiedJobTemplate, WorkflowJob, WorkflowJobNode, + WorkflowApprovalTemplate, Job, User, Project, @@ -70,7 +71,9 @@ def test_organization_copy_to_jobs(): All unified job types should infer their organization from their template organization ''' for cls in UnifiedJobTemplate.__subclasses__(): - assert 'organization' in cls._get_unified_job_field_names() + if cls is WorkflowApprovalTemplate: + continue # these do not track organization + assert 'organization' in cls._get_unified_job_field_names(), cls def test_log_representation(): diff --git a/awx/main/utils/named_url_graph.py b/awx/main/utils/named_url_graph.py index f17c503d4c..6a9aeb2b85 100644 --- a/awx/main/utils/named_url_graph.py +++ b/awx/main/utils/named_url_graph.py @@ -315,3 +315,8 @@ def generate_graph(models): settings.NAMED_URL_GRAPH = largest_graph for node in settings.NAMED_URL_GRAPH.values(): node.add_bindings() + + +def reset_counters(): + for node in settings.NAMED_URL_GRAPH.values(): + node.counter = 0 diff --git a/awxkit/awxkit/api/pages/job_templates.py b/awxkit/awxkit/api/pages/job_templates.py index 4060da5f2c..11d46cfbfa 100644 --- a/awxkit/awxkit/api/pages/job_templates.py +++ b/awxkit/awxkit/api/pages/job_templates.py @@ -7,7 +7,7 @@ from awxkit.utils import ( suppress, update_payload, PseudoNamespace) -from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate, Organization +from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter from awxkit.api.resources import resources import awxkit.exceptions as exc @@ -23,7 +23,7 @@ class JobTemplate( HasSurvey, UnifiedJobTemplate): - optional_dependencies = [Organization, Inventory, Credential, Project] + optional_dependencies = [Inventory, Credential, Project] def launch(self, payload={}): """Launch the job_template using related->launch endpoint.""" @@ -129,7 +129,6 @@ class JobTemplate( playbook='ping.yml', credential=Credential, inventory=Inventory, - organization=Organization, project=None, **kwargs): if not project: @@ -149,18 +148,12 @@ class JobTemplate( project = self.ds.project if project else None inventory = self.ds.inventory if inventory else None credential = self.ds.credential if credential else None - # if the created project has an organization, and the parameters - # specified no organization, then borrow the one from the project - if hasattr(project.ds, 'organization') and organization is Organization: - self.ds.organization = project.ds.organization - organization = self.ds.organization payload = self.payload( name=name, description=description, job_type=job_type, playbook=playbook, - organization=organization, credential=credential, inventory=inventory, project=project, @@ -176,12 +169,11 @@ class JobTemplate( playbook='ping.yml', credential=Credential, inventory=Inventory, - organization=Organization, project=None, **kwargs): payload, credential = self.create_payload(name=name, description=description, job_type=job_type, playbook=playbook, credential=credential, inventory=inventory, - project=project, organization=organization, **kwargs) + project=project, **kwargs) ret = self.update_identity( JobTemplates( self.connection).post(payload)) diff --git a/awxkit/awxkit/api/pages/workflow_job_templates.py b/awxkit/awxkit/api/pages/workflow_job_templates.py index b5b169340e..1d67a0a171 100644 --- a/awxkit/awxkit/api/pages/workflow_job_templates.py +++ b/awxkit/awxkit/api/pages/workflow_job_templates.py @@ -12,7 +12,7 @@ from . import page class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate): - dependencies = [Organization] + optional_dependencies = [Organization] def launch(self, payload={}): """Launch using related->launch endpoint.""" @@ -71,14 +71,14 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi return payload - def create_payload(self, name='', description='', organization=Organization, **kwargs): + def create_payload(self, name='', description='', organization=None, **kwargs): self.create_and_update_dependencies(*filter_by_class((organization, Organization))) organization = self.ds.organization if organization else None payload = self.payload(name=name, description=description, organization=organization, **kwargs) payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store) return payload - def create(self, name='', description='', organization=Organization, **kwargs): + def create(self, name='', description='', organization=None, **kwargs): payload = self.create_payload(name=name, description=description, organization=organization, **kwargs) return self.update_identity(WorkflowJobTemplates(self.connection).post(payload)) diff --git a/awxkit/awxkit/api/resources.py b/awxkit/awxkit/api/resources.py index 97a9c31c90..d317bcc55d 100644 --- a/awxkit/awxkit/api/resources.py +++ b/awxkit/awxkit/api/resources.py @@ -63,6 +63,7 @@ class Resources(object): _inventory_related_root_groups = r'inventories/\d+/root_groups/' _inventory_related_script = r'inventories/\d+/script/' _inventory_related_update_inventory_sources = r'inventories/\d+/update_inventory_sources/' + _inventory_scan_job_templates = r'inventories/\d+/scan_job_templates/' _inventory_script = r'inventory_scripts/\d+/' _inventory_script_copy = r'inventory_scripts/\d+/copy/' _inventory_scripts = 'inventory_scripts/'