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
This commit is contained in:
AlanCoding
2020-01-16 14:59:43 -05:00
parent 1876849d89
commit daa9282790
46 changed files with 985 additions and 377 deletions

View File

@@ -548,6 +548,15 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView):
}) })
return d 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): def create(self, request, *args, **kwargs):
# If the object ID was not specified, it probably doesn't exist in the # 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 # DB yet. We want to see if we can create it. The URL may choose to

View File

@@ -642,7 +642,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
_capabilities_prefetch = [ _capabilities_prefetch = [
'admin', 'execute', 'admin', 'execute',
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use', {'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
'workflowjobtemplate.organization.workflow_admin']} 'organization.workflow_admin']}
] ]
class Meta: class Meta:
@@ -700,6 +700,18 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
else: else:
return super(UnifiedJobTemplateSerializer, self).to_representation(obj) 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): class UnifiedJobSerializer(BaseSerializer):
show_capabilities = ['start', 'delete'] show_capabilities = ['start', 'delete']
@@ -1387,12 +1399,6 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
def get_field_from_model_or_attrs(fd): def get_field_from_model_or_attrs(fd):
return attrs.get(fd, self.instance and getattr(self.instance, fd) or None) 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: if 'allow_override' in attrs and self.instance:
# case where user is turning off this project setting # case where user is turning off this project setting
if self.instance.allow_override and not attrs['allow_override']: 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]) ' '.join([str(pk) for pk in used_by])
)}) )})
view = self.context.get('view', None) if get_field_from_model_or_attrs('scm_type') == '':
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') == '':
for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_clean'): for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_clean'):
if get_field_from_model_or_attrs(fd): if get_field_from_model_or_attrs(fd):
raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')}) 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', fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'scm_branch',
'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags', 'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags',
'force_handlers', 'skip_tags', 'start_at_task', 'timeout', 'force_handlers', 'skip_tags', 'start_at_task', 'timeout',
'use_fact_cache',) 'use_fact_cache', 'organization',)
def get_related(self, obj): def get_related(self, obj):
res = super(JobOptionsSerializer, self).get_related(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}) res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk})
except ObjectDoesNotExist: except ObjectDoesNotExist:
setattr(obj, 'project', None) setattr(obj, 'project', None)
if obj.organization_id:
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id})
if isinstance(obj, UnifiedJobTemplate): if isinstance(obj, UnifiedJobTemplate):
res['extra_credentials'] = self.reverse( res['extra_credentials'] = self.reverse(
'api:job_template_extra_credentials_list', 'api:job_template_extra_credentials_list',
@@ -2899,6 +2903,8 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
) )
if obj.host_config_key: if obj.host_config_key:
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk}) 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 return res
def validate(self, attrs): def validate(self, attrs):

View File

