From daa9282790ca9c3f4fedfdd9fdf0251bba663824 Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 16 Jan 2020 14:59:43 -0500 Subject: [PATCH] Initial (editable) pass of adding JT.organization This is the old version of this feature from 2019 this allows setting the organization in the data sent to the API when creating a JT, and exposes the field in the UI as well Subsequent commit changes the field from editable to read-only, but as of this commit, the machinery is not hooked up to infer it from project --- awx/api/generics.py | 9 + awx/api/serializers.py | 32 ++-- awx/api/urls/organization.py | 2 + awx/api/views/__init__.py | 1 + awx/api/views/mixin.py | 37 +--- awx/api/views/organization.py | 22 ++- awx/main/access.py | 132 ++++++------- awx/main/fields.py | 38 ++-- awx/main/middleware.py | 36 +++- ...85_v360_job_template_organization_field.py | 72 +++++++ awx/main/migrations/_rbac.py | 179 ++++++++++++++++-- awx/main/models/inventory.py | 16 +- awx/main/models/jobs.py | 30 +-- awx/main/models/organization.py | 8 +- awx/main/models/projects.py | 13 +- awx/main/models/unified_jobs.py | 16 ++ awx/main/models/workflow.py | 9 +- awx/main/signals.py | 1 - awx/main/tests/factories/fixtures.py | 3 +- awx/main/tests/factories/tower.py | 2 +- .../test_deprecated_credential_assignment.py | 1 + awx/main/tests/functional/api/test_job.py | 29 ++- .../tests/functional/api/test_job_template.py | 47 ++++- .../api/test_organization_counts.py | 60 ++---- .../functional/api/test_rbac_displays.py | 5 +- .../api/test_unified_job_template.py | 110 +++++++++++ awx/main/tests/functional/conftest.py | 37 ++-- .../functional/models/test_activity_stream.py | 5 +- awx/main/tests/functional/models/test_job.py | 9 +- .../tests/functional/models/test_project.py | 6 + awx/main/tests/functional/test_copy.py | 4 +- awx/main/tests/functional/test_instances.py | 11 +- awx/main/tests/functional/test_named_url.py | 22 ++- awx/main/tests/functional/test_projects.py | 26 +-- awx/main/tests/functional/test_rbac_job.py | 3 +- .../tests/functional/test_rbac_job_start.py | 11 +- .../functional/test_rbac_job_templates.py | 115 +++++++---- .../tests/functional/test_rbac_migration.py | 64 +++++++ .../tests/functional/test_rbac_workflow.py | 5 +- .../test_job_template_serializers.py | 1 + .../unit/models/test_unified_job_unit.py | 8 + awx/main/tests/unit/test_access.py | 29 +-- awx/main/tests/unit/test_fields.py | 63 +++++- .../job_templates/job-template.form.js | 13 ++ awxkit/awxkit/api/pages/job_templates.py | 14 +- .../api/pages/workflow_job_templates.py | 6 +- 46 files changed, 985 insertions(+), 377 deletions(-) create mode 100644 awx/main/migrations/0085_v360_job_template_organization_field.py create mode 100644 awx/main/tests/functional/test_rbac_migration.py diff --git a/awx/api/generics.py b/awx/api/generics.py index af763d875e..f352019c65 100644 --- a/awx/api/generics.py +++ b/awx/api/generics.py @@ -548,6 +548,15 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView): }) return d + def get_queryset(self): + if hasattr(self, 'parent_key'): + # Prefer this filtering because ForeignKey allows us more assumptions + parent = self.get_parent_object() + self.check_parent_access(parent) + qs = self.request.user.get_queryset(self.model) + return qs.filter(**{self.parent_key: parent}) + return super(SubListCreateAPIView, self).get_queryset() + def create(self, request, *args, **kwargs): # If the object ID was not specified, it probably doesn't exist in the # DB yet. We want to see if we can create it. The URL may choose to diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 858aeb4ebf..1240d00291 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -642,7 +642,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer): _capabilities_prefetch = [ 'admin', 'execute', {'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use', - 'workflowjobtemplate.organization.workflow_admin']} + 'organization.workflow_admin']} ] class Meta: @@ -700,6 +700,18 @@ 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'] @@ -1387,12 +1399,6 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): def get_field_from_model_or_attrs(fd): return attrs.get(fd, self.instance and getattr(self.instance, fd) or None) - organization = None - if 'organization' in attrs: - organization = attrs['organization'] - elif self.instance: - organization = self.instance.organization - if 'allow_override' in attrs and self.instance: # case where user is turning off this project setting if self.instance.allow_override and not attrs['allow_override']: @@ -1408,11 +1414,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer): ' '.join([str(pk) for pk in used_by]) )}) - view = self.context.get('view', None) - if not organization and not view.request.user.is_superuser: - # Only allow super users to create orgless projects - raise serializers.ValidationError(_('Organization is missing')) - elif get_field_from_model_or_attrs('scm_type') == '': + if get_field_from_model_or_attrs('scm_type') == '': for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_clean'): if get_field_from_model_or_attrs(fd): raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')}) @@ -2738,7 +2740,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'scm_branch', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'force_handlers', 'skip_tags', 'start_at_task', 'timeout', - 'use_fact_cache',) + 'use_fact_cache', 'organization',) def get_related(self, obj): res = super(JobOptionsSerializer, self).get_related(obj) @@ -2753,6 +2755,8 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer): res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk}) except ObjectDoesNotExist: setattr(obj, 'project', None) + if obj.organization_id: + res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id}) if isinstance(obj, UnifiedJobTemplate): res['extra_credentials'] = self.reverse( 'api:job_template_extra_credentials_list', @@ -2899,6 +2903,8 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO ) if obj.host_config_key: res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk}) + if obj.organization_id: + res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id}) return res def validate(self, attrs): diff --git a/awx/api/urls/organization.py b/awx/api/urls/organization.py index 1b0997b05c..3d172f1360 100644 --- a/awx/api/urls/organization.py +++ b/awx/api/urls/organization.py @@ -10,6 +10,7 @@ from awx.api.views import ( OrganizationAdminsList, OrganizationInventoriesList, OrganizationProjectsList, + OrganizationJobTemplatesList, OrganizationWorkflowJobTemplatesList, OrganizationTeamsList, OrganizationCredentialList, @@ -33,6 +34,7 @@ urls = [ url(r'^(?P[0-9]+)/admins/$', OrganizationAdminsList.as_view(), name='organization_admins_list'), url(r'^(?P[0-9]+)/inventories/$', OrganizationInventoriesList.as_view(), name='organization_inventories_list'), url(r'^(?P[0-9]+)/projects/$', OrganizationProjectsList.as_view(), name='organization_projects_list'), + url(r'^(?P[0-9]+)/job_templates/$', OrganizationJobTemplatesList.as_view(), name='organization_job_templates_list'), url(r'^(?P[0-9]+)/workflow_job_templates/$', OrganizationWorkflowJobTemplatesList.as_view(), name='organization_workflow_job_templates_list'), url(r'^(?P[0-9]+)/teams/$', OrganizationTeamsList.as_view(), name='organization_teams_list'), url(r'^(?P[0-9]+)/credentials/$', OrganizationCredentialList.as_view(), name='organization_credential_list'), diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 0e937da67c..1339c50c68 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -111,6 +111,7 @@ from awx.api.views.organization import ( # noqa OrganizationUsersList, OrganizationAdminsList, OrganizationProjectsList, + OrganizationJobTemplatesList, OrganizationWorkflowJobTemplatesList, OrganizationTeamsList, OrganizationActivityStreamList, diff --git a/awx/api/views/mixin.py b/awx/api/views/mixin.py index e7d4959dfc..9b57278e2e 100644 --- a/awx/api/views/mixin.py +++ b/awx/api/views/mixin.py @@ -4,10 +4,7 @@ import dateutil import logging -from django.db.models import ( - Count, - F, -) +from django.db.models import Count from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils.timezone import now @@ -175,28 +172,18 @@ class OrganizationCountsMixin(object): inv_qs = Inventory.accessible_objects(self.request.user, 'read_role') project_qs = Project.accessible_objects(self.request.user, 'read_role') + jt_qs = JobTemplate.accessible_objects(self.request.user, 'read_role') # Produce counts of Foreign Key relationships - db_results['inventories'] = inv_qs\ - .values('organization').annotate(Count('organization')).order_by('organization') + db_results['inventories'] = inv_qs.values('organization').annotate(Count('organization')).order_by('organization') db_results['teams'] = Team.accessible_objects( self.request.user, 'read_role').values('organization').annotate( Count('organization')).order_by('organization') - JT_project_reference = 'project__organization' - JT_inventory_reference = 'inventory__organization' - db_results['job_templates_project'] = JobTemplate.accessible_objects( - self.request.user, 'read_role').exclude( - project__organization=F(JT_inventory_reference)).values(JT_project_reference).annotate( - Count(JT_project_reference)).order_by(JT_project_reference) + db_results['job_templates'] = jt_qs.values('organization').annotate(Count('organization')).order_by('organization') - db_results['job_templates_inventory'] = JobTemplate.accessible_objects( - self.request.user, 'read_role').values(JT_inventory_reference).annotate( - Count(JT_inventory_reference)).order_by(JT_inventory_reference) - - db_results['projects'] = project_qs\ - .values('organization').annotate(Count('organization')).order_by('organization') + db_results['projects'] = project_qs.values('organization').annotate(Count('organization')).order_by('organization') # Other members and admins of organization are always viewable db_results['users'] = org_qs.annotate( @@ -212,11 +199,7 @@ class OrganizationCountsMixin(object): 'admins': 0, 'projects': 0} for res, count_qs in db_results.items(): - if res == 'job_templates_project': - org_reference = JT_project_reference - elif res == 'job_templates_inventory': - org_reference = JT_inventory_reference - elif res == 'users': + if res == 'users': org_reference = 'id' else: org_reference = 'organization' @@ -229,14 +212,6 @@ class OrganizationCountsMixin(object): continue count_context[org_id][res] = entry['%s__count' % org_reference] - # Combine the counts for job templates by project and inventory - for org in org_id_list: - org_id = org['id'] - count_context[org_id]['job_templates'] = 0 - for related_path in ['job_templates_project', 'job_templates_inventory']: - if related_path in count_context[org_id]: - count_context[org_id]['job_templates'] += count_context[org_id].pop(related_path) - full_context['related_field_counts'] = count_context return full_context diff --git a/awx/api/views/organization.py b/awx/api/views/organization.py index e1af4c67b1..cb929ec5b5 100644 --- a/awx/api/views/organization.py +++ b/awx/api/views/organization.py @@ -20,7 +20,7 @@ from awx.main.models import ( Role, User, Team, - InstanceGroup, + InstanceGroup ) from awx.api.generics import ( ListCreateAPIView, @@ -28,6 +28,7 @@ from awx.api.generics import ( SubListAPIView, SubListCreateAttachDetachAPIView, SubListAttachDetachAPIView, + SubListCreateAPIView, ResourceAccessList, BaseUsersList, ) @@ -35,14 +36,13 @@ from awx.api.generics import ( from awx.api.serializers import ( OrganizationSerializer, InventorySerializer, - ProjectSerializer, UserSerializer, TeamSerializer, ActivityStreamSerializer, RoleSerializer, NotificationTemplateSerializer, - WorkflowJobTemplateSerializer, InstanceGroupSerializer, + ProjectSerializer, JobTemplateSerializer, WorkflowJobTemplateSerializer ) from awx.api.views.mixin import ( RelatedJobsPreventDeleteMixin, @@ -94,7 +94,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter( organization__id=org_id).count() org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter( - project__organization__id=org_id).count() + organization__id=org_id).count() full_context['related_field_counts'] = {} full_context['related_field_counts'][org_id] = org_counts @@ -128,21 +128,27 @@ class OrganizationAdminsList(BaseUsersList): ordering = ('username',) -class OrganizationProjectsList(SubListCreateAttachDetachAPIView): +class OrganizationProjectsList(SubListCreateAPIView): model = Project serializer_class = ProjectSerializer parent_model = Organization - relationship = 'projects' parent_key = 'organization' -class OrganizationWorkflowJobTemplatesList(SubListCreateAttachDetachAPIView): +class OrganizationJobTemplatesList(SubListCreateAPIView): + + model = JobTemplate + serializer_class = JobTemplateSerializer + parent_model = Organization + parent_key = 'organization' + + +class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView): model = WorkflowJobTemplate serializer_class = WorkflowJobTemplateSerializer parent_model = Organization - relationship = 'workflows' parent_key = 'organization' diff --git a/awx/main/access.py b/awx/main/access.py index 95ec0f20a8..d6ae9c0082 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -1411,7 +1411,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess): ''' model = JobTemplate - select_related = ('created_by', 'modified_by', 'inventory', 'project', + select_related = ('created_by', 'modified_by', 'inventory', 'project', 'organization', 'next_schedule',) prefetch_related = ( 'instance_groups', @@ -1435,9 +1435,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess): Users who are able to create deploy jobs can also run normal and check (dry run) jobs. ''' if not data: # So the browseable API will work - return ( - Project.accessible_objects(self.user, 'use_role').exists() or - Inventory.accessible_objects(self.user, 'use_role').exists()) + return Organization.accessible_objects(self.user, 'job_template_admin_role').exists() # if reference_obj is provided, determine if it can be copied reference_obj = data.get('reference_obj', None) @@ -1467,6 +1465,10 @@ 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. @@ -1504,22 +1506,31 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess): return self.user in obj.execute_role def can_change(self, obj, data): - data_for_change = data if self.user not in obj.admin_role and not self.user.is_superuser: return False - if data is not None: - data = dict(data) + if data is None: + return True - if self.changes_are_non_sensitive(obj, data): - if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']: - self.check_license(feature='surveys') - return True + # standard type of check for organization - cannot change the value + # unless posessing the respective job_template_admin_role, otherwise non-blocking + if not self.check_related('organization', Organization, data, obj=obj, role_field='job_template_admin_role'): + return False - for required_field in ('inventory', 'project'): - required_obj = getattr(obj, required_field, None) - if required_field not in data_for_change and required_obj is not None: - data_for_change[required_field] = required_obj.pk - return self.can_read(obj) and (self.can_add(data_for_change) if data is not None else True) + data = dict(data) + + if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']: + self.check_license(feature='surveys') + + if self.changes_are_non_sensitive(obj, data): + return True + + for required_field, cls in (('inventory', Inventory), ('project', Project)): + is_mandatory = True + if not getattr(obj, '{}_id'.format(required_field)): + is_mandatory = False + if not self.check_related(required_field, cls, data, obj=obj, role_field='use_role', mandatory=is_mandatory): + return False + return True def changes_are_non_sensitive(self, obj, data): ''' @@ -1554,9 +1565,9 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess): @check_superuser def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): if relationship == "instance_groups": - if not obj.project.organization: + if not obj.organization: return False - return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.project.organization.admin_role + return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.organization.admin_role if relationship == 'credentials' and isinstance(sub_obj, Credential): return self.user in obj.admin_role and self.user in sub_obj.use_role return super(JobTemplateAccess, self).can_attach( @@ -1587,6 +1598,7 @@ class JobAccess(BaseAccess): select_related = ('created_by', 'modified_by', 'job_template', 'inventory', 'project', 'project_update',) prefetch_related = ( + 'organization', 'unified_job_template', 'instance_group', 'credentials__credential_type', @@ -1607,42 +1619,19 @@ class JobAccess(BaseAccess): return qs.filter( Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) | - Q(inventory__organization__in=org_access_qs) | - Q(project__organization__in=org_access_qs)).distinct() - - def related_orgs(self, obj): - orgs = [] - if obj.inventory and obj.inventory.organization: - orgs.append(obj.inventory.organization) - if obj.project and obj.project.organization and obj.project.organization not in orgs: - orgs.append(obj.project.organization) - return orgs - - def org_access(self, obj, role_types=['admin_role']): - orgs = self.related_orgs(obj) - for org in orgs: - for role_type in role_types: - role = getattr(org, role_type) - if self.user in role: - return True - return False + Q(organization__in=org_access_qs)).distinct() def can_add(self, data, validate_license=True): - if validate_license: - self.check_license() - - if not data: # So the browseable API will work - return True - return self.user.is_superuser + raise NotImplementedError('Direct job creation not possible in v2 API') def can_change(self, obj, data): - return (obj.status == 'new' and - self.can_read(obj) and - self.can_add(data, validate_license=False)) + raise NotImplementedError('Direct job editing not supported in v2 API') @check_superuser def can_delete(self, obj): - return self.org_access(obj) + if not obj.organization: + return False + return self.user in obj.organization.admin_role def can_start(self, obj, validate_license=True): if validate_license: @@ -1662,6 +1651,7 @@ class JobAccess(BaseAccess): except JobLaunchConfig.DoesNotExist: config = None + # Standard permissions model (1) if obj.job_template and (self.user not in obj.job_template.execute_role): return False @@ -1676,24 +1666,15 @@ class JobAccess(BaseAccess): if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): return True - org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role - project_access = obj.project is None or self.user in obj.project.admin_role - credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()]) + # Standard permissions model (2) + 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.') - # job can be relaunched if user could make an equivalent JT - ret = org_access and credential_access and project_access - if not ret and self.save_messages and not self.messages: - if not obj.job_template: - pretext = _('Job has been orphaned from its job template.') - elif config is None: - pretext = _('Job was launched with unknown prompted fields.') - else: - pretext = _('Job was launched with prompted fields.') - if credential_access: - self.messages['detail'] = '{} {}'.format(pretext, _(' Organization level permissions required.')) - else: - self.messages['detail'] = '{} {}'.format(pretext, _(' You do not have permission to related resources.')) - return ret + return False def get_method_capability(self, method, obj, parent_obj): if method == 'start': @@ -1706,10 +1687,16 @@ class JobAccess(BaseAccess): def can_cancel(self, obj): if not obj.can_cancel: return False - # Delete access allows org admins to stop running jobs - if self.user == obj.created_by or self.can_delete(obj): + # Users may always cancel their own jobs + if self.user == obj.created_by: return True - return obj.job_template is not None and self.user in obj.job_template.admin_role + # Users with direct admin to JT may cancel jobs started by anyone + if obj.job_template and self.user in obj.job_template.admin_role: + return True + # If orphaned, allow org JT admins to stop running jobs + if not obj.job_template and obj.organization and self.user in obj.organization.job_template_admin_role: + return True + return False class SystemJobTemplateAccess(BaseAccess): @@ -1944,11 +1931,11 @@ class WorkflowJobNodeAccess(BaseAccess): # TODO: notification attachments? class WorkflowJobTemplateAccess(NotificationAttachMixin, BaseAccess): ''' - I can only see/manage Workflow Job Templates if I'm a super user + I can see/manage Workflow Job Templates based on object roles ''' model = WorkflowJobTemplate - select_related = ('created_by', 'modified_by', 'next_schedule', + select_related = ('created_by', 'modified_by', 'organization', 'next_schedule', 'admin_role', 'execute_role', 'read_role',) def filtered_queryset(self): @@ -2038,7 +2025,7 @@ class WorkflowJobAccess(BaseAccess): I can also cancel it if I started it ''' model = WorkflowJob - select_related = ('created_by', 'modified_by',) + select_related = ('created_by', 'modified_by', 'organization',) def filtered_queryset(self): return WorkflowJob.objects.filter( @@ -2332,6 +2319,7 @@ class UnifiedJobTemplateAccess(BaseAccess): prefetch_related = ( 'last_job', 'current_job', + 'organization', 'credentials__credential_type', Prefetch('labels', queryset=Label.objects.all().order_by('name')), ) @@ -2371,6 +2359,7 @@ class UnifiedJobAccess(BaseAccess): prefetch_related = ( 'created_by', 'modified_by', + 'organization', 'unified_job_node__workflow_job', 'unified_job_template', 'instance_group', @@ -2401,8 +2390,7 @@ class UnifiedJobAccess(BaseAccess): Q(unified_job_template_id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) | Q(inventoryupdate__inventory_source__inventory__id__in=inv_pk_qs) | Q(adhoccommand__inventory__id__in=inv_pk_qs) | - Q(job__inventory__organization__in=org_auditor_qs) | - Q(job__project__organization__in=org_auditor_qs) + Q(organization__in=org_auditor_qs) ) return qs diff --git a/awx/main/fields.py b/awx/main/fields.py index d395803c7c..1038673eb2 100644 --- a/awx/main/fields.py +++ b/awx/main/fields.py @@ -56,7 +56,8 @@ from awx.main import utils __all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField', 'SmartFilterField', 'OrderedManyToManyField', - 'update_role_parentage_for_instance', 'is_implicit_parent'] + 'update_role_parentage_for_instance', + 'is_implicit_parent'] # Provide a (better) custom error message for enum jsonschema validation @@ -140,8 +141,9 @@ def resolve_role_field(obj, field): return [] if len(field_components) == 1: - role_cls = str(utils.get_current_apps().get_model('main', 'Role')) - if not str(type(obj)) == role_cls: + # use extremely generous duck typing to accomidate all possible forms + # of the model that may be used during various migrations + if obj._meta.model_name != 'role' or obj._meta.app_label != 'main': raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj)))) ret.append(obj.id) else: @@ -197,18 +199,30 @@ def update_role_parentage_for_instance(instance): updates the parents listing for all the roles of a given instance if they have changed ''' + changed_ct = 0 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) - cur_role.parents.remove(*list(original_parents - new_parents)) - cur_role.parents.add(*list(new_parents - original_parents)) + removals = original_parents - new_parents + if removals: + changed = True + cur_role.parents.remove(*list(removals)) + additions = new_parents - original_parents + if additions: + changed = True + cur_role.parents.add(*list(additions)) 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 class ImplicitRoleDescriptor(ForwardManyToOneDescriptor): @@ -256,20 +270,18 @@ class ImplicitRoleField(models.ForeignKey): field_names = [field_names] for field_name in field_names: - # Handle the OR syntax for role parents - if type(field_name) == tuple: - continue - - if type(field_name) == bytes: - field_name = field_name.decode('utf-8') if field_name.startswith('singleton:'): continue field_name, sep, field_attr = field_name.partition('.') - field = getattr(cls, field_name) + # Non existent fields will occur if ever a parent model is + # moved inside a migration, needed for job_template_organization_field + # migration in particular + # consistency is assured by unit test awx.main.tests.functional + field = getattr(cls, field_name, None) - if type(field) is ReverseManyToOneDescriptor or \ + if field and type(field) is ReverseManyToOneDescriptor or \ type(field) is ManyToManyDescriptor: if '.' in field_attr: diff --git a/awx/main/middleware.py b/awx/main/middleware.py index d79eb06af9..8382b81408 100644 --- a/awx/main/middleware.py +++ b/awx/main/middleware.py @@ -192,21 +192,41 @@ class URLModificationMiddleware(MiddlewareMixin): ) super().__init__(get_response) - def _named_url_to_pk(self, node, named_url): - kwargs = {} - if not node.populate_named_url_query_kwargs(kwargs, named_url): - return named_url - return str(get_object_or_404(node.model, **kwargs).pk) + @staticmethod + def _hijack_for_old_jt_name(node, kwargs, named_url): + try: + int(named_url) + return False + except ValueError: + pass + JobTemplate = node.model + name = urllib.parse.unquote(named_url) + return JobTemplate.objects.filter(name=name).order_by('organization__created').first() - def _convert_named_url(self, url_path): + @classmethod + def _named_url_to_pk(cls, node, resource, named_url): + kwargs = {} + if node.populate_named_url_query_kwargs(kwargs, named_url): + return str(get_object_or_404(node.model, **kwargs).pk) + if resource == 'job_templates' and '++' not in named_url: + # special case for deprecated job template case + # will not raise a 404 on its own + jt = cls._hijack_for_old_jt_name(node, kwargs, named_url) + if jt: + return str(jt.pk) + return named_url + + @classmethod + def _convert_named_url(cls, url_path): url_units = url_path.split('/') # If the identifier is an empty string, it is always invalid. if len(url_units) < 6 or url_units[1] != 'api' or url_units[2] not in ['v2'] or not url_units[4]: return url_path resource = url_units[3] if resource in settings.NAMED_URL_MAPPINGS: - url_units[4] = self._named_url_to_pk(settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]], - url_units[4]) + url_units[4] = cls._named_url_to_pk( + settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]], + resource, url_units[4]) return '/'.join(url_units) def process_request(self, request): diff --git a/awx/main/migrations/0085_v360_job_template_organization_field.py b/awx/main/migrations/0085_v360_job_template_organization_field.py new file mode 100644 index 0000000000..a454083e98 --- /dev/null +++ b/awx/main/migrations/0085_v360_job_template_organization_field.py @@ -0,0 +1,72 @@ +# Generated by Django 2.2.4 on 2019-08-07 19:56 + +import awx.main.utils.polymorphic +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 + + +class Migration(migrations.Migration): + + dependencies = [ + ('main', '0084_v360_token_description'), + ] + + operations = [ + # backwards parents and ancestors caching + migrations.RunPython(migrations.RunPython.noop, rebuild_role_parentage), + # add new organization field for JT and all other unified jobs + migrations.AddField( + model_name='unifiedjob', + name='tmp_organization', + field=models.ForeignKey(blank=True, help_text='The organization used to determine access to this unified job.', null=True, on_delete=awx.main.utils.polymorphic.SET_NULL, related_name='unifiedjobs', to='main.Organization'), + ), + migrations.AddField( + model_name='unifiedjobtemplate', + name='tmp_organization', + field=models.ForeignKey(blank=True, help_text='The organization used to determine access to this template.', null=True, on_delete=awx.main.utils.polymorphic.SET_NULL, related_name='unifiedjobtemplates', to='main.Organization'), + ), + # while new and old fields exist, copy the organization fields + migrations.RunPython(migrate_ujt_organization, migrate_ujt_organization_backward), + # with data saved, remove old fields + migrations.RemoveField( + model_name='project', + name='organization', + ), + migrations.RemoveField( + model_name='workflowjobtemplate', + name='organization', + ), + # now, without safely rename the new field without conflicts from old field + migrations.RenameField( + model_name='unifiedjobtemplate', + old_name='tmp_organization', + new_name='organization', + ), + migrations.RenameField( + model_name='unifiedjob', + old_name='tmp_organization', + new_name='organization', + ), + # parentage of job template roles has genuinely changed at this point + migrations.AlterField( + model_name='jobtemplate', + name='admin_role', + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.job_template_admin_role'], related_name='+', to='main.Role'), + ), + migrations.AlterField( + model_name='jobtemplate', + name='execute_role', + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['admin_role', 'organization.execute_role'], related_name='+', to='main.Role'), + ), + migrations.AlterField( + model_name='jobtemplate', + name='read_role', + 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), + ] diff --git a/awx/main/migrations/_rbac.py b/awx/main/migrations/_rbac.py index 9b85c71086..0b052c2350 100644 --- a/awx/main/migrations/_rbac.py +++ b/awx/main/migrations/_rbac.py @@ -1,6 +1,9 @@ import logging from time import time +from django.db.models import Subquery, OuterRef + +from awx.main.fields import update_role_parentage_for_instance from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding logger = logging.getLogger('rbac_migrations') @@ -10,11 +13,11 @@ def create_roles(apps, schema_editor): ''' Implicit role creation happens in our post_save hook for all of our resources. Here we iterate through all of our resource types and call - .save() to ensure all that happens for every object in the system before we - get busy with the actual migration work. + .save() to ensure all that happens for every object in the system. - This gets run after migrate_users, which does role creation for users a - little differently. + This can be used whenever new roles are introduced in a migration to + create those roles for pre-existing objects that did not previously + have them created via signals. ''' models = [ @@ -35,7 +38,118 @@ def create_roles(apps, schema_editor): obj.save() +def delete_all_user_roles(apps, schema_editor): + ContentType = apps.get_model('contenttypes', "ContentType") + Role = apps.get_model('main', "Role") + User = apps.get_model('auth', "User") + user_content_type = ContentType.objects.get_for_model(User) + for role in Role.objects.filter(content_type=user_content_type).iterator(): + role.delete() + + +UNIFIED_ORG_LOOKUPS = { + # Job Templates had an implicit organization via their project + 'jobtemplate': 'project', + # Inventory Sources had an implicit organization via their inventory + 'inventorysource': 'inventory', + # Projects had an explicit organization in their subclass table + 'project': None, + # Workflow JTs also had an explicit organization in their subclass table + 'workflowjobtemplate': None, + # Jobs inherited project from job templates as a convenience field + 'job': 'project', + # Inventory Sources had an convenience field of inventory + 'inventoryupdate': 'inventory', + # Project Updates did not have a direct organization field, obtained it from project + 'projectupdate': 'project', + # Workflow Jobs are handled same as project updates + # Sliced jobs are a special case, but old data is not given special treatment for simplicity + 'workflowjob': 'workflow_job_template', + # AdHocCommands do not have a template, but still migrate them + 'adhoccommand': 'inventory' +} + + +def implicit_org_subquery(UnifiedClass, cls, backward=False): + """Returns a subquery that returns the so-called organization for objects + in the class in question, before migration to the explicit unified org field. + In some cases, this can still be applied post-migration. + """ + if cls._meta.model_name not in UNIFIED_ORG_LOOKUPS: + return None + cls_name = cls._meta.model_name + source_field = UNIFIED_ORG_LOOKUPS[cls_name] + + unified_field = UnifiedClass._meta.get_field(cls_name) + unified_ptr = unified_field.remote_field.name + if backward: + qs = UnifiedClass.objects.filter(**{cls_name: OuterRef('id')}).order_by().values_list('tmp_organization')[:1] + elif source_field is None: + qs = cls.objects.filter(**{unified_ptr: OuterRef('id')}).order_by().values_list('organization')[:1] + else: + intermediary_field = cls._meta.get_field(source_field) + intermediary_model = intermediary_field.related_model + intermediary_reverse_rel = intermediary_field.remote_field.name + qs = intermediary_model.objects.filter(**{ + # this filter leverages the fact that the Unified models have same pk as subclasses. + # For instance... filters projects used in job template, where that job template + # has same id same as UJT from the outer reference (which it does) + intermediary_reverse_rel: OuterRef('id')} + ).order_by().values_list('organization')[:1] + return Subquery(qs) + + +def _migrate_unified_organization(apps, unified_cls_name, backward=False): + """Given a unified base model (either UJT or UJ) + and a dict org_field_mapping which gives related model to get org from + saves organization for those objects to the temporary migration + variable tmp_organization on the unified model + (optimized method) + """ + start = time() + UnifiedClass = apps.get_model('main', unified_cls_name) + ContentType = apps.get_model('contenttypes', 'ContentType') + + for cls in UnifiedClass.__subclasses__(): + cls_name = cls._meta.model_name + 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)) + + sub_qs = implicit_org_subquery(UnifiedClass, cls, backward=backward) + if sub_qs is None: + logger.debug('Class {} has no organization migration'.format(cls_name)) + continue + + this_ct = ContentType.objects.get_for_model(cls) + if backward: + r = cls.objects.order_by().update(organization=sub_qs) + else: + 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)) + + +def migrate_ujt_organization(apps, schema_editor): + '''Move organization field to UJT and UJ models''' + _migrate_unified_organization(apps, 'UnifiedJobTemplate') + _migrate_unified_organization(apps, 'UnifiedJob') + + +def migrate_ujt_organization_backward(apps, schema_editor): + '''Move organization field from UJT and UJ models back to their original places''' + _migrate_unified_organization(apps, 'UnifiedJobTemplate', backward=True) + _migrate_unified_organization(apps, 'UnifiedJob', backward=True) + + def rebuild_role_hierarchy(apps, schema_editor): + ''' + This should be called in any migration when ownerships are changed. + Ex. I remove a user from the admin_role of a credential. + Ancestors are cached from parents for performance, this re-computes ancestors. + ''' logger.info('Computing role roots..') start = time() roots = Role.objects \ @@ -46,14 +160,57 @@ def rebuild_role_hierarchy(apps, schema_editor): start = time() Role.rebuild_role_ancestor_list(roots, []) stop = time() - logger.info('Rebuild completed in %f seconds' % (stop - start)) + logger.info('Rebuild ancestors completed in %f seconds' % (stop - start)) logger.info('Done.') -def delete_all_user_roles(apps, schema_editor): - ContentType = apps.get_model('contenttypes', "ContentType") +def rebuild_role_parentage(apps, schema_editor): + ''' + This should be called in any migration when any parent_role entry + is modified so that the cached parent fields will be updated. Ex: + foo_role = ImplicitRoleField( + parent_role=['bar_role'] # change to parent_role=['admin_role'] + ) + + This is like rebuild_role_hierarchy, but that method updates ancestors, + whereas this method updates parents. + ''' + start = time() + seen_models = set() + updated_ct = 0 + model_ct = 0 + noop_ct = 0 Role = apps.get_model('main', "Role") - User = apps.get_model('auth', "User") - user_content_type = ContentType.objects.get_for_model(User) - for role in Role.objects.filter(content_type=user_content_type).iterator(): - role.delete() + for role in Role.objects.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: + continue + seen_models.add(model_tuple) + + # The GenericForeignKey does not work right in migrations + # with the usage as role.content_object + # so we do the lookup ourselves with current migration models + ct = role.content_type + app = ct.app_label + 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: + model_ct += 1 + logger.debug('Updated parents of {} roles of {}'.format(updated, 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.info('Rebuild parentage completed in %f seconds' % (time() - start)) + + if updated_ct: + rebuild_role_hierarchy(apps, schema_editor) diff --git a/awx/main/models/inventory.py b/awx/main/models/inventory.py index 9ced56cc4a..d94b428581 100644 --- a/awx/main/models/inventory.py +++ b/awx/main/models/inventory.py @@ -426,9 +426,9 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin): ''' def _get_related_jobs(self): return UnifiedJob.objects.non_polymorphic().filter( - Q(Job___inventory=self) | - Q(InventoryUpdate___inventory_source__inventory=self) | - Q(AdHocCommand___inventory=self) + Q(job__inventory=self) | + Q(inventoryupdate__inventory=self) | + Q(adhoccommand__inventory=self) ) @@ -808,8 +808,8 @@ class Group(CommonModelNameNotUnique, RelatedJobsMixin): ''' def _get_related_jobs(self): return UnifiedJob.objects.non_polymorphic().filter( - Q(Job___inventory=self.inventory) | - Q(InventoryUpdate___inventory_source__groups=self) + Q(job__inventory=self.inventory) | + Q(inventoryupdate__inventory_source__groups=self) ) @@ -1277,10 +1277,14 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in InventorySourceOptions._meta.fields) | set( - ['name', 'description', 'credentials', 'inventory'] + ['name', 'description', 'organization', 'credentials', 'inventory'] ) def save(self, *args, **kwargs): + # if this is a new object, inherit organization from its inventory + if not self.pk and self.inventory and self.inventory.organization_id and not self.organization_id: + self.organization_id = self.inventory.organization_id + # If update_fields has been specified, add our field names to it, # if it hasn't been specified, then we're just doing a normal save. update_fields = kwargs.get('update_fields', []) diff --git a/awx/main/models/jobs.py b/awx/main/models/jobs.py index 77b3871626..829720afa1 100644 --- a/awx/main/models/jobs.py +++ b/awx/main/models/jobs.py @@ -199,7 +199,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour 'labels', 'instance_groups', 'credentials', 'survey_spec' ] FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential'] - SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] + SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')] class Meta: app_label = 'main' @@ -262,13 +262,17 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour ) admin_role = ImplicitRoleField( - parent_role=['project.organization.job_template_admin_role', 'inventory.organization.job_template_admin_role'] + parent_role=['organization.job_template_admin_role'] ) execute_role = ImplicitRoleField( - parent_role=['admin_role', 'project.organization.execute_role', 'inventory.organization.execute_role'], + parent_role=['admin_role', 'organization.execute_role'], ) read_role = ImplicitRoleField( - parent_role=['project.organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'], + parent_role=[ + 'organization.auditor_role', + 'inventory.organization.auditor_role', # partial support for old inheritance via inventory + 'execute_role', 'admin_role' + ], ) @@ -279,7 +283,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in JobOptions._meta.fields) | set( - ['name', 'description', 'survey_passwords', 'labels', 'credentials', + ['name', 'description', 'organization', 'survey_passwords', 'labels', 'credentials', 'job_slice_number', 'job_slice_count'] ) @@ -479,13 +483,13 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour success_notification_templates = list(base_notification_templates.filter( unifiedjobtemplate_notification_templates_for_success__in=[self, self.project])) # Get Organization NotificationTemplates - if self.project is not None and self.project.organization is not None: + if self.organization is not None: error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter( - organization_notification_templates_for_errors=self.project.organization))) + organization_notification_templates_for_errors=self.organization))) started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter( - organization_notification_templates_for_started=self.project.organization))) + organization_notification_templates_for_started=self.organization))) success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter( - organization_notification_templates_for_success=self.project.organization))) + organization_notification_templates_for_success=self.organization))) return dict(error=list(error_notification_templates), started=list(started_notification_templates), success=list(success_notification_templates)) @@ -588,7 +592,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana for virtualenv in ( self.job_template.custom_virtualenv if self.job_template else None, self.project.custom_virtualenv, - self.project.organization.custom_virtualenv if self.project.organization else None + self.organization.custom_virtualenv if self.organization else None ): if virtualenv: return virtualenv @@ -741,8 +745,8 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana @property def preferred_instance_groups(self): - if self.project is not None and self.project.organization is not None: - organization_groups = [x for x in self.project.organization.instance_groups.all()] + if self.organization is not None: + organization_groups = [x for x in self.organization.instance_groups.all()] else: organization_groups = [] if self.inventory is not None: @@ -1144,7 +1148,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions): @classmethod def _get_unified_job_field_names(cls): - return ['name', 'description', 'job_type', 'extra_vars'] + return ['name', 'description', 'organization', 'job_type', 'extra_vars'] def get_absolute_url(self, request=None): return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request) diff --git a/awx/main/models/organization.py b/awx/main/models/organization.py index df5d491d20..23ce65f5e9 100644 --- a/awx/main/models/organization.py +++ b/awx/main/models/organization.py @@ -6,7 +6,6 @@ # Django from django.conf import settings from django.db import models -from django.db.models import Q from django.contrib.auth.models import User from django.contrib.sessions.models import Session from django.utils.timezone import now as tz_now @@ -106,12 +105,7 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi RelatedJobsMixin ''' def _get_related_jobs(self): - project_ids = self.projects.all().values_list('id') - return UnifiedJob.objects.non_polymorphic().filter( - Q(Job___project__in=project_ids) | - Q(ProjectUpdate___project__in=project_ids) | - Q(InventoryUpdate___inventory_source__inventory__organization=self) - ) + return UnifiedJob.objects.non_polymorphic().filter(organization=self) class Team(CommonModelNameNotUnique, ResourceMixin): diff --git a/awx/main/models/projects.py b/awx/main/models/projects.py index cc31842d4d..c9bf9762dd 100644 --- a/awx/main/models/projects.py +++ b/awx/main/models/projects.py @@ -254,13 +254,6 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn app_label = 'main' ordering = ('id',) - organization = models.ForeignKey( - 'Organization', - blank=True, - null=True, - on_delete=models.CASCADE, - related_name='projects', - ) scm_update_on_launch = models.BooleanField( default=False, help_text=_('Update the project when a job is launched that uses the project.'), @@ -329,7 +322,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn @classmethod def _get_unified_job_field_names(cls): return set(f.name for f in ProjectOptions._meta.fields) | set( - ['name', 'description'] + ['name', 'description', 'organization'] ) def save(self, *args, **kwargs): @@ -450,8 +443,8 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn ''' def _get_related_jobs(self): return UnifiedJob.objects.non_polymorphic().filter( - models.Q(Job___project=self) | - models.Q(ProjectUpdate___project=self) + models.Q(job__project=self) | + models.Q(projectupdate__project=self) ) def delete(self, *args, **kwargs): diff --git a/awx/main/models/unified_jobs.py b/awx/main/models/unified_jobs.py index 1b17291fba..1c2c6ffeed 100644 --- a/awx/main/models/unified_jobs.py +++ b/awx/main/models/unified_jobs.py @@ -157,6 +157,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio default='ok', editable=False, ) + organization = models.ForeignKey( + 'Organization', + blank=True, + null=True, + on_delete=polymorphic.SET_NULL, + related_name='%(class)ss', + help_text=_('The organization used to determine access to this template.'), + ) credentials = models.ManyToManyField( 'Credential', related_name='%(class)ss', @@ -700,6 +708,14 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique on_delete=polymorphic.SET_NULL, help_text=_('The Rampart/Instance group the job was run under'), ) + organization = models.ForeignKey( + 'Organization', + blank=True, + null=True, + on_delete=polymorphic.SET_NULL, + related_name='%(class)ss', + help_text=_('The organization used to determine access to this unified job.'), + ) credentials = models.ManyToManyField( 'Credential', related_name='%(class)ss', diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index ff75f260d7..df4772a20d 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -335,7 +335,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase): @classmethod def _get_unified_job_field_names(cls): r = set(f.name for f in WorkflowJobOptions._meta.fields) | set( - ['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch'] + ['name', 'description', 'organization', 'survey_passwords', 'labels', 'limit', 'scm_branch'] ) r.remove('char_prompts') # needed due to copying launch config to launch config return r @@ -382,13 +382,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl class Meta: app_label = 'main' - organization = models.ForeignKey( - 'Organization', - blank=True, - null=True, - on_delete=models.SET_NULL, - related_name='workflows', - ) ask_inventory_on_launch = AskForField( blank=True, default=False, diff --git a/awx/main/signals.py b/awx/main/signals.py index bc39fa85d0..27a6426eba 100644 --- a/awx/main/signals.py +++ b/awx/main/signals.py @@ -183,7 +183,6 @@ def connect_computed_field_signals(): connect_computed_field_signals() -post_save.connect(save_related_job_templates, sender=Project) post_save.connect(save_related_job_templates, sender=Inventory) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) m2m_changed.connect(rbac_activity_stream, Role.members.through) diff --git a/awx/main/tests/factories/fixtures.py b/awx/main/tests/factories/fixtures.py index 2f8cbe6934..0952738174 100644 --- a/awx/main/tests/factories/fixtures.py +++ b/awx/main/tests/factories/fixtures.py @@ -159,7 +159,8 @@ 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') + webhook_service=webhook_service, playbook='helloworld.yml', + organization=organization) 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 bfa7f9fc1b..dd412571e1 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) + persisted=persisted, webhook_service=webhook_service, organization=org) if 'jobs' in kwargs: for i in kwargs['jobs']: diff --git a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py index 880b7ff892..b837af5a84 100644 --- a/awx/main/tests/functional/api/test_deprecated_credential_assignment.py +++ b/awx/main/tests/functional/api/test_deprecated_credential_assignment.py @@ -278,6 +278,7 @@ def test_multi_vault_preserved_on_put(get, put, admin_user, job_template, vault_ job_template.credentials.add(vault_credential, vault2) assert job_template.credentials.count() == 2 # sanity check r = get(job_template.get_absolute_url(), admin_user, expect=200) + r.data.pop('organization') # so that it passes validation # should be a no-op PUT request put( job_template.get_absolute_url(), diff --git a/awx/main/tests/functional/api/test_job.py b/awx/main/tests/functional/api/test_job.py index 0692d70ffb..683f62dc43 100644 --- a/awx/main/tests/functional/api/test_job.py +++ b/awx/main/tests/functional/api/test_job.py @@ -39,6 +39,26 @@ def test_extra_credentials(get, organization_factory, job_template_factory, cred @pytest.mark.django_db def test_job_relaunch_permission_denied_response( post, get, inventory, project, credential, net_credential, machine_credential): + jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project, ask_credential_on_launch=True) + jt.credentials.add(machine_credential) + jt_user = User.objects.create(username='jobtemplateuser') + jt.execute_role.members.add(jt_user) + with impersonate(jt_user): + job = jt.create_unified_job() + + # User capability is shown for this + r = get(job.get_absolute_url(), jt_user, expect=200) + assert r.data['summary_fields']['user_capabilities']['start'] + + # 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'] + + +@pytest.mark.django_db +def test_job_relaunch_prompts_not_accepted_response( + post, get, inventory, project, credential, net_credential, machine_credential): jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project) jt.credentials.add(machine_credential) jt_user = User.objects.create(username='jobtemplateuser') @@ -53,8 +73,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' in r.data['detail'] - assert 'do not have permission' in r.data['detail'] + assert 'no longer accepts the prompts provided for this job' in r.data['detail'] @pytest.mark.django_db @@ -201,7 +220,8 @@ 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' + playbook='helloworld.yml', + organization=organization ) time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500") Job.objects.create( @@ -209,7 +229,8 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete, status='finished', finished=time_of_finish, job_template=job_template, - project=project + project=project, + organization=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 8ad864ee8e..2e56d8253b 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 +from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization from awx.main.migrations import _save_password_keys as save_password_keys # Django @@ -30,16 +30,55 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje project.use_role.members.add(alice) if grant_inventory: 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 + ) + + @pytest.mark.django_db def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws): objs = organization_factory("org", superusers=['admin']) @@ -524,13 +563,14 @@ def test_callback_disallowed_null_inventory(project): @pytest.mark.django_db -def test_job_template_branch_error(project, inventory, post, admin_user): +def test_job_template_branch_error(project, inventory, organization, 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" }, @@ -541,13 +581,14 @@ def test_job_template_branch_error(project, inventory, post, admin_user): @pytest.mark.django_db -def test_job_template_branch_prompt_error(project, inventory, post, admin_user): +def test_job_template_branch_prompt_error(project, inventory, post, organization, 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_organization_counts.py b/awx/main/tests/functional/api/test_organization_counts.py index 9c4f536b09..f5221ef5f1 100644 --- a/awx/main/tests/functional/api/test_organization_counts.py +++ b/awx/main/tests/functional/api/test_organization_counts.py @@ -2,6 +2,8 @@ import pytest from awx.api.versioning import reverse +from awx.main.models import Project + @pytest.fixture def organization_resource_creator(organization, user): @@ -19,21 +21,26 @@ def organization_resource_creator(organization, user): for i in range(inventories): inventory = organization.inventories.create(name="associated-inv %s" % i) for i in range(projects): - organization.projects.create(name="test-proj %s" % i, - description="test-proj-desc") + Project.objects.create( + name="test-proj %s" % i, + description="test-proj-desc", + organization=organization + ) # Mix up the inventories and projects used by the job templates i_proj = 0 i_inv = 0 for i in range(job_templates): - project = organization.projects.all()[i_proj] + project = Project.objects.filter(organization=organization)[i_proj] + # project = organization.projects.all()[i_proj] inventory = organization.inventories.all()[i_inv] project.jobtemplates.create(name="test-jt %s" % i, description="test-job-template-desc", inventory=inventory, - playbook="test_playbook.yml") + playbook="test_playbook.yml", + organization=organization) i_proj += 1 i_inv += 1 - if i_proj >= organization.projects.count(): + if i_proj >= Project.objects.filter(organization=organization).count(): i_proj = 0 if i_inv >= organization.inventories.count(): i_inv = 0 @@ -179,12 +186,14 @@ def test_scan_JT_counted(resourced_organization, user, get): @pytest.mark.django_db def test_JT_not_double_counted(resourced_organization, user, get): admin_user = user('admin', True) + proj = Project.objects.filter(organization=resourced_organization).all()[0] # Add a run job template to the org - resourced_organization.projects.all()[0].jobtemplates.create( + proj.jobtemplates.create( job_type='run', inventory=resourced_organization.inventories.all()[0], - project=resourced_organization.projects.all()[0], - name='double-linked-job-template') + project=proj, + name='double-linked-job-template', + organization=resourced_organization) counts_dict = COUNTS_PRIMES counts_dict['job_templates'] += 1 @@ -197,38 +206,3 @@ def test_JT_not_double_counted(resourced_organization, user, get): detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user) assert detail_response.status_code == 200 assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict - - -@pytest.mark.django_db -def test_JT_associated_with_project(organizations, project, user, get): - # Check that adding a project to an organization gets the project's JT - # included in the organization's JT count - external_admin = user('admin', True) - two_orgs = organizations(2) - organization = two_orgs[0] - other_org = two_orgs[1] - - unrelated_inv = other_org.inventories.create(name='not-in-organization') - organization.projects.add(project) - project.jobtemplates.create(name="test-jt", - description="test-job-template-desc", - inventory=unrelated_inv, - playbook="test_playbook.yml") - - response = get(reverse('api:organization_list'), external_admin) - assert response.status_code == 200 - - org_id = organization.id - counts = {} - for org_json in response.data['results']: - working_id = org_json['id'] - counts[working_id] = org_json['summary_fields']['related_field_counts'] - - assert counts[org_id] == { - 'users': 0, - 'admins': 0, - 'job_templates': 1, - 'projects': 1, - 'inventories': 0, - 'teams': 0 - } diff --git a/awx/main/tests/functional/api/test_rbac_displays.py b/awx/main/tests/functional/api/test_rbac_displays.py index 4180647d44..c3dd65d9c4 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) + project=project, organization=project.organization) return objects.job_template def fake_context(self, user): @@ -129,9 +129,8 @@ 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.project.save() + jt_copy_edit.organization.job_template_admin_role.members.add(rando) 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 faae3cce3c..1febd2f50e 100644 --- a/awx/main/tests/functional/api/test_unified_job_template.py +++ b/awx/main/tests/functional/api/test_unified_job_template.py @@ -1,6 +1,8 @@ import pytest from awx.api.versioning import reverse +from awx.main import models +from awx.main.utils import get_type_for_model @pytest.mark.django_db @@ -9,3 +11,111 @@ def test_aliased_forward_reverse_field_searches(instance, options, get, admin): response = options(url, None, admin) assert 'job_template__search' in response.data['related_search_fields'] get(reverse("api:unified_job_template_list") + "?job_template__search=anything", user=admin, expect=200) + + +@pytest.mark.django_db +@pytest.mark.parametrize('model', ( + 'Project', + 'JobTemplate', + 'WorkflowJobTemplate' +)) +class TestUnifiedOrganization: + + def data_for_model(self, model, orm_style=False): + data = { + 'name': 'foo', + 'organization': None + } + if model == 'JobTemplate': + proj = models.Project.objects.create( + name="test-proj", + playbook_files=['helloworld.yml'] + ) + if orm_style: + data['project_id'] = proj.id + else: + data['project'] = proj.id + data['playbook'] = 'helloworld.yml' + 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) + obj = cls.objects.create(**data) + patch( + url=obj.get_absolute_url(), + data={'name': 'foooooo'}, + user=admin_user, + expect=200 + ) + obj.refresh_from_db() + assert obj.name == 'foooooo' + + def test_organization_blank_on_edit_of_orphan_as_nonsuperuser(self, model, rando, patch): + """Test case reflects historical bug where ordinary users got weird error + message when editing an orphaned project + """ + cls = getattr(models, model) + data = self.data_for_model(model, orm_style=True) + obj = cls.objects.create(**data) + if model == 'JobTemplate': + obj.project.admin_role.members.add(rando) + obj.admin_role.members.add(rando) + patch( + url=obj.get_absolute_url(), + data={'name': 'foooooo'}, + user=rando, + expect=200 + ) + obj.refresh_from_db() + assert obj.name == 'foooooo' + + def test_organization_blank_on_edit_of_normal(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={'name': 'foooooo'}, + user=admin_user, + expect=200 + ) + 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 1b680eee8d..cae55c8562 100644 --- a/awx/main/tests/functional/conftest.py +++ b/awx/main/tests/functional/conftest.py @@ -75,24 +75,26 @@ def user(): @pytest.fixture -def check_jobtemplate(project, inventory, credential): +def check_jobtemplate(project, inventory, credential, organization): jt = JobTemplate.objects.create( job_type='check', project=project, inventory=inventory, - name='check-job-template' + name='check-job-template', + organization=organization ) jt.credentials.add(credential) return jt @pytest.fixture -def deploy_jobtemplate(project, inventory, credential): +def deploy_jobtemplate(project, inventory, credential, organization): jt = JobTemplate.objects.create( job_type='run', project=project, inventory=inventory, - name='deploy-job-template' + name='deploy-job-template', + organization=organization ) jt.credentials.add(credential) return jt @@ -180,8 +182,8 @@ def project_factory(organization): @pytest.fixture -def job_factory(job_template, admin): - def factory(job_template=job_template, initial_state='new', created_by=admin): +def job_factory(jt_linked, admin): + def factory(job_template=jt_linked, initial_state='new', created_by=admin): return job_template.create_unified_job(_eager_fields={ 'status': initial_state, 'created_by': created_by}) return factory @@ -701,11 +703,8 @@ def ad_hoc_command_factory(inventory, machine_credential, admin): @pytest.fixture -def job_template(organization): - jt = JobTemplate(name='test-job_template') - jt.save() - - return jt +def job_template(): + return JobTemplate.objects.create(name='test-job_template') @pytest.fixture @@ -717,20 +716,16 @@ def job_template_labels(organization, job_template): @pytest.fixture -def jt_linked(job_template_factory, credential, net_credential, vault_credential): +def jt_linked(organization, project, inventory, machine_credential, credential, net_credential, vault_credential): ''' A job template with a reasonably complete set of related objects to test RBAC and other functionality affected by related objects ''' - objects = job_template_factory( - 'testJT', organization='org1', project='proj1', inventory='inventory1', - credential='cred1') - jt = objects.job_template - jt.credentials.add(vault_credential) - jt.save() - # Add AWS cloud credential and network credential - jt.credentials.add(credential) - jt.credentials.add(net_credential) + jt = JobTemplate.objects.create( + project=project, inventory=inventory, playbook='helloworld.yml', + organization=organization + ) + jt.credentials.add(machine_credential, vault_credential, credential, net_credential) return jt diff --git a/awx/main/tests/functional/models/test_activity_stream.py b/awx/main/tests/functional/models/test_activity_stream.py index 0529b5377b..f220641759 100644 --- a/awx/main/tests/functional/models/test_activity_stream.py +++ b/awx/main/tests/functional/models/test_activity_stream.py @@ -12,6 +12,7 @@ from awx.main.models import ( CredentialType, Inventory, InventorySource, + Project, User ) @@ -99,8 +100,8 @@ class TestRolesAssociationEntries: ).count() == 1, 'In loop %s' % i def test_model_associations_are_recorded(self, organization): - proj1 = organization.projects.create(name='proj1') - proj2 = organization.projects.create(name='proj2') + proj1 = Project.objects.create(name='proj1', organization=organization) + proj2 = Project.objects.create(name='proj2', organization=organization) proj2.use_role.parents.add(proj1.admin_role) assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1 diff --git a/awx/main/tests/functional/models/test_job.py b/awx/main/tests/functional/models/test_job.py index 31b430d268..b097f85548 100644 --- a/awx/main/tests/functional/models/test_job.py +++ b/awx/main/tests/functional/models/test_job.py @@ -29,18 +29,19 @@ def test_prevent_slicing(): @pytest.mark.django_db -def test_awx_custom_virtualenv(inventory, project, machine_credential): +def test_awx_custom_virtualenv(inventory, project, machine_credential, organization): jt = JobTemplate.objects.create( name='my-jt', inventory=inventory, project=project, - playbook='helloworld.yml' + playbook='helloworld.yml', + organization=organization ) jt.credentials.add(machine_credential) job = jt.create_unified_job() - job.project.organization.custom_virtualenv = '/venv/fancy-org' - job.project.organization.save() + job.organization.custom_virtualenv = '/venv/fancy-org' + job.organization.save() assert job.ansible_virtualenv_path == '/venv/fancy-org' job.project.custom_virtualenv = '/venv/fancy-proj' diff --git a/awx/main/tests/functional/models/test_project.py b/awx/main/tests/functional/models/test_project.py index 719c37436e..3f57691ac3 100644 --- a/awx/main/tests/functional/models/test_project.py +++ b/awx/main/tests/functional/models/test_project.py @@ -39,3 +39,9 @@ def test_foreign_key_change_changes_modified_by(project, organization): assert project._get_fields_snapshot()['organization_id'] == organization.id project.organization = Organization(name='foo', pk=41) assert project._get_fields_snapshot()['organization_id'] == 41 + + +@pytest.mark.django_db +def test_project_related_jobs(project): + update = project.create_unified_job() + assert update.id in [u.id for u in project._get_related_jobs()] diff --git a/awx/main/tests/functional/test_copy.py b/awx/main/tests/functional/test_copy.py index 7be582d6c8..747f7754c6 100644 --- a/awx/main/tests/functional/test_copy.py +++ b/awx/main/tests/functional/test_copy.py @@ -11,10 +11,11 @@ from awx.main.tasks import deep_copy_model_obj @pytest.mark.django_db -def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential, +def test_job_template_copy(post, get, project, inventory, organization, 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) @@ -22,6 +23,7 @@ def test_job_template_copy(post, get, project, inventory, machine_credential, va 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 404b557227..a2a0a646ec 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 +from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate, Organization 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.project.organization.instance_groups.add(ig_org) + j.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,13 +320,14 @@ 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): - jt = JobTemplate.objects.create(inventory=inventory, project=project) - job = Job.objects.create(inventory=inventory, job_template=jt, project=project) + 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) 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()]) - project.organization.instance_groups.add(ig_org) + jt.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_named_url.py b/awx/main/tests/functional/test_named_url.py index 6ad6512d48..2f921470a5 100644 --- a/awx/main/tests/functional/test_named_url.py +++ b/awx/main/tests/functional/test_named_url.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import pytest from django.core.exceptions import ImproperlyConfigured @@ -26,7 +27,7 @@ def setup_module(module): def teardown_module(module): - # settings_registry will be persistent states unless we explicitly clean them up. + # settings_registry will be persistent states unless we explicitly clean them up. settings_registry.unregister('NAMED_URL_FORMATS') settings_registry.unregister('NAMED_URL_GRAPH_NODES') @@ -58,10 +59,25 @@ def test_organization(get, admin_user): @pytest.mark.django_db def test_job_template(get, admin_user): - test_jt = JobTemplate.objects.create(name='test_jt') + test_org = Organization.objects.create(name='test_org') + test_jt = JobTemplate.objects.create(name='test_jt', organization=test_org) url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk}) response = get(url, user=admin_user, expect=200) - assert response.data['related']['named_url'].endswith('/test_jt/') + assert response.data['related']['named_url'].endswith('/test_jt++test_org/') + + +@pytest.mark.django_db +def test_job_template_old_way(get, admin_user, mocker): + test_org = Organization.objects.create(name='test_org') + test_jt = JobTemplate.objects.create(name='test_jt ♥', organization=test_org) + url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk}) + + response = get(url, user=admin_user, expect=200) + new_url = response.data['related']['named_url'] + old_url = '/'.join([url.rsplit('/', 2)[0], test_jt.name, '']) + + assert URLModificationMiddleware._convert_named_url(new_url) == url + assert URLModificationMiddleware._convert_named_url(old_url) == url @pytest.mark.django_db diff --git a/awx/main/tests/functional/test_projects.py b/awx/main/tests/functional/test_projects.py index 2106c7d3f7..ef4b59630d 100644 --- a/awx/main/tests/functional/test_projects.py +++ b/awx/main/tests/functional/test_projects.py @@ -213,34 +213,14 @@ def test_project_credential_protection(post, put, project, organization, scm_cre }, org_admin, expect=403 ) post( - reverse('api:project_list'), { - 'name': 'should not create', - 'organization':organization.id, + reverse('api:project_list'), { + 'name': 'should not create', + 'organization':organization.id, 'credential': scm_credential.id }, org_admin, expect=403 ) -@pytest.mark.django_db() -def test_create_project_null_organization(post, organization, admin): - post(reverse('api:project_list'), { 'name': 't', 'organization': None}, admin, expect=201) - - -@pytest.mark.django_db() -def test_create_project_null_organization_xfail(post, organization, org_admin): - post(reverse('api:project_list'), { 'name': 't', 'organization': None}, org_admin, expect=403) - - -@pytest.mark.django_db() -def test_patch_project_null_organization(patch, organization, project, admin): - patch(reverse('api:project_detail', kwargs={'pk':project.id,}), { 'name': 't', 'organization': organization.id}, admin, expect=200) - - -@pytest.mark.django_db() -def test_patch_project_null_organization_xfail(patch, project, org_admin): - patch(reverse('api:project_detail', kwargs={'pk':project.id,}), { 'name': 't', 'organization': None}, org_admin, expect=400) - - @pytest.mark.django_db def test_cannot_schedule_manual_project(manual_project, admin_user, post): response = post( diff --git a/awx/main/tests/functional/test_rbac_job.py b/awx/main/tests/functional/test_rbac_job.py index 5dbe797f6c..16ff68e0ef 100644 --- a/awx/main/tests/functional/test_rbac_job.py +++ b/awx/main/tests/functional/test_rbac_job.py @@ -29,7 +29,8 @@ def normal_job(deploy_jobtemplate): return Job.objects.create( job_template=deploy_jobtemplate, project=deploy_jobtemplate.project, - inventory=deploy_jobtemplate.inventory + inventory=deploy_jobtemplate.inventory, + organization=deploy_jobtemplate.organization ) diff --git a/awx/main/tests/functional/test_rbac_job_start.py b/awx/main/tests/functional/test_rbac_job_start.py index d205d992d3..3c6d74a0a8 100644 --- a/awx/main/tests/functional/test_rbac_job_start.py +++ b/awx/main/tests/functional/test_rbac_job_start.py @@ -89,8 +89,8 @@ def test_slice_job(slice_job_factory, rando): @pytest.mark.django_db class TestJobRelaunchAccess: @pytest.fixture - def job_no_prompts(self, machine_credential, inventory): - jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory) + def job_no_prompts(self, machine_credential, inventory, organization): + jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory, organization=organization) jt.credentials.add(machine_credential) return jt.create_unified_job() @@ -119,6 +119,13 @@ class TestJobRelaunchAccess: job_no_prompts.job_template.execute_role.members.add(rando) assert rando.can_access(Job, 'start', job_no_prompts) + def test_orphan_relaunch_via_organization(self, job_no_prompts, rando, organization): + "JT for job has been deleted, relevant organization roles will allow management" + 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 + assert rando.can_access(Job, 'start', job_no_prompts) + 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) diff --git a/awx/main/tests/functional/test_rbac_job_templates.py b/awx/main/tests/functional/test_rbac_job_templates.py index 26d3628f9e..29910db3ae 100644 --- a/awx/main/tests/functional/test_rbac_job_templates.py +++ b/awx/main/tests/functional/test_rbac_job_templates.py @@ -24,6 +24,29 @@ def test_job_template_access_superuser(check_license, user, deploy_jobtemplate): assert access.can_add({}) +@pytest.mark.django_db +class TestImplicitAccess: + def test_org_execute(self, jt_linked, rando): + assert rando not in jt_linked.execute_role + jt_linked.organization.execute_role.members.add(rando) + assert rando in jt_linked.execute_role + + def test_org_admin(self, jt_linked, rando): + assert rando not in jt_linked.execute_role + jt_linked.organization.job_template_admin_role.members.add(rando) + assert rando in jt_linked.execute_role + + def test_org_auditor(self, jt_linked, rando): + assert rando not in jt_linked.read_role + jt_linked.organization.auditor_role.members.add(rando) + assert rando in jt_linked.read_role + + def test_deprecated_inventory_read(self, jt_linked, rando): + assert rando not in jt_linked.read_role + jt_linked.inventory.organization.execute_role.members.add(rando) + assert rando in jt_linked.read_role + + @pytest.mark.django_db def test_job_template_access_read_level(jt_linked, rando): ssh_cred = jt_linked.machine_credential @@ -45,22 +68,21 @@ def test_job_template_access_read_level(jt_linked, rando): @pytest.mark.django_db def test_job_template_access_use_level(jt_linked, rando): - ssh_cred = jt_linked.machine_credential - vault_cred = jt_linked.vault_credentials[0] - access = JobTemplateAccess(rando) jt_linked.project.use_role.members.add(rando) jt_linked.inventory.use_role.members.add(rando) - ssh_cred.use_role.members.add(rando) - vault_cred.use_role.members.add(rando) - + jt_linked.organization.job_template_admin_role.members.add(rando) proj_pk = jt_linked.project.pk - assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk)) - assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk)) - assert access.can_add(dict(vault_credential=vault_cred.pk, project=proj_pk)) + org_pk = jt_linked.organization_id + + assert access.can_change(jt_linked, {'job_type': 'check', 'project': proj_pk}) + assert access.can_change(jt_linked, {'job_type': 'check', 'inventory': None}) for cred in jt_linked.credentials.all(): - assert not access.can_unattach(jt_linked, cred, 'credentials', {}) + assert access.can_unattach(jt_linked, cred, 'credentials', {}) + + assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk, organization=org_pk)) + assert access.can_add(dict(project=proj_pk, organization=org_pk)) @pytest.mark.django_db @@ -69,22 +91,21 @@ def test_job_template_access_admin(role_names, jt_linked, rando): ssh_cred = jt_linked.machine_credential access = JobTemplateAccess(rando) - # Appoint this user as admin of the organization - #jt_linked.inventory.organization.admin_role.members.add(rando) + assert not access.can_read(jt_linked) assert not access.can_delete(jt_linked) - for role_name in role_names: - role = getattr(jt_linked.inventory.organization, role_name) - role.members.add(rando) + # Appoint this user as admin of the organization + jt_linked.organization.admin_role.members.add(rando) + org_pk = jt_linked.organization.id # Assign organization permission in the same way the create view does organization = jt_linked.inventory.organization ssh_cred.admin_role.parents.add(organization.admin_role) proj_pk = jt_linked.project.pk - assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk)) - assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk)) + assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk, organization=org_pk)) + assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk, organization=org_pk)) for cred in jt_linked.credentials.all(): assert access.can_unattach(jt_linked, cred, 'credentials', {}) @@ -148,26 +169,46 @@ class TestOrphanJobTemplate: @pytest.mark.django_db @pytest.mark.job_permissions -def test_job_template_creator_access(project, rando, post): +def test_job_template_creator_access(project, organization, rando, post): + project.use_role.members.add(rando) + organization.job_template_admin_role.members.add(rando) + response = post(url=reverse('api:job_template_list'), data=dict( + name='newly-created-jt', + ask_inventory_on_launch=True, + project=project.pk, + organization=organization.id, + playbook='helloworld.yml' + ), user=rando, expect=201) - project.admin_role.members.add(rando) - with mock.patch( - 'awx.main.models.projects.ProjectOptions.playbooks', - new_callable=mock.PropertyMock(return_value=['helloworld.yml'])): - response = post(reverse('api:job_template_list'), dict( - name='newly-created-jt', - job_type='run', - ask_inventory_on_launch=True, - ask_credential_on_launch=True, - project=project.pk, - playbook='helloworld.yml' - ), rando) - - assert response.status_code == 201 jt_pk = response.data['id'] jt_obj = JobTemplate.objects.get(pk=jt_pk) # Creating a JT should place the creator in the admin role - assert rando in jt_obj.admin_role + assert rando in jt_obj.admin_role.members.all() + + +@pytest.mark.django_db +@pytest.mark.job_permissions +@pytest.mark.parametrize('lacking', ['project', 'inventory', 'organization']) +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: + inventory.read_role.members.add(rando) + post(url=reverse('api:job_template_list'), data=dict( + name='newly-created-jt', + inventory=inventory.id, + project=project.pk, + organization=organization.id, + playbook='helloworld.yml' + ), user=rando, expect=403) @pytest.mark.django_db @@ -239,7 +280,7 @@ class TestJobTemplateSchedules: @pytest.mark.django_db def test_jt_org_ownership_change(user, jt_linked): admin1 = user('admin1') - org1 = jt_linked.project.organization + org1 = jt_linked.organization org1.admin_role.members.add(admin1) a1_access = JobTemplateAccess(admin1) @@ -254,10 +295,8 @@ def test_jt_org_ownership_change(user, jt_linked): assert not a2_access.can_read(jt_linked) - jt_linked.project.organization = org2 - jt_linked.project.save() - jt_linked.inventory.organization = org2 - jt_linked.inventory.save() + jt_linked.organization = org2 + jt_linked.save() assert a2_access.can_read(jt_linked) assert not a1_access.can_read(jt_linked) diff --git a/awx/main/tests/functional/test_rbac_migration.py b/awx/main/tests/functional/test_rbac_migration.py new file mode 100644 index 0000000000..19693ed5e7 --- /dev/null +++ b/awx/main/tests/functional/test_rbac_migration.py @@ -0,0 +1,64 @@ +import pytest + +from awx.main.migrations import _rbac as rbac +from awx.main.models import ( + UnifiedJobTemplate, + InventorySource, Inventory, + JobTemplate, Project, + Organization +) + + +@pytest.mark.django_db +def test_implied_organization_subquery_inventory(): + orgs = [] + for i in range(3): + orgs.append(Organization.objects.create(name='foo{}'.format(i))) + orgs.append(orgs[0]) + for i in range(4): + org = orgs[i] + if i == 2: + inventory = Inventory.objects.create(name='foo{}'.format(i)) + else: + inventory = Inventory.objects.create(name='foo{}'.format(i), organization=org) + inv_src = InventorySource.objects.create(name='foo{}'.format(i), inventory=inventory) + sources = UnifiedJobTemplate.objects.annotate( + test_field=rbac.implicit_org_subquery(UnifiedJobTemplate, InventorySource) + ) + for inv_src in sources: + assert inv_src.test_field == inv_src.inventory.organization_id + + +@pytest.mark.django_db +def test_implied_organization_subquery_job_template(): + jts = [] + for i in range(5): + if i <= 3: + org = Organization.objects.create(name='foo{}'.format(i)) + else: + org = None + if i <= 4: + proj = Project.objects.create( + name='foo{}'.format(i), + organization=org + ) + else: + proj = None + jts.append(JobTemplate.objects.create( + name='foo{}'.format(i), + project=proj + )) + # test case of sharing same org + jts[2].project.organization = jts[3].project.organization + jts[2].save() + ujts = UnifiedJobTemplate.objects.annotate( + test_field=rbac.implicit_org_subquery(UnifiedJobTemplate, JobTemplate) + ) + for jt in ujts: + if not isinstance(jt, JobTemplate): # some are projects + assert jt.test_field is None + else: + if jt.project is None: + assert jt.test_field is None + else: + assert jt.test_field == jt.project.organization_id diff --git a/awx/main/tests/functional/test_rbac_workflow.py b/awx/main/tests/functional/test_rbac_workflow.py index a53d769324..6e92082358 100644 --- a/awx/main/tests/functional/test_rbac_workflow.py +++ b/awx/main/tests/functional/test_rbac_workflow.py @@ -62,10 +62,11 @@ class TestWorkflowJobTemplateAccess: @pytest.mark.django_db class TestWorkflowJobTemplateNodeAccess: - def test_no_jt_access_to_edit(self, wfjt_node, org_admin): + def test_no_jt_access_to_edit(self, wfjt_node, rando): # without access to the related job template, admin to the WFJT can # not change the prompted parameters - access = WorkflowJobTemplateNodeAccess(org_admin) + wfjt_node.workflow_job_template.admin_role.members.add(rando) + access = WorkflowJobTemplateNodeAccess(rando) assert not access.can_change(wfjt_node, {'job_type': 'check'}) def test_node_edit_allowed(self, wfjt_node, org_admin): diff --git a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py index 4c0751ffbe..2287348d5f 100644 --- a/awx/main/tests/unit/api/serializers/test_job_template_serializers.py +++ b/awx/main/tests/unit/api/serializers/test_job_template_serializers.py @@ -30,6 +30,7 @@ def job_template(mocker): mock_jt.host_config_key = '9283920492' mock_jt.validation_errors = mock_JT_resource_data mock_jt.webhook_service = '' + mock_jt.organization_id = None return mock_jt 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 328b695371..4442770188 100644 --- a/awx/main/tests/unit/models/test_unified_job_unit.py +++ b/awx/main/tests/unit/models/test_unified_job_unit.py @@ -65,6 +65,14 @@ def test_cancel_job_explanation(unified_job): unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status', 'job_explanation']) +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() + + def test_log_representation(): ''' Common representation used inside of log messages diff --git a/awx/main/tests/unit/test_access.py b/awx/main/tests/unit/test_access.py index f95e6dbd4d..abef7df728 100644 --- a/awx/main/tests/unit/test_access.py +++ b/awx/main/tests/unit/test_access.py @@ -148,7 +148,9 @@ def job_template_with_ids(job_template_factory): 'testJT', project=proj, inventory=inv, credential=credential, cloud_credential=cloud_cred, network_credential=net_cred, persisted=False) - return jt_objects.job_template + jt = jt_objects.job_template + jt.organization = Organization(id=1, pk=1, name='fooOrg') + return jt def test_superuser(mocker): @@ -180,21 +182,24 @@ def test_jt_existing_values_are_nonsensitive(job_template_with_ids, user_unit): def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit): """Assure that can_add is called with all ForeignKeys.""" - job_template_with_ids.admin_role = Role() + class RoleReturnsTrue(Role): + def __contains__(self, accessor): + return True + + job_template_with_ids.admin_role = RoleReturnsTrue() + job_template_with_ids.organization.job_template_admin_role = RoleReturnsTrue() + + inv2 = Inventory() + inv2.use_role = RoleReturnsTrue() + data = {'inventory': inv2} - data = {'inventory': job_template_with_ids.inventory.id + 1} access = JobTemplateAccess(user_unit) - mock_add = mock.MagicMock(return_value=False) - with mock.patch('awx.main.models.rbac.Role.__contains__', return_value=True): - with mocker.patch('awx.main.access.JobTemplateAccess.can_add', mock_add): - with mocker.patch('awx.main.access.JobTemplateAccess.can_read', return_value=True): - assert not access.can_change(job_template_with_ids, data) + assert not access.changes_are_non_sensitive(job_template_with_ids, data) - mock_add.assert_called_once_with({ - 'inventory': data['inventory'], - 'project': job_template_with_ids.project.id - }) + job_template_with_ids.inventory.use_role = RoleReturnsTrue() + job_template_with_ids.project.use_role = RoleReturnsTrue() + assert access.can_change(job_template_with_ids, data) def mock_raise_none(self, add_host=False, feature=None, check_expiration=True): diff --git a/awx/main/tests/unit/test_fields.py b/awx/main/tests/unit/test_fields.py index 479d4728b3..429ab6faa0 100644 --- a/awx/main/tests/unit/test_fields.py +++ b/awx/main/tests/unit/test_fields.py @@ -2,10 +2,17 @@ import pytest from django.core.exceptions import ValidationError +from django.apps import apps +from django.db.models.fields.related import ForeignKey +from django.db.models.fields.related_descriptors import ( + ReverseManyToOneDescriptor, + ForwardManyToOneDescriptor +) + from rest_framework.serializers import ValidationError as DRFValidationError from awx.main.models import Credential, CredentialType, BaseModel -from awx.main.fields import JSONSchemaField +from awx.main.fields import JSONSchemaField, ImplicitRoleField, ImplicitRoleDescriptor @pytest.mark.parametrize('schema, given, message', [ @@ -194,3 +201,57 @@ def test_credential_creation_validation_failure(inputs): with pytest.raises(Exception) as e: field.validate(inputs, cred) assert e.type in (ValidationError, DRFValidationError) + + +def test_implicit_role_field_parents(): + """This assures that every ImplicitRoleField only references parents + which are relationships that actually exist + """ + app_models = apps.get_app_config('main').get_models() + for cls in app_models: + for field in cls._meta.get_fields(): + if not isinstance(field, ImplicitRoleField): + continue + + if not field.parent_role: + continue + + field_names = field.parent_role + if type(field_names) is not list: + field_names = [field_names] + + for field_name in field_names: + # this type of specification appears to have been considered + # at some point, but does not exist in the app and would + # need support and tests built out for it + assert not isinstance(field_name, tuple) + # also used to be a thing before py3 upgrade + assert not isinstance(field_name, bytes) + # this is always coherent + if field_name.startswith('singleton:'): + continue + # separate out parent role syntax + field_name, sep, field_attr = field_name.partition('.') + # now make primary assertion, that specified paths exist + assert hasattr(cls, field_name) + + # inspect in greater depth + second_field = cls._meta.get_field(field_name) + second_field_descriptor = getattr(cls, field_name) + # all supported linkage types + assert isinstance(second_field_descriptor, ( + ReverseManyToOneDescriptor, # not currently used + ImplicitRoleDescriptor, + ForwardManyToOneDescriptor + )) + # only these links are supported + if field_attr: + if isinstance(second_field_descriptor, ReverseManyToOneDescriptor): + assert type(second_field) is ForeignKey + rel_model = cls._meta.get_field(field_name).related_model + third_field = getattr(rel_model, field_attr) + # expecting for related_model.foo_role, test role field type + assert isinstance(third_field, ImplicitRoleDescriptor) + else: + # expecting simple format of foo_role + assert type(second_field) is ImplicitRoleField diff --git a/awx/ui/client/src/templates/job_templates/job-template.form.js b/awx/ui/client/src/templates/job_templates/job-template.form.js index 6f1f8cf025..953dcbbd55 100644 --- a/awx/ui/client/src/templates/job_templates/job-template.form.js +++ b/awx/ui/client/src/templates/job_templates/job-template.form.js @@ -43,6 +43,19 @@ function(NotificationsList, i18n) { column: 1, ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' }, + organization: { + label: i18n._('Organization'), + type: 'lookup', + list: 'OrganizationList', + sourceModel: 'organization', + basePath: 'organizations', + sourceField: 'name', + dataTitle: i18n._('Organization'), + dataContainer: 'body', + dataPlacement: 'right', + ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)', + awLookupWhen: '(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' + }, job_type: { label: i18n._('Job Type'), type: 'select', diff --git a/awxkit/awxkit/api/pages/job_templates.py b/awxkit/awxkit/api/pages/job_templates.py index 11d46cfbfa..4060da5f2c 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 +from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate, Organization 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 = [Inventory, Credential, Project] + optional_dependencies = [Organization, Inventory, Credential, Project] def launch(self, payload={}): """Launch the job_template using related->launch endpoint.""" @@ -129,6 +129,7 @@ class JobTemplate( playbook='ping.yml', credential=Credential, inventory=Inventory, + organization=Organization, project=None, **kwargs): if not project: @@ -148,12 +149,18 @@ 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, @@ -169,11 +176,12 @@ 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, **kwargs) + project=project, organization=organization, **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 1d67a0a171..b5b169340e 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): - optional_dependencies = [Organization] + 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=None, **kwargs): + def create_payload(self, name='', description='', organization=Organization, **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=None, **kwargs): + def create(self, name='', description='', organization=Organization, **kwargs): payload = self.create_payload(name=name, description=description, organization=organization, **kwargs) return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))