From 7d0b20757181dde280fd3b4eac15b03d01898219 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Tue, 21 Jan 2020 11:12:08 -0500 Subject: [PATCH] Organization on JT as read-only field Set JT.organization with value from its project Remove validation requiring JT.organization Undo some of the additional org definitions in tests Revert some tests no longer needed for feature exclude workflow approvals from unified organization field revert awxkit changes for providing organization Roll back additional JT creation permission requirement Fix up more issues by persisting organization field when project is removed Restrict project org editing, logging, and testing Grant removed inventory org admin permissions in migration Add special validate_unique for job templates this deals with enforcing name-organization uniqueness Add back in special message where config is unknown when receiving 403 on job relaunch Fix logical and performance bugs with data migration within JT.inventory.organization make-permission-explicit migration remove nested loops so we do .iterator() on JT queryset in reverse migration, carefully remove execute role on JT held by org admins of inventory organization, as well as the execute_role holders Use current state of Role model in logic, with 1 notable exception that is used to filter on ancestors the ancestor and descentent relationship in the migration model is not reliable output of this is saved as an integer list to avoid future compatibility errors make the parents rebuilding logic skip over irrelevant models this is the largest performance gain for small resource numbers --- awx/api/serializers.py | 15 +- awx/main/access.py | 16 +- awx/main/analytics/collectors.py | 6 +- awx/main/fields.py | 14 +- ...7_v370_job_template_organization_field.py} | 19 ++- awx/main/migrations/_rbac.py | 119 ++++++++++++--- awx/main/models/jobs.py | 35 +++++ awx/main/models/projects.py | 7 + awx/main/models/unified_jobs.py | 2 +- awx/main/models/workflow.py | 2 +- awx/main/signals.py | 15 +- awx/main/tests/factories/fixtures.py | 3 +- awx/main/tests/factories/tower.py | 2 +- awx/main/tests/functional/api/test_job.py | 8 +- .../tests/functional/api/test_job_template.py | 144 +++++++++++------- .../functional/api/test_rbac_displays.py | 5 +- .../api/test_unified_job_template.py | 35 ----- awx/main/tests/functional/conftest.py | 10 +- awx/main/tests/functional/models/test_job.py | 21 ++- .../functional/models/test_unified_job.py | 24 +++ awx/main/tests/functional/test_copy.py | 4 +- awx/main/tests/functional/test_instances.py | 11 +- awx/main/tests/functional/test_rbac_job.py | 16 +- .../tests/functional/test_rbac_job_start.py | 15 +- .../functional/test_rbac_job_templates.py | 125 +++++++++++---- .../tests/functional/test_rbac_migration.py | 39 ++++- .../unit/models/test_unified_job_unit.py | 5 +- awx/main/utils/named_url_graph.py | 5 + awxkit/awxkit/api/pages/job_templates.py | 14 +- .../api/pages/workflow_job_templates.py | 6 +- awxkit/awxkit/api/resources.py | 1 + 31 files changed, 517 insertions(+), 226 deletions(-) rename awx/main/migrations/{0085_v360_job_template_organization_field.py => 0107_v370_job_template_organization_field.py} (82%) 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/'