@@ -10,6 +10,7 @@ from awx.api.views import (
OrganizationAdminsList, OrganizationAdminsList,
OrganizationInventoriesList, OrganizationInventoriesList,
OrganizationProjectsList, OrganizationProjectsList,
OrganizationJobTemplatesList,
OrganizationWorkflowJobTemplatesList, OrganizationWorkflowJobTemplatesList,
OrganizationTeamsList, OrganizationTeamsList,
OrganizationCredentialList, OrganizationCredentialList,
@@ -33,6 +34,7 @@ urls = [
url(r'^(?P<pk>[0-9]+)/admins/$', OrganizationAdminsList.as_view(), name='organization_admins_list'), url(r'^(?P<pk>[0-9]+)/admins/$', OrganizationAdminsList.as_view(), name='organization_admins_list'),
url(r'^(?P<pk>[0-9]+)/inventories/$', OrganizationInventoriesList.as_view(), name='organization_inventories_list'), url(r'^(?P<pk>[0-9]+)/inventories/$', OrganizationInventoriesList.as_view(), name='organization_inventories_list'),
url(r'^(?P<pk>[0-9]+)/projects/$', OrganizationProjectsList.as_view(), name='organization_projects_list'), url(r'^(?P<pk>[0-9]+)/projects/$', OrganizationProjectsList.as_view(), name='organization_projects_list'),
url(r'^(?P<pk>[0-9]+)/job_templates/$', OrganizationJobTemplatesList.as_view(), name='organization_job_templates_list'),
url(r'^(?P<pk>[0-9]+)/workflow_job_templates/$', OrganizationWorkflowJobTemplatesList.as_view(), name='organization_workflow_job_templates_list'), url(r'^(?P<pk>[0-9]+)/workflow_job_templates/$', OrganizationWorkflowJobTemplatesList.as_view(), name='organization_workflow_job_templates_list'),
url(r'^(?P<pk>[0-9]+)/teams/$', OrganizationTeamsList.as_view(), name='organization_teams_list'), url(r'^(?P<pk>[0-9]+)/teams/$', OrganizationTeamsList.as_view(), name='organization_teams_list'),
url(r'^(?P<pk>[0-9]+)/credentials/$', OrganizationCredentialList.as_view(), name='organization_credential_list'), url(r'^(?P<pk>[0-9]+)/credentials/$', OrganizationCredentialList.as_view(), name='organization_credential_list'),

View File

@@ -111,6 +111,7 @@ from awx.api.views.organization import ( # noqa
OrganizationUsersList, OrganizationUsersList,
OrganizationAdminsList, OrganizationAdminsList,
OrganizationProjectsList, OrganizationProjectsList,
OrganizationJobTemplatesList,
OrganizationWorkflowJobTemplatesList, OrganizationWorkflowJobTemplatesList,
OrganizationTeamsList, OrganizationTeamsList,
OrganizationActivityStreamList, OrganizationActivityStreamList,

View File

@@ -4,10 +4,7 @@
import dateutil import dateutil
import logging import logging
from django.db.models import ( from django.db.models import Count
Count,
F,
)
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.timezone import now from django.utils.timezone import now
@@ -175,28 +172,18 @@ class OrganizationCountsMixin(object):
inv_qs = Inventory.accessible_objects(self.request.user, 'read_role') inv_qs = Inventory.accessible_objects(self.request.user, 'read_role')
project_qs = Project.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 # Produce counts of Foreign Key relationships
db_results['inventories'] = inv_qs\ db_results['inventories'] = inv_qs.values('organization').annotate(Count('organization')).order_by('organization')
.values('organization').annotate(Count('organization')).order_by('organization')
db_results['teams'] = Team.accessible_objects( db_results['teams'] = Team.accessible_objects(
self.request.user, 'read_role').values('organization').annotate( self.request.user, 'read_role').values('organization').annotate(
Count('organization')).order_by('organization') Count('organization')).order_by('organization')
JT_project_reference = 'project__organization' db_results['job_templates'] = jt_qs.values('organization').annotate(Count('organization')).order_by('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_inventory'] = JobTemplate.accessible_objects( db_results['projects'] = project_qs.values('organization').annotate(Count('organization')).order_by('organization')
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')
# Other members and admins of organization are always viewable # Other members and admins of organization are always viewable
db_results['users'] = org_qs.annotate( db_results['users'] = org_qs.annotate(
@@ -212,11 +199,7 @@ class OrganizationCountsMixin(object):
'admins': 0, 'projects': 0} 'admins': 0, 'projects': 0}
for res, count_qs in db_results.items(): for res, count_qs in db_results.items():
if res == 'job_templates_project': if res == 'users':
org_reference = JT_project_reference
elif res == 'job_templates_inventory':
org_reference = JT_inventory_reference
elif res == 'users':
org_reference = 'id' org_reference = 'id'
else: else:
org_reference = 'organization' org_reference = 'organization'
@@ -229,14 +212,6 @@ class OrganizationCountsMixin(object):
continue continue
count_context[org_id][res] = entry['%s__count' % org_reference] 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 full_context['related_field_counts'] = count_context
return full_context return full_context

View File

@@ -20,7 +20,7 @@ from awx.main.models import (
Role, Role,
User, User,
Team, Team,
InstanceGroup, InstanceGroup
) )
from awx.api.generics import ( from awx.api.generics import (
ListCreateAPIView, ListCreateAPIView,
@@ -28,6 +28,7 @@ from awx.api.generics import (
SubListAPIView, SubListAPIView,
SubListCreateAttachDetachAPIView, SubListCreateAttachDetachAPIView,
SubListAttachDetachAPIView, SubListAttachDetachAPIView,
SubListCreateAPIView,
ResourceAccessList, ResourceAccessList,
BaseUsersList, BaseUsersList,
) )
@@ -35,14 +36,13 @@ from awx.api.generics import (
from awx.api.serializers import ( from awx.api.serializers import (
OrganizationSerializer, OrganizationSerializer,
InventorySerializer, InventorySerializer,
ProjectSerializer,
UserSerializer, UserSerializer,
TeamSerializer, TeamSerializer,
ActivityStreamSerializer, ActivityStreamSerializer,
RoleSerializer, RoleSerializer,
NotificationTemplateSerializer, NotificationTemplateSerializer,
WorkflowJobTemplateSerializer,
InstanceGroupSerializer, InstanceGroupSerializer,
ProjectSerializer, JobTemplateSerializer, WorkflowJobTemplateSerializer
) )
from awx.api.views.mixin import ( from awx.api.views.mixin import (
RelatedJobsPreventDeleteMixin, RelatedJobsPreventDeleteMixin,
@@ -94,7 +94,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI
org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter( org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter(
organization__id=org_id).count() organization__id=org_id).count()
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter( 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'] = {}
full_context['related_field_counts'][org_id] = org_counts full_context['related_field_counts'][org_id] = org_counts
@@ -128,21 +128,27 @@ class OrganizationAdminsList(BaseUsersList):
ordering = ('username',) ordering = ('username',)
class OrganizationProjectsList(SubListCreateAttachDetachAPIView): class OrganizationProjectsList(SubListCreateAPIView):
model = Project model = Project
serializer_class = ProjectSerializer serializer_class = ProjectSerializer
parent_model = Organization parent_model = Organization
relationship = 'projects'
parent_key = 'organization' 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 model = WorkflowJobTemplate
serializer_class = WorkflowJobTemplateSerializer serializer_class = WorkflowJobTemplateSerializer
parent_model = Organization parent_model = Organization
relationship = 'workflows'
parent_key = 'organization' parent_key = 'organization'

View File

@@ -1411,7 +1411,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
''' '''
model = JobTemplate model = JobTemplate
select_related = ('created_by', 'modified_by', 'inventory', 'project', select_related = ('created_by', 'modified_by', 'inventory', 'project', 'organization',
'next_schedule',) 'next_schedule',)
prefetch_related = ( prefetch_related = (
'instance_groups', '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. 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 if not data: # So the browseable API will work
return ( return Organization.accessible_objects(self.user, 'job_template_admin_role').exists()
Project.accessible_objects(self.user, 'use_role').exists() or
Inventory.accessible_objects(self.user, 'use_role').exists())
# if reference_obj is provided, determine if it can be copied # if reference_obj is provided, determine if it can be copied
reference_obj = data.get('reference_obj', None) reference_obj = data.get('reference_obj', None)
@@ -1467,6 +1465,10 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
if self.user not in inventory.use_role: if self.user not in inventory.use_role:
return False 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') project = get_value(Project, 'project')
# If the user has admin access to the project (as an org admin), should # If the user has admin access to the project (as an org admin), should
# be able to proceed without additional checks. # be able to proceed without additional checks.
@@ -1504,22 +1506,31 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
return self.user in obj.execute_role return self.user in obj.execute_role
def can_change(self, obj, data): def can_change(self, obj, data):
data_for_change = data
if self.user not in obj.admin_role and not self.user.is_superuser: if self.user not in obj.admin_role and not self.user.is_superuser:
return False return False
if data is not None: if data is None:
data = dict(data) return True
if self.changes_are_non_sensitive(obj, data): # standard type of check for organization - cannot change the value
if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']: # unless posessing the respective job_template_admin_role, otherwise non-blocking
self.check_license(feature='surveys') if not self.check_related('organization', Organization, data, obj=obj, role_field='job_template_admin_role'):
return True return False
for required_field in ('inventory', 'project'): data = dict(data)
required_obj = getattr(obj, required_field, None)
if required_field not in data_for_change and required_obj is not None: if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']:
data_for_change[required_field] = required_obj.pk self.check_license(feature='surveys')
return self.can_read(obj) and (self.can_add(data_for_change) if data is not None else True)
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): def changes_are_non_sensitive(self, obj, data):
''' '''
@@ -1554,9 +1565,9 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
@check_superuser @check_superuser
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False): def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
if relationship == "instance_groups": if relationship == "instance_groups":
if not obj.project.organization: if not obj.organization:
return False 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): if relationship == 'credentials' and isinstance(sub_obj, Credential):
return self.user in obj.admin_role and self.user in sub_obj.use_role return self.user in obj.admin_role and self.user in sub_obj.use_role
return super(JobTemplateAccess, self).can_attach( return super(JobTemplateAccess, self).can_attach(
@@ -1587,6 +1598,7 @@ class JobAccess(BaseAccess):
select_related = ('created_by', 'modified_by', 'job_template', 'inventory', select_related = ('created_by', 'modified_by', 'job_template', 'inventory',
'project', 'project_update',) 'project', 'project_update',)
prefetch_related = ( prefetch_related = (
'organization',
'unified_job_template', 'unified_job_template',
'instance_group', 'instance_group',
'credentials__credential_type', 'credentials__credential_type',
@@ -1607,42 +1619,19 @@ class JobAccess(BaseAccess):
return qs.filter( return qs.filter(
Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) | Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) |
Q(inventory__organization__in=org_access_qs) | Q(organization__in=org_access_qs)).distinct()
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
def can_add(self, data, validate_license=True): def can_add(self, data, validate_license=True):
if validate_license: raise NotImplementedError('Direct job creation not possible in v2 API')
self.check_license()
if not data: # So the browseable API will work
return True
return self.user.is_superuser
def can_change(self, obj, data): def can_change(self, obj, data):
return (obj.status == 'new' and raise NotImplementedError('Direct job editing not supported in v2 API')
self.can_read(obj) and
self.can_add(data, validate_license=False))
@check_superuser @check_superuser
def can_delete(self, obj): 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): def can_start(self, obj, validate_license=True):
if validate_license: if validate_license:
@@ -1662,6 +1651,7 @@ class JobAccess(BaseAccess):
except JobLaunchConfig.DoesNotExist: except JobLaunchConfig.DoesNotExist:
config = None config = None
# Standard permissions model (1)
if obj.job_template and (self.user not in obj.job_template.execute_role): if obj.job_template and (self.user not in obj.job_template.execute_role):
return False return False
@@ -1676,24 +1666,15 @@ class JobAccess(BaseAccess):
if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}): if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
return True return True
org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role # Standard permissions model (2)
project_access = obj.project is None or self.user in obj.project.admin_role if obj.organization and self.user in obj.organization.execute_role:
credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()]) # 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 return False
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
def get_method_capability(self, method, obj, parent_obj): def get_method_capability(self, method, obj, parent_obj):
if method == 'start': if method == 'start':
@@ -1706,10 +1687,16 @@ class JobAccess(BaseAccess):
def can_cancel(self, obj): def can_cancel(self, obj):
if not obj.can_cancel: if not obj.can_cancel:
return False return False
# Delete access allows org admins to stop running jobs # Users may always cancel their own jobs
if self.user == obj.created_by or self.can_delete(obj): if self.user == obj.created_by:
return True 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): class SystemJobTemplateAccess(BaseAccess):
@@ -1944,11 +1931,11 @@ class WorkflowJobNodeAccess(BaseAccess):
# TODO: notification attachments? # TODO: notification attachments?
class WorkflowJobTemplateAccess(NotificationAttachMixin, BaseAccess): 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 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',) 'admin_role', 'execute_role', 'read_role',)
def filtered_queryset(self): def filtered_queryset(self):
@@ -2038,7 +2025,7 @@ class WorkflowJobAccess(BaseAccess):
I can also cancel it if I started it I can also cancel it if I started it
''' '''
model = WorkflowJob model = WorkflowJob
select_related = ('created_by', 'modified_by',) select_related = ('created_by', 'modified_by', 'organization',)
def filtered_queryset(self): def filtered_queryset(self):
return WorkflowJob.objects.filter( return WorkflowJob.objects.filter(
@@ -2332,6 +2319,7 @@ class UnifiedJobTemplateAccess(BaseAccess):
prefetch_related = ( prefetch_related = (
'last_job', 'last_job',
'current_job', 'current_job',
'organization',
'credentials__credential_type', 'credentials__credential_type',
Prefetch('labels', queryset=Label.objects.all().order_by('name')), Prefetch('labels', queryset=Label.objects.all().order_by('name')),
) )
@@ -2371,6 +2359,7 @@ class UnifiedJobAccess(BaseAccess):
prefetch_related = ( prefetch_related = (
'created_by', 'created_by',
'modified_by', 'modified_by',
'organization',
'unified_job_node__workflow_job', 'unified_job_node__workflow_job',
'unified_job_template', 'unified_job_template',
'instance_group', 'instance_group',
@@ -2401,8 +2390,7 @@ class UnifiedJobAccess(BaseAccess):
Q(unified_job_template_id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) | 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(inventoryupdate__inventory_source__inventory__id__in=inv_pk_qs) |
Q(adhoccommand__inventory__id__in=inv_pk_qs) | Q(adhoccommand__inventory__id__in=inv_pk_qs) |
Q(job__inventory__organization__in=org_auditor_qs) | Q(organization__in=org_auditor_qs)
Q(job__project__organization__in=org_auditor_qs)
) )
return qs return qs

View File

@@ -56,7 +56,8 @@ from awx.main import utils
__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField', __all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField',
'SmartFilterField', 'OrderedManyToManyField', '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 # Provide a (better) custom error message for enum jsonschema validation
@@ -140,8 +141,9 @@ def resolve_role_field(obj, field):
return [] return []
if len(field_components) == 1: if len(field_components) == 1:
role_cls = str(utils.get_current_apps().get_model('main', 'Role')) # use extremely generous duck typing to accomidate all possible forms
if not str(type(obj)) == role_cls: # 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)))) raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj))))
ret.append(obj.id) ret.append(obj.id)
else: else:
@@ -197,18 +199,30 @@ def update_role_parentage_for_instance(instance):
updates the parents listing for all the roles updates the parents listing for all the roles
of a given instance if they have changed of a given instance if they have changed
''' '''
changed_ct = 0
for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'): for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'):
changed = False
cur_role = getattr(instance, implicit_role_field.name) cur_role = getattr(instance, implicit_role_field.name)
original_parents = set(json.loads(cur_role.implicit_parents)) original_parents = set(json.loads(cur_role.implicit_parents))
new_parents = implicit_role_field._resolve_parent_roles(instance) new_parents = implicit_role_field._resolve_parent_roles(instance)
cur_role.parents.remove(*list(original_parents - new_parents)) removals = original_parents - new_parents
cur_role.parents.add(*list(new_parents - original_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 = list(new_parents)
new_parents_list.sort() new_parents_list.sort()
new_parents_json = json.dumps(new_parents_list) new_parents_json = json.dumps(new_parents_list)
if cur_role.implicit_parents != new_parents_json: if cur_role.implicit_parents != new_parents_json:
changed = True
cur_role.implicit_parents = new_parents_json cur_role.implicit_parents = new_parents_json
cur_role.save() cur_role.save()
if changed:
changed_ct += 1
return changed_ct
class ImplicitRoleDescriptor(ForwardManyToOneDescriptor): class ImplicitRoleDescriptor(ForwardManyToOneDescriptor):
@@ -256,20 +270,18 @@ class ImplicitRoleField(models.ForeignKey):
field_names = [field_names] field_names = [field_names]
for field_name in 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:'): if field_name.startswith('singleton:'):
continue continue
field_name, sep, field_attr = field_name.partition('.') 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: type(field) is ManyToManyDescriptor:
if '.' in field_attr: if '.' in field_attr:

View File

@@ -192,21 +192,41 @@ class URLModificationMiddleware(MiddlewareMixin):
) )
super().__init__(get_response) super().__init__(get_response)
def _named_url_to_pk(self, node, named_url): @staticmethod
kwargs = {} def _hijack_for_old_jt_name(node, kwargs, named_url):
if not node.populate_named_url_query_kwargs(kwargs, named_url): try:
return named_url int(named_url)
return str(get_object_or_404(node.model, **kwargs).pk) 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('/') url_units = url_path.split('/')
# If the identifier is an empty string, it is always invalid. # 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]: 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 return url_path
resource = url_units[3] resource = url_units[3]
if resource in settings.NAMED_URL_MAPPINGS: 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] = cls._named_url_to_pk(
url_units[4]) settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]],
resource, url_units[4])
return '/'.join(url_units) return '/'.join(url_units)
def process_request(self, request): def process_request(self, request):

View File

@@ -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),
]

View File

@@ -1,6 +1,9 @@
import logging import logging
from time import time 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 from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
logger = logging.getLogger('rbac_migrations') 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 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 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 .save() to ensure all that happens for every object in the system.
get busy with the actual migration work.
This gets run after migrate_users, which does role creation for users a This can be used whenever new roles are introduced in a migration to
little differently. create those roles for pre-existing objects that did not previously
have them created via signals.
''' '''
models = [ models = [
@@ -35,7 +38,118 @@ def create_roles(apps, schema_editor):
obj.save() 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): 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..') logger.info('Computing role roots..')
start = time() start = time()
roots = Role.objects \ roots = Role.objects \
@@ -46,14 +160,57 @@ def rebuild_role_hierarchy(apps, schema_editor):
start = time() start = time()
Role.rebuild_role_ancestor_list(roots, []) Role.rebuild_role_ancestor_list(roots, [])
stop = time() stop = time()
logger.info('Rebuild completed in %f seconds' % (stop - start)) logger.info('Rebuild ancestors completed in %f seconds' % (stop - start))
logger.info('Done.') logger.info('Done.')
def delete_all_user_roles(apps, schema_editor): def rebuild_role_parentage(apps, schema_editor):
ContentType = apps.get_model('contenttypes', "ContentType") '''
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") Role = apps.get_model('main', "Role")
User = apps.get_model('auth', "User") for role in Role.objects.iterator():
user_content_type = ContentType.objects.get_for_model(User) if not role.object_id:
for role in Role.objects.filter(content_type=user_content_type).iterator(): noop_ct += 1
role.delete() 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)

View File

@@ -426,9 +426,9 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
''' '''
def _get_related_jobs(self): def _get_related_jobs(self):
return UnifiedJob.objects.non_polymorphic().filter( return UnifiedJob.objects.non_polymorphic().filter(
Q(Job___inventory=self) | Q(job__inventory=self) |
Q(InventoryUpdate___inventory_source__inventory=self) | Q(inventoryupdate__inventory=self) |
Q(AdHocCommand___inventory=self) Q(adhoccommand__inventory=self)
) )
@@ -808,8 +808,8 @@ class Group(CommonModelNameNotUnique, RelatedJobsMixin):
''' '''
def _get_related_jobs(self): def _get_related_jobs(self):
return UnifiedJob.objects.non_polymorphic().filter( return UnifiedJob.objects.non_polymorphic().filter(
Q(Job___inventory=self.inventory) | Q(job__inventory=self.inventory) |
Q(InventoryUpdate___inventory_source__groups=self) Q(inventoryupdate__inventory_source__groups=self)
) )
@@ -1277,10 +1277,14 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
@classmethod @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in InventorySourceOptions._meta.fields) | set( 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): 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 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. # if it hasn't been specified, then we're just doing a normal save.
update_fields = kwargs.get('update_fields', []) update_fields = kwargs.get('update_fields', [])

View File

@@ -199,7 +199,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
'labels', 'instance_groups', 'credentials', 'survey_spec' 'labels', 'instance_groups', 'credentials', 'survey_spec'
] ]
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential'] FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')] SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
class Meta: class Meta:
app_label = 'main' app_label = 'main'
@@ -262,13 +262,17 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
) )
admin_role = ImplicitRoleField( 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( 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( 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 @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in JobOptions._meta.fields) | set( 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'] 'job_slice_number', 'job_slice_count']
) )
@@ -479,13 +483,13 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
success_notification_templates = list(base_notification_templates.filter( success_notification_templates = list(base_notification_templates.filter(
unifiedjobtemplate_notification_templates_for_success__in=[self, self.project])) unifiedjobtemplate_notification_templates_for_success__in=[self, self.project]))
# Get Organization NotificationTemplates # 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( 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( 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( 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), return dict(error=list(error_notification_templates),
started=list(started_notification_templates), started=list(started_notification_templates),
success=list(success_notification_templates)) success=list(success_notification_templates))
@@ -588,7 +592,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
for virtualenv in ( for virtualenv in (
self.job_template.custom_virtualenv if self.job_template else None, self.job_template.custom_virtualenv if self.job_template else None,
self.project.custom_virtualenv, 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: if virtualenv:
return virtualenv return virtualenv
@@ -741,8 +745,8 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
@property @property
def preferred_instance_groups(self): def preferred_instance_groups(self):
if self.project is not None and self.project.organization is not None: if self.organization is not None:
organization_groups = [x for x in self.project.organization.instance_groups.all()] organization_groups = [x for x in self.organization.instance_groups.all()]
else: else:
organization_groups = [] organization_groups = []
if self.inventory is not None: if self.inventory is not None:
@@ -1144,7 +1148,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions):
@classmethod @classmethod
def _get_unified_job_field_names(cls): 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): def get_absolute_url(self, request=None):
return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request) return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request)

View File

@@ -6,7 +6,6 @@
# Django # Django
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
from django.db.models import Q
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.sessions.models import Session from django.contrib.sessions.models import Session
from django.utils.timezone import now as tz_now from django.utils.timezone import now as tz_now
@@ -106,12 +105,7 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
RelatedJobsMixin RelatedJobsMixin
''' '''
def _get_related_jobs(self): def _get_related_jobs(self):
project_ids = self.projects.all().values_list('id') return UnifiedJob.objects.non_polymorphic().filter(organization=self)
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)
)
class Team(CommonModelNameNotUnique, ResourceMixin): class Team(CommonModelNameNotUnique, ResourceMixin):

View File

@@ -254,13 +254,6 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
app_label = 'main' app_label = 'main'
ordering = ('id',) ordering = ('id',)
organization = models.ForeignKey(
'Organization',
blank=True,
null=True,
on_delete=models.CASCADE,
related_name='projects',
)
scm_update_on_launch = models.BooleanField( scm_update_on_launch = models.BooleanField(
default=False, default=False,
help_text=_('Update the project when a job is launched that uses the project.'), 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 @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
return set(f.name for f in ProjectOptions._meta.fields) | set( return set(f.name for f in ProjectOptions._meta.fields) | set(
['name', 'description'] ['name', 'description', 'organization']
) )
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
@@ -450,8 +443,8 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
''' '''
def _get_related_jobs(self): def _get_related_jobs(self):
return UnifiedJob.objects.non_polymorphic().filter( return UnifiedJob.objects.non_polymorphic().filter(
models.Q(Job___project=self) | models.Q(job__project=self) |
models.Q(ProjectUpdate___project=self) models.Q(projectupdate__project=self)
) )
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):

View File

@@ -157,6 +157,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
default='ok', default='ok',
editable=False, 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( credentials = models.ManyToManyField(
'Credential', 'Credential',
related_name='%(class)ss', related_name='%(class)ss',
@@ -700,6 +708,14 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
on_delete=polymorphic.SET_NULL, on_delete=polymorphic.SET_NULL,
help_text=_('The Rampart/Instance group the job was run under'), 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( credentials = models.ManyToManyField(
'Credential', 'Credential',
related_name='%(class)ss', related_name='%(class)ss',

View File

@@ -335,7 +335,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase):
@classmethod @classmethod
def _get_unified_job_field_names(cls): def _get_unified_job_field_names(cls):
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set( 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 r.remove('char_prompts') # needed due to copying launch config to launch config
return r return r
@@ -382,13 +382,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
class Meta: class Meta:
app_label = 'main' app_label = 'main'
organization = models.ForeignKey(
'Organization',
blank=True,
null=True,
on_delete=models.SET_NULL,
related_name='workflows',
)
ask_inventory_on_launch = AskForField( ask_inventory_on_launch = AskForField(
blank=True, blank=True,
default=False, default=False,

View File

@@ -183,7 +183,6 @@ def connect_computed_field_signals():
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) post_save.connect(save_related_job_templates, sender=Inventory)
m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through) m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
m2m_changed.connect(rbac_activity_stream, Role.members.through) m2m_changed.connect(rbac_activity_stream, Role.members.through)

View File

@@ -159,7 +159,8 @@ def mk_job_template(name, job_type='run',
extra_vars = json.dumps(extra_vars) extra_vars = json.dumps(extra_vars)
jt = JobTemplate(name=name, job_type=job_type, extra_vars=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 jt.inventory = inventory
if jt.inventory is None: if jt.inventory is None:

View File

@@ -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, jt = mk_job_template(name, project=proj, inventory=inv, credential=cred,
network_credential=net_cred, cloud_credential=cloud_cred, network_credential=net_cred, cloud_credential=cloud_cred,
job_type=job_type, spec=spec, extra_vars=extra_vars, 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: if 'jobs' in kwargs:
for i in kwargs['jobs']: for i in kwargs['jobs']:

View File

@@ -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) job_template.credentials.add(vault_credential, vault2)
assert job_template.credentials.count() == 2 # sanity check assert job_template.credentials.count() == 2 # sanity check
r = get(job_template.get_absolute_url(), admin_user, expect=200) 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 # should be a no-op PUT request
put( put(
job_template.get_absolute_url(), job_template.get_absolute_url(),

View File

@@ -39,6 +39,26 @@ def test_extra_credentials(get, organization_factory, job_template_factory, cred
@pytest.mark.django_db @pytest.mark.django_db
def test_job_relaunch_permission_denied_response( def test_job_relaunch_permission_denied_response(
post, get, inventory, project, credential, net_credential, machine_credential): 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 = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project)
jt.credentials.add(machine_credential) jt.credentials.add(machine_credential)
jt_user = User.objects.create(username='jobtemplateuser') 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 has prompted extra_credential, launch denied w/ message
job.launch_config.credentials.add(net_credential) job.launch_config.credentials.add(net_credential)
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403) r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
assert 'launched with prompted fields' in r.data['detail'] assert 'no longer accepts the prompts provided for this job' in r.data['detail']
assert 'do not have permission' in r.data['detail']
@pytest.mark.django_db @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): def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user):
job_template = JobTemplate.objects.create( job_template = JobTemplate.objects.create(
project=project, project=project,
playbook='helloworld.yml' playbook='helloworld.yml',
organization=organization
) )
time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500") time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500")
Job.objects.create( Job.objects.create(
@@ -209,7 +229,8 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete,
status='finished', status='finished',
finished=time_of_finish, finished=time_of_finish,
job_template=job_template, job_template=job_template,
project=project project=project,
organization=organization
) )
view = RelatedJobsPreventDeleteMixin() view = RelatedJobsPreventDeleteMixin()
time_of_request = time_of_finish + relativedelta(seconds=2) time_of_request = time_of_finish + relativedelta(seconds=2)

View File

@@ -6,7 +6,7 @@ import pytest
# AWX # AWX
from awx.api.serializers import JobTemplateSerializer from awx.api.serializers import JobTemplateSerializer
from awx.api.versioning import reverse 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 from awx.main.migrations import _save_password_keys as save_password_keys
# Django # Django
@@ -30,16 +30,55 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje
project.use_role.members.add(alice) project.use_role.members.add(alice)
if grant_inventory: if grant_inventory:
inventory.use_role.members.add(alice) inventory.use_role.members.add(alice)
project.organization.job_template_admin_role.members.add(alice)
r = post(reverse('api:job_template_list'), { r = post(reverse('api:job_template_list'), {
'name': 'Some name', 'name': 'Some name',
'project': project.id, 'project': project.id,
'inventory': inventory.id, 'inventory': inventory.id,
'playbook': 'helloworld.yml', 'playbook': 'helloworld.yml',
'organization': project.organization_id
}, alice) }, alice)
assert r.status_code == expect 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 @pytest.mark.django_db
def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws): def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws):
objs = organization_factory("org", superusers=['admin']) objs = organization_factory("org", superusers=['admin'])
@@ -524,13 +563,14 @@ def test_callback_disallowed_null_inventory(project):
@pytest.mark.django_db @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( r = post(
url=reverse('api:job_template_list'), url=reverse('api:job_template_list'),
data={ data={
"name": "fooo", "name": "fooo",
"inventory": inventory.pk, "inventory": inventory.pk,
"project": project.pk, "project": project.pk,
"organization": organization.pk,
"playbook": "helloworld.yml", "playbook": "helloworld.yml",
"scm_branch": "foobar" "scm_branch": "foobar"
}, },
@@ -541,13 +581,14 @@ def test_job_template_branch_error(project, inventory, post, admin_user):
@pytest.mark.django_db @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( r = post(
url=reverse('api:job_template_list'), url=reverse('api:job_template_list'),
data={ data={
"name": "fooo", "name": "fooo",
"inventory": inventory.pk, "inventory": inventory.pk,
"project": project.pk, "project": project.pk,
"organization": organization.pk,
"playbook": "helloworld.yml", "playbook": "helloworld.yml",
"ask_scm_branch_on_launch": True "ask_scm_branch_on_launch": True
}, },

View File

@@ -2,6 +2,8 @@ import pytest
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main.models import Project
@pytest.fixture @pytest.fixture
def organization_resource_creator(organization, user): def organization_resource_creator(organization, user):
@@ -19,21 +21,26 @@ def organization_resource_creator(organization, user):
for i in range(inventories): for i in range(inventories):
inventory = organization.inventories.create(name="associated-inv %s" % i) inventory = organization.inventories.create(name="associated-inv %s" % i)
for i in range(projects): for i in range(projects):
organization.projects.create(name="test-proj %s" % i, Project.objects.create(
description="test-proj-desc") name="test-proj %s" % i,
description="test-proj-desc",
organization=organization
)
# Mix up the inventories and projects used by the job templates # Mix up the inventories and projects used by the job templates
i_proj = 0 i_proj = 0
i_inv = 0 i_inv = 0
for i in range(job_templates): 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] inventory = organization.inventories.all()[i_inv]
project.jobtemplates.create(name="test-jt %s" % i, project.jobtemplates.create(name="test-jt %s" % i,
description="test-job-template-desc", description="test-job-template-desc",
inventory=inventory, inventory=inventory,
playbook="test_playbook.yml") playbook="test_playbook.yml",
organization=organization)
i_proj += 1 i_proj += 1
i_inv += 1 i_inv += 1
if i_proj >= organization.projects.count(): if i_proj >= Project.objects.filter(organization=organization).count():
i_proj = 0 i_proj = 0
if i_inv >= organization.inventories.count(): if i_inv >= organization.inventories.count():
i_inv = 0 i_inv = 0
@@ -179,12 +186,14 @@ def test_scan_JT_counted(resourced_organization, user, get):
@pytest.mark.django_db @pytest.mark.django_db
def test_JT_not_double_counted(resourced_organization, user, get): def test_JT_not_double_counted(resourced_organization, user, get):
admin_user = user('admin', True) admin_user = user('admin', True)
proj = Project.objects.filter(organization=resourced_organization).all()[0]
# Add a run job template to the org # Add a run job template to the org
resourced_organization.projects.all()[0].jobtemplates.create( proj.jobtemplates.create(
job_type='run', job_type='run',
inventory=resourced_organization.inventories.all()[0], inventory=resourced_organization.inventories.all()[0],
project=resourced_organization.projects.all()[0], project=proj,
name='double-linked-job-template') name='double-linked-job-template',
organization=resourced_organization)
counts_dict = COUNTS_PRIMES counts_dict = COUNTS_PRIMES
counts_dict['job_templates'] += 1 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) detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
assert detail_response.status_code == 200 assert detail_response.status_code == 200
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict 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
}

View File

@@ -61,7 +61,7 @@ class TestJobTemplateCopyEdit:
def jt_copy_edit(self, job_template_factory, project): def jt_copy_edit(self, job_template_factory, project):
objects = job_template_factory( objects = job_template_factory(
'copy-edit-job-template', 'copy-edit-job-template',
project=project) project=project, organization=project.organization)
return objects.job_template return objects.job_template
def fake_context(self, user): def fake_context(self, user):
@@ -129,9 +129,8 @@ class TestJobTemplateCopyEdit:
# random user given JT and project admin abilities # random user given JT and project admin abilities
jt_copy_edit.admin_role.members.add(rando) 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.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)) serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando))
response = serializer.to_representation(jt_copy_edit) response = serializer.to_representation(jt_copy_edit)

View File

@@ -1,6 +1,8 @@
import pytest import pytest
from awx.api.versioning import reverse from awx.api.versioning import reverse
from awx.main import models
from awx.main.utils import get_type_for_model
@pytest.mark.django_db @pytest.mark.django_db
@@ -9,3 +11,111 @@ def test_aliased_forward_reverse_field_searches(instance, options, get, admin):
response = options(url, None, admin) response = options(url, None, admin)
assert 'job_template__search' in response.data['related_search_fields'] 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) 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
)

View File

@@ -75,24 +75,26 @@ def user():
@pytest.fixture @pytest.fixture
def check_jobtemplate(project, inventory, credential): def check_jobtemplate(project, inventory, credential, organization):
jt = JobTemplate.objects.create( jt = JobTemplate.objects.create(
job_type='check', job_type='check',
project=project, project=project,
inventory=inventory, inventory=inventory,
name='check-job-template' name='check-job-template',
organization=organization
) )
jt.credentials.add(credential) jt.credentials.add(credential)
return jt return jt
@pytest.fixture @pytest.fixture
def deploy_jobtemplate(project, inventory, credential): def deploy_jobtemplate(project, inventory, credential, organization):
jt = JobTemplate.objects.create( jt = JobTemplate.objects.create(
job_type='run', job_type='run',
project=project, project=project,
inventory=inventory, inventory=inventory,
name='deploy-job-template' name='deploy-job-template',
organization=organization
) )
jt.credentials.add(credential) jt.credentials.add(credential)
return jt return jt
@@ -180,8 +182,8 @@ def project_factory(organization):
@pytest.fixture @pytest.fixture
def job_factory(job_template, admin): def job_factory(jt_linked, admin):
def factory(job_template=job_template, initial_state='new', created_by=admin): def factory(job_template=jt_linked, initial_state='new', created_by=admin):
return job_template.create_unified_job(_eager_fields={ return job_template.create_unified_job(_eager_fields={
'status': initial_state, 'created_by': created_by}) 'status': initial_state, 'created_by': created_by})
return factory return factory
@@ -701,11 +703,8 @@ def ad_hoc_command_factory(inventory, machine_credential, admin):
@pytest.fixture @pytest.fixture
def job_template(organization): def job_template():
jt = JobTemplate(name='test-job_template') return JobTemplate.objects.create(name='test-job_template')
jt.save()
return jt
@pytest.fixture @pytest.fixture
@@ -717,20 +716,16 @@ def job_template_labels(organization, job_template):
@pytest.fixture @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 A job template with a reasonably complete set of related objects to
test RBAC and other functionality affected by related objects test RBAC and other functionality affected by related objects
''' '''
objects = job_template_factory( jt = JobTemplate.objects.create(
'testJT', organization='org1', project='proj1', inventory='inventory1', project=project, inventory=inventory, playbook='helloworld.yml',
credential='cred1') organization=organization
jt = objects.job_template )
jt.credentials.add(vault_credential) jt.credentials.add(machine_credential, vault_credential, credential, net_credential)
jt.save()
# Add AWS cloud credential and network credential
jt.credentials.add(credential)
jt.credentials.add(net_credential)
return jt return jt

View File

@@ -12,6 +12,7 @@ from awx.main.models import (
CredentialType, CredentialType,
Inventory, Inventory,
InventorySource, InventorySource,
Project,
User User
) )
@@ -99,8 +100,8 @@ class TestRolesAssociationEntries:
).count() == 1, 'In loop %s' % i ).count() == 1, 'In loop %s' % i
def test_model_associations_are_recorded(self, organization): def test_model_associations_are_recorded(self, organization):
proj1 = organization.projects.create(name='proj1') proj1 = Project.objects.create(name='proj1', organization=organization)
proj2 = organization.projects.create(name='proj2') proj2 = Project.objects.create(name='proj2', organization=organization)
proj2.use_role.parents.add(proj1.admin_role) proj2.use_role.parents.add(proj1.admin_role)
assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1 assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1

View File

@@ -29,18 +29,19 @@ def test_prevent_slicing():
@pytest.mark.django_db @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( jt = JobTemplate.objects.create(
name='my-jt', name='my-jt',
inventory=inventory, inventory=inventory,
project=project, project=project,
playbook='helloworld.yml' playbook='helloworld.yml',
organization=organization
) )
jt.credentials.add(machine_credential) jt.credentials.add(machine_credential)
job = jt.create_unified_job() job = jt.create_unified_job()
job.project.organization.custom_virtualenv = '/venv/fancy-org' job.organization.custom_virtualenv = '/venv/fancy-org'
job.project.organization.save() job.organization.save()
assert job.ansible_virtualenv_path == '/venv/fancy-org' assert job.ansible_virtualenv_path == '/venv/fancy-org'
job.project.custom_virtualenv = '/venv/fancy-proj' job.project.custom_virtualenv = '/venv/fancy-proj'

View File

@@ -39,3 +39,9 @@ def test_foreign_key_change_changes_modified_by(project, organization):
assert project._get_fields_snapshot()['organization_id'] == organization.id assert project._get_fields_snapshot()['organization_id'] == organization.id
project.organization = Organization(name='foo', pk=41) project.organization = Organization(name='foo', pk=41)
assert project._get_fields_snapshot()['organization_id'] == 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()]

View File

@@ -11,10 +11,11 @@ from awx.main.tasks import deep_copy_model_obj
@pytest.mark.django_db @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): credential, alice, job_template_with_survey_passwords, admin):
job_template_with_survey_passwords.project = project job_template_with_survey_passwords.project = project
job_template_with_survey_passwords.inventory = inventory 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.save()
job_template_with_survey_passwords.credentials.add(credential) job_template_with_survey_passwords.credentials.add(credential)
job_template_with_survey_passwords.credentials.add(machine_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) job_template_with_survey_passwords.admin_role.members.add(alice)
project.admin_role.members.add(alice) project.admin_role.members.add(alice)
inventory.admin_role.members.add(alice) inventory.admin_role.members.add(alice)
organization.job_template_admin_role.members.add(alice)
assert get( assert get(
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}), reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
alice, expect=200 alice, expect=200

View File

@@ -1,7 +1,7 @@
import pytest import pytest
from unittest import mock 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.models.ha import Instance, InstanceGroup
from awx.main.tasks import apply_cluster_membership_policies from awx.main.tasks import apply_cluster_membership_policies
from awx.api.versioning import reverse from awx.api.versioning import reverse
@@ -253,7 +253,7 @@ def test_inherited_instance_group_membership(instance_group_factory, default_ins
j.inventory = inventory j.inventory = inventory
ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()]) ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()])
ig_inv = instance_group_factory("basicB", [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) j.inventory.instance_groups.add(ig_inv)
assert ig_org in j.preferred_instance_groups assert ig_org in j.preferred_instance_groups
assert ig_inv 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] assert pu.preferred_instance_groups == [ig_tmp, ig_org]
def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group): def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group):
jt = JobTemplate.objects.create(inventory=inventory, project=project) org = Organization.objects.create(name='foo')
job = Job.objects.create(inventory=inventory, job_template=jt, project=project) 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] assert job.preferred_instance_groups == [default_instance_group]
ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()]) ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()])
ig_inv = instance_group_factory("InvIstGrp", [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()]) 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) inventory.instance_groups.add(ig_inv)
assert job.preferred_instance_groups == [ig_inv, ig_org] assert job.preferred_instance_groups == [ig_inv, ig_org]
job.job_template.instance_groups.add(ig_tmp) job.job_template.instance_groups.add(ig_tmp)

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
import pytest import pytest
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@@ -58,10 +59,25 @@ def test_organization(get, admin_user):
@pytest.mark.django_db @pytest.mark.django_db
def test_job_template(get, admin_user): 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}) url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk})
response = get(url, user=admin_user, expect=200) 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 @pytest.mark.django_db

View File

@@ -221,26 +221,6 @@ def test_project_credential_protection(post, put, project, organization, scm_cre
) )
@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 @pytest.mark.django_db
def test_cannot_schedule_manual_project(manual_project, admin_user, post): def test_cannot_schedule_manual_project(manual_project, admin_user, post):
response = post( response = post(

View File

@@ -29,7 +29,8 @@ def normal_job(deploy_jobtemplate):
return Job.objects.create( return Job.objects.create(
job_template=deploy_jobtemplate, job_template=deploy_jobtemplate,
project=deploy_jobtemplate.project, project=deploy_jobtemplate.project,
inventory=deploy_jobtemplate.inventory inventory=deploy_jobtemplate.inventory,
organization=deploy_jobtemplate.organization
) )

View File

@@ -89,8 +89,8 @@ def test_slice_job(slice_job_factory, rando):
@pytest.mark.django_db @pytest.mark.django_db
class TestJobRelaunchAccess: class TestJobRelaunchAccess:
@pytest.fixture @pytest.fixture
def job_no_prompts(self, machine_credential, inventory): def job_no_prompts(self, machine_credential, inventory, organization):
jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory) jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory, organization=organization)
jt.credentials.add(machine_credential) jt.credentials.add(machine_credential)
return jt.create_unified_job() return jt.create_unified_job()
@@ -119,6 +119,13 @@ class TestJobRelaunchAccess:
job_no_prompts.job_template.execute_role.members.add(rando) job_no_prompts.job_template.execute_role.members.add(rando)
assert rando.can_access(Job, 'start', job_no_prompts) 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): 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" "Has JT execute_role but no use_role on inventory & credential - deny relaunch"
job_with_prompts.job_template.execute_role.members.add(rando) job_with_prompts.job_template.execute_role.members.add(rando)

View File

@@ -24,6 +24,29 @@ def test_job_template_access_superuser(check_license, user, deploy_jobtemplate):
assert access.can_add({}) 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 @pytest.mark.django_db
def test_job_template_access_read_level(jt_linked, rando): def test_job_template_access_read_level(jt_linked, rando):
ssh_cred = jt_linked.machine_credential ssh_cred = jt_linked.machine_credential
@@ -45,22 +68,21 @@ def test_job_template_access_read_level(jt_linked, rando):
@pytest.mark.django_db @pytest.mark.django_db
def test_job_template_access_use_level(jt_linked, rando): 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) access = JobTemplateAccess(rando)
jt_linked.project.use_role.members.add(rando) jt_linked.project.use_role.members.add(rando)
jt_linked.inventory.use_role.members.add(rando) jt_linked.inventory.use_role.members.add(rando)
ssh_cred.use_role.members.add(rando) jt_linked.organization.job_template_admin_role.members.add(rando)
vault_cred.use_role.members.add(rando)
proj_pk = jt_linked.project.pk proj_pk = jt_linked.project.pk
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk)) org_pk = jt_linked.organization_id
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)) 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(): 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 @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 ssh_cred = jt_linked.machine_credential
access = JobTemplateAccess(rando) 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_read(jt_linked)
assert not access.can_delete(jt_linked) assert not access.can_delete(jt_linked)
for role_name in role_names: # Appoint this user as admin of the organization
role = getattr(jt_linked.inventory.organization, role_name) jt_linked.organization.admin_role.members.add(rando)
role.members.add(rando) org_pk = jt_linked.organization.id
# Assign organization permission in the same way the create view does # Assign organization permission in the same way the create view does
organization = jt_linked.inventory.organization organization = jt_linked.inventory.organization
ssh_cred.admin_role.parents.add(organization.admin_role) ssh_cred.admin_role.parents.add(organization.admin_role)
proj_pk = jt_linked.project.pk proj_pk = jt_linked.project.pk
assert access.can_add(dict(inventory=jt_linked.inventory.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)) assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk, organization=org_pk))
for cred in jt_linked.credentials.all(): for cred in jt_linked.credentials.all():
assert access.can_unattach(jt_linked, cred, 'credentials', {}) assert access.can_unattach(jt_linked, cred, 'credentials', {})
@@ -148,26 +169,46 @@ class TestOrphanJobTemplate:
@pytest.mark.django_db @pytest.mark.django_db
@pytest.mark.job_permissions @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_pk = response.data['id']
jt_obj = JobTemplate.objects.get(pk=jt_pk) jt_obj = JobTemplate.objects.get(pk=jt_pk)
# Creating a JT should place the creator in the admin role # 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 @pytest.mark.django_db
@@ -239,7 +280,7 @@ class TestJobTemplateSchedules:
@pytest.mark.django_db @pytest.mark.django_db
def test_jt_org_ownership_change(user, jt_linked): def test_jt_org_ownership_change(user, jt_linked):
admin1 = user('admin1') admin1 = user('admin1')
org1 = jt_linked.project.organization org1 = jt_linked.organization
org1.admin_role.members.add(admin1) org1.admin_role.members.add(admin1)
a1_access = JobTemplateAccess(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) assert not a2_access.can_read(jt_linked)
jt_linked.project.organization = org2 jt_linked.organization = org2
jt_linked.project.save() jt_linked.save()
jt_linked.inventory.organization = org2
jt_linked.inventory.save()
assert a2_access.can_read(jt_linked) assert a2_access.can_read(jt_linked)
assert not a1_access.can_read(jt_linked) assert not a1_access.can_read(jt_linked)

View File

@@ -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

View File

@@ -62,10 +62,11 @@ class TestWorkflowJobTemplateAccess:
@pytest.mark.django_db @pytest.mark.django_db
class TestWorkflowJobTemplateNodeAccess: 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 # without access to the related job template, admin to the WFJT can
# not change the prompted parameters # 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'}) assert not access.can_change(wfjt_node, {'job_type': 'check'})
def test_node_edit_allowed(self, wfjt_node, org_admin): def test_node_edit_allowed(self, wfjt_node, org_admin):

View File

@@ -30,6 +30,7 @@ def job_template(mocker):
mock_jt.host_config_key = '9283920492' mock_jt.host_config_key = '9283920492'
mock_jt.validation_errors = mock_JT_resource_data mock_jt.validation_errors = mock_JT_resource_data
mock_jt.webhook_service = '' mock_jt.webhook_service = ''
mock_jt.organization_id = None
return mock_jt return mock_jt

View File

@@ -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']) 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(): def test_log_representation():
''' '''
Common representation used inside of log messages Common representation used inside of log messages

View File

@@ -148,7 +148,9 @@ def job_template_with_ids(job_template_factory):
'testJT', project=proj, inventory=inv, credential=credential, 'testJT', project=proj, inventory=inv, credential=credential,
cloud_credential=cloud_cred, network_credential=net_cred, cloud_credential=cloud_cred, network_credential=net_cred,
persisted=False) 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): 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): def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit):
"""Assure that can_add is called with all ForeignKeys.""" """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) access = JobTemplateAccess(user_unit)
mock_add = mock.MagicMock(return_value=False) assert not access.changes_are_non_sensitive(job_template_with_ids, data)
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)
mock_add.assert_called_once_with({ job_template_with_ids.inventory.use_role = RoleReturnsTrue()
'inventory': data['inventory'], job_template_with_ids.project.use_role = RoleReturnsTrue()
'project': job_template_with_ids.project.id assert access.can_change(job_template_with_ids, data)
})
def mock_raise_none(self, add_host=False, feature=None, check_expiration=True): def mock_raise_none(self, add_host=False, feature=None, check_expiration=True):

View File

@@ -2,10 +2,17 @@
import pytest import pytest
from django.core.exceptions import ValidationError 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 rest_framework.serializers import ValidationError as DRFValidationError
from awx.main.models import Credential, CredentialType, BaseModel 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', [ @pytest.mark.parametrize('schema, given, message', [
@@ -194,3 +201,57 @@ def test_credential_creation_validation_failure(inputs):
with pytest.raises(Exception) as e: with pytest.raises(Exception) as e:
field.validate(inputs, cred) field.validate(inputs, cred)
assert e.type in (ValidationError, DRFValidationError) 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

View File

@@ -43,6 +43,19 @@ function(NotificationsList, i18n) {
column: 1, column: 1,
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)' 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: { job_type: {
label: i18n._('Job Type'), label: i18n._('Job Type'),
type: 'select', type: 'select',

View File

@@ -7,7 +7,7 @@ from awxkit.utils import (
suppress, suppress,
update_payload, update_payload,
PseudoNamespace) 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.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter
from awxkit.api.resources import resources from awxkit.api.resources import resources
import awxkit.exceptions as exc import awxkit.exceptions as exc
@@ -23,7 +23,7 @@ class JobTemplate(
HasSurvey, HasSurvey,
UnifiedJobTemplate): UnifiedJobTemplate):
optional_dependencies = [Inventory, Credential, Project] optional_dependencies = [Organization, Inventory, Credential, Project]
def launch(self, payload={}): def launch(self, payload={}):
"""Launch the job_template using related->launch endpoint.""" """Launch the job_template using related->launch endpoint."""
@@ -129,6 +129,7 @@ class JobTemplate(
playbook='ping.yml', playbook='ping.yml',
credential=Credential, credential=Credential,
inventory=Inventory, inventory=Inventory,
organization=Organization,
project=None, project=None,
**kwargs): **kwargs):
if not project: if not project:
@@ -148,12 +149,18 @@ class JobTemplate(
project = self.ds.project if project else None project = self.ds.project if project else None
inventory = self.ds.inventory if inventory else None inventory = self.ds.inventory if inventory else None
credential = self.ds.credential if credential 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( payload = self.payload(
name=name, name=name,
description=description, description=description,
job_type=job_type, job_type=job_type,
playbook=playbook, playbook=playbook,
organization=organization,
credential=credential, credential=credential,
inventory=inventory, inventory=inventory,
project=project, project=project,
@@ -169,11 +176,12 @@ class JobTemplate(
playbook='ping.yml', playbook='ping.yml',
credential=Credential, credential=Credential,
inventory=Inventory, inventory=Inventory,
organization=Organization,
project=None, project=None,
**kwargs): **kwargs):
payload, credential = self.create_payload(name=name, description=description, job_type=job_type, payload, credential = self.create_payload(name=name, description=description, job_type=job_type,
playbook=playbook, credential=credential, inventory=inventory, playbook=playbook, credential=credential, inventory=inventory,
project=project, **kwargs) project=project, organization=organization, **kwargs)
ret = self.update_identity( ret = self.update_identity(
JobTemplates( JobTemplates(
self.connection).post(payload)) self.connection).post(payload))

View File

@@ -12,7 +12,7 @@ from . import page
class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate): class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate):
optional_dependencies = [Organization] dependencies = [Organization]
def launch(self, payload={}): def launch(self, payload={}):
"""Launch using related->launch endpoint.""" """Launch using related->launch endpoint."""
@@ -71,14 +71,14 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
return payload 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))) self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
organization = self.ds.organization if organization else None organization = self.ds.organization if organization else None
payload = self.payload(name=name, description=description, organization=organization, **kwargs) payload = self.payload(name=name, description=description, organization=organization, **kwargs)
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store) payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
return payload 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) payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
return self.update_identity(WorkflowJobTemplates(self.connection).post(payload)) return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))