mirror of
https://github.com/ansible/awx.git
synced 2026-01-15 03:40:42 -03:30
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:
parent
1876849d89
commit
daa9282790
@ -548,6 +548,15 @@ class SubListCreateAPIView(SubListAPIView, ListCreateAPIView):
|
||||
})
|
||||
return d
|
||||
|
||||
def get_queryset(self):
|
||||
if hasattr(self, 'parent_key'):
|
||||
# Prefer this filtering because ForeignKey allows us more assumptions
|
||||
parent = self.get_parent_object()
|
||||
self.check_parent_access(parent)
|
||||
qs = self.request.user.get_queryset(self.model)
|
||||
return qs.filter(**{self.parent_key: parent})
|
||||
return super(SubListCreateAPIView, self).get_queryset()
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
# If the object ID was not specified, it probably doesn't exist in the
|
||||
# DB yet. We want to see if we can create it. The URL may choose to
|
||||
|
||||
@ -642,7 +642,7 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
|
||||
_capabilities_prefetch = [
|
||||
'admin', 'execute',
|
||||
{'copy': ['jobtemplate.project.use', 'jobtemplate.inventory.use',
|
||||
'workflowjobtemplate.organization.workflow_admin']}
|
||||
'organization.workflow_admin']}
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@ -700,6 +700,18 @@ class UnifiedJobTemplateSerializer(BaseSerializer):
|
||||
else:
|
||||
return super(UnifiedJobTemplateSerializer, self).to_representation(obj)
|
||||
|
||||
def validate(self, attrs):
|
||||
if 'organization' in self.fields:
|
||||
# Do not allow setting template organization to null
|
||||
# otherwise be as non-restrictive as possible for PATCH or PUT, even with orphans
|
||||
# does not correspond with any REST framework field construct
|
||||
if self.instance is None and attrs.get('organization', None) is None:
|
||||
raise serializers.ValidationError({'organization': _('Organization required for new object.')})
|
||||
if self.instance and self.instance.organization_id and attrs.get('organization', 'blank') is None:
|
||||
raise serializers.ValidationError({'organization': _('Organization can not be set to null.')})
|
||||
|
||||
return super(UnifiedJobTemplateSerializer, self).validate(attrs)
|
||||
|
||||
|
||||
class UnifiedJobSerializer(BaseSerializer):
|
||||
show_capabilities = ['start', 'delete']
|
||||
@ -1387,12 +1399,6 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
|
||||
def get_field_from_model_or_attrs(fd):
|
||||
return attrs.get(fd, self.instance and getattr(self.instance, fd) or None)
|
||||
|
||||
organization = None
|
||||
if 'organization' in attrs:
|
||||
organization = attrs['organization']
|
||||
elif self.instance:
|
||||
organization = self.instance.organization
|
||||
|
||||
if 'allow_override' in attrs and self.instance:
|
||||
# case where user is turning off this project setting
|
||||
if self.instance.allow_override and not attrs['allow_override']:
|
||||
@ -1408,11 +1414,7 @@ class ProjectSerializer(UnifiedJobTemplateSerializer, ProjectOptionsSerializer):
|
||||
' '.join([str(pk) for pk in used_by])
|
||||
)})
|
||||
|
||||
view = self.context.get('view', None)
|
||||
if not organization and not view.request.user.is_superuser:
|
||||
# Only allow super users to create orgless projects
|
||||
raise serializers.ValidationError(_('Organization is missing'))
|
||||
elif get_field_from_model_or_attrs('scm_type') == '':
|
||||
if get_field_from_model_or_attrs('scm_type') == '':
|
||||
for fd in ('scm_update_on_launch', 'scm_delete_on_update', 'scm_clean'):
|
||||
if get_field_from_model_or_attrs(fd):
|
||||
raise serializers.ValidationError({fd: _('Update options must be set to false for manual projects.')})
|
||||
@ -2738,7 +2740,7 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer):
|
||||
fields = ('*', 'job_type', 'inventory', 'project', 'playbook', 'scm_branch',
|
||||
'forks', 'limit', 'verbosity', 'extra_vars', 'job_tags',
|
||||
'force_handlers', 'skip_tags', 'start_at_task', 'timeout',
|
||||
'use_fact_cache',)
|
||||
'use_fact_cache', 'organization',)
|
||||
|
||||
def get_related(self, obj):
|
||||
res = super(JobOptionsSerializer, self).get_related(obj)
|
||||
@ -2753,6 +2755,8 @@ class JobOptionsSerializer(LabelsListMixin, BaseSerializer):
|
||||
res['project'] = self.reverse('api:project_detail', kwargs={'pk': obj.project.pk})
|
||||
except ObjectDoesNotExist:
|
||||
setattr(obj, 'project', None)
|
||||
if obj.organization_id:
|
||||
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id})
|
||||
if isinstance(obj, UnifiedJobTemplate):
|
||||
res['extra_credentials'] = self.reverse(
|
||||
'api:job_template_extra_credentials_list',
|
||||
@ -2899,6 +2903,8 @@ class JobTemplateSerializer(JobTemplateMixin, UnifiedJobTemplateSerializer, JobO
|
||||
)
|
||||
if obj.host_config_key:
|
||||
res['callback'] = self.reverse('api:job_template_callback', kwargs={'pk': obj.pk})
|
||||
if obj.organization_id:
|
||||
res['organization'] = self.reverse('api:organization_detail', kwargs={'pk': obj.organization_id})
|
||||
return res
|
||||
|
||||
def validate(self, attrs):
|
||||
|
||||
@ -10,6 +10,7 @@ from awx.api.views import (
|
||||
OrganizationAdminsList,
|
||||
OrganizationInventoriesList,
|
||||
OrganizationProjectsList,
|
||||
OrganizationJobTemplatesList,
|
||||
OrganizationWorkflowJobTemplatesList,
|
||||
OrganizationTeamsList,
|
||||
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]+)/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]+)/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]+)/teams/$', OrganizationTeamsList.as_view(), name='organization_teams_list'),
|
||||
url(r'^(?P<pk>[0-9]+)/credentials/$', OrganizationCredentialList.as_view(), name='organization_credential_list'),
|
||||
|
||||
@ -111,6 +111,7 @@ from awx.api.views.organization import ( # noqa
|
||||
OrganizationUsersList,
|
||||
OrganizationAdminsList,
|
||||
OrganizationProjectsList,
|
||||
OrganizationJobTemplatesList,
|
||||
OrganizationWorkflowJobTemplatesList,
|
||||
OrganizationTeamsList,
|
||||
OrganizationActivityStreamList,
|
||||
|
||||
@ -4,10 +4,7 @@
|
||||
import dateutil
|
||||
import logging
|
||||
|
||||
from django.db.models import (
|
||||
Count,
|
||||
F,
|
||||
)
|
||||
from django.db.models import Count
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.timezone import now
|
||||
@ -175,28 +172,18 @@ class OrganizationCountsMixin(object):
|
||||
|
||||
inv_qs = Inventory.accessible_objects(self.request.user, 'read_role')
|
||||
project_qs = Project.accessible_objects(self.request.user, 'read_role')
|
||||
jt_qs = JobTemplate.accessible_objects(self.request.user, 'read_role')
|
||||
|
||||
# Produce counts of Foreign Key relationships
|
||||
db_results['inventories'] = inv_qs\
|
||||
.values('organization').annotate(Count('organization')).order_by('organization')
|
||||
db_results['inventories'] = inv_qs.values('organization').annotate(Count('organization')).order_by('organization')
|
||||
|
||||
db_results['teams'] = Team.accessible_objects(
|
||||
self.request.user, 'read_role').values('organization').annotate(
|
||||
Count('organization')).order_by('organization')
|
||||
|
||||
JT_project_reference = 'project__organization'
|
||||
JT_inventory_reference = 'inventory__organization'
|
||||
db_results['job_templates_project'] = JobTemplate.accessible_objects(
|
||||
self.request.user, 'read_role').exclude(
|
||||
project__organization=F(JT_inventory_reference)).values(JT_project_reference).annotate(
|
||||
Count(JT_project_reference)).order_by(JT_project_reference)
|
||||
db_results['job_templates'] = jt_qs.values('organization').annotate(Count('organization')).order_by('organization')
|
||||
|
||||
db_results['job_templates_inventory'] = JobTemplate.accessible_objects(
|
||||
self.request.user, 'read_role').values(JT_inventory_reference).annotate(
|
||||
Count(JT_inventory_reference)).order_by(JT_inventory_reference)
|
||||
|
||||
db_results['projects'] = project_qs\
|
||||
.values('organization').annotate(Count('organization')).order_by('organization')
|
||||
db_results['projects'] = project_qs.values('organization').annotate(Count('organization')).order_by('organization')
|
||||
|
||||
# Other members and admins of organization are always viewable
|
||||
db_results['users'] = org_qs.annotate(
|
||||
@ -212,11 +199,7 @@ class OrganizationCountsMixin(object):
|
||||
'admins': 0, 'projects': 0}
|
||||
|
||||
for res, count_qs in db_results.items():
|
||||
if res == 'job_templates_project':
|
||||
org_reference = JT_project_reference
|
||||
elif res == 'job_templates_inventory':
|
||||
org_reference = JT_inventory_reference
|
||||
elif res == 'users':
|
||||
if res == 'users':
|
||||
org_reference = 'id'
|
||||
else:
|
||||
org_reference = 'organization'
|
||||
@ -229,14 +212,6 @@ class OrganizationCountsMixin(object):
|
||||
continue
|
||||
count_context[org_id][res] = entry['%s__count' % org_reference]
|
||||
|
||||
# Combine the counts for job templates by project and inventory
|
||||
for org in org_id_list:
|
||||
org_id = org['id']
|
||||
count_context[org_id]['job_templates'] = 0
|
||||
for related_path in ['job_templates_project', 'job_templates_inventory']:
|
||||
if related_path in count_context[org_id]:
|
||||
count_context[org_id]['job_templates'] += count_context[org_id].pop(related_path)
|
||||
|
||||
full_context['related_field_counts'] = count_context
|
||||
|
||||
return full_context
|
||||
|
||||
@ -20,7 +20,7 @@ from awx.main.models import (
|
||||
Role,
|
||||
User,
|
||||
Team,
|
||||
InstanceGroup,
|
||||
InstanceGroup
|
||||
)
|
||||
from awx.api.generics import (
|
||||
ListCreateAPIView,
|
||||
@ -28,6 +28,7 @@ from awx.api.generics import (
|
||||
SubListAPIView,
|
||||
SubListCreateAttachDetachAPIView,
|
||||
SubListAttachDetachAPIView,
|
||||
SubListCreateAPIView,
|
||||
ResourceAccessList,
|
||||
BaseUsersList,
|
||||
)
|
||||
@ -35,14 +36,13 @@ from awx.api.generics import (
|
||||
from awx.api.serializers import (
|
||||
OrganizationSerializer,
|
||||
InventorySerializer,
|
||||
ProjectSerializer,
|
||||
UserSerializer,
|
||||
TeamSerializer,
|
||||
ActivityStreamSerializer,
|
||||
RoleSerializer,
|
||||
NotificationTemplateSerializer,
|
||||
WorkflowJobTemplateSerializer,
|
||||
InstanceGroupSerializer,
|
||||
ProjectSerializer, JobTemplateSerializer, WorkflowJobTemplateSerializer
|
||||
)
|
||||
from awx.api.views.mixin import (
|
||||
RelatedJobsPreventDeleteMixin,
|
||||
@ -94,7 +94,7 @@ class OrganizationDetail(RelatedJobsPreventDeleteMixin, RetrieveUpdateDestroyAPI
|
||||
org_counts['projects'] = Project.accessible_objects(**access_kwargs).filter(
|
||||
organization__id=org_id).count()
|
||||
org_counts['job_templates'] = JobTemplate.accessible_objects(**access_kwargs).filter(
|
||||
project__organization__id=org_id).count()
|
||||
organization__id=org_id).count()
|
||||
|
||||
full_context['related_field_counts'] = {}
|
||||
full_context['related_field_counts'][org_id] = org_counts
|
||||
@ -128,21 +128,27 @@ class OrganizationAdminsList(BaseUsersList):
|
||||
ordering = ('username',)
|
||||
|
||||
|
||||
class OrganizationProjectsList(SubListCreateAttachDetachAPIView):
|
||||
class OrganizationProjectsList(SubListCreateAPIView):
|
||||
|
||||
model = Project
|
||||
serializer_class = ProjectSerializer
|
||||
parent_model = Organization
|
||||
relationship = 'projects'
|
||||
parent_key = 'organization'
|
||||
|
||||
|
||||
class OrganizationWorkflowJobTemplatesList(SubListCreateAttachDetachAPIView):
|
||||
class OrganizationJobTemplatesList(SubListCreateAPIView):
|
||||
|
||||
model = JobTemplate
|
||||
serializer_class = JobTemplateSerializer
|
||||
parent_model = Organization
|
||||
parent_key = 'organization'
|
||||
|
||||
|
||||
class OrganizationWorkflowJobTemplatesList(SubListCreateAPIView):
|
||||
|
||||
model = WorkflowJobTemplate
|
||||
serializer_class = WorkflowJobTemplateSerializer
|
||||
parent_model = Organization
|
||||
relationship = 'workflows'
|
||||
parent_key = 'organization'
|
||||
|
||||
|
||||
|
||||
@ -1411,7 +1411,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||
'''
|
||||
|
||||
model = JobTemplate
|
||||
select_related = ('created_by', 'modified_by', 'inventory', 'project',
|
||||
select_related = ('created_by', 'modified_by', 'inventory', 'project', 'organization',
|
||||
'next_schedule',)
|
||||
prefetch_related = (
|
||||
'instance_groups',
|
||||
@ -1435,9 +1435,7 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||
Users who are able to create deploy jobs can also run normal and check (dry run) jobs.
|
||||
'''
|
||||
if not data: # So the browseable API will work
|
||||
return (
|
||||
Project.accessible_objects(self.user, 'use_role').exists() or
|
||||
Inventory.accessible_objects(self.user, 'use_role').exists())
|
||||
return Organization.accessible_objects(self.user, 'job_template_admin_role').exists()
|
||||
|
||||
# if reference_obj is provided, determine if it can be copied
|
||||
reference_obj = data.get('reference_obj', None)
|
||||
@ -1467,6 +1465,10 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||
if self.user not in inventory.use_role:
|
||||
return False
|
||||
|
||||
organization = get_value(Organization, 'organization')
|
||||
if (not organization) or (self.user not in organization.job_template_admin_role):
|
||||
return False
|
||||
|
||||
project = get_value(Project, 'project')
|
||||
# If the user has admin access to the project (as an org admin), should
|
||||
# be able to proceed without additional checks.
|
||||
@ -1504,22 +1506,31 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||
return self.user in obj.execute_role
|
||||
|
||||
def can_change(self, obj, data):
|
||||
data_for_change = data
|
||||
if self.user not in obj.admin_role and not self.user.is_superuser:
|
||||
return False
|
||||
if data is not None:
|
||||
data = dict(data)
|
||||
if data is None:
|
||||
return True
|
||||
|
||||
if self.changes_are_non_sensitive(obj, data):
|
||||
if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']:
|
||||
self.check_license(feature='surveys')
|
||||
return True
|
||||
# standard type of check for organization - cannot change the value
|
||||
# unless posessing the respective job_template_admin_role, otherwise non-blocking
|
||||
if not self.check_related('organization', Organization, data, obj=obj, role_field='job_template_admin_role'):
|
||||
return False
|
||||
|
||||
for required_field in ('inventory', 'project'):
|
||||
required_obj = getattr(obj, required_field, None)
|
||||
if required_field not in data_for_change and required_obj is not None:
|
||||
data_for_change[required_field] = required_obj.pk
|
||||
return self.can_read(obj) and (self.can_add(data_for_change) if data is not None else True)
|
||||
data = dict(data)
|
||||
|
||||
if 'survey_enabled' in data and obj.survey_enabled != data['survey_enabled'] and data['survey_enabled']:
|
||||
self.check_license(feature='surveys')
|
||||
|
||||
if self.changes_are_non_sensitive(obj, data):
|
||||
return True
|
||||
|
||||
for required_field, cls in (('inventory', Inventory), ('project', Project)):
|
||||
is_mandatory = True
|
||||
if not getattr(obj, '{}_id'.format(required_field)):
|
||||
is_mandatory = False
|
||||
if not self.check_related(required_field, cls, data, obj=obj, role_field='use_role', mandatory=is_mandatory):
|
||||
return False
|
||||
return True
|
||||
|
||||
def changes_are_non_sensitive(self, obj, data):
|
||||
'''
|
||||
@ -1554,9 +1565,9 @@ class JobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||
@check_superuser
|
||||
def can_attach(self, obj, sub_obj, relationship, data, skip_sub_obj_read_check=False):
|
||||
if relationship == "instance_groups":
|
||||
if not obj.project.organization:
|
||||
if not obj.organization:
|
||||
return False
|
||||
return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.project.organization.admin_role
|
||||
return self.user.can_access(type(sub_obj), "read", sub_obj) and self.user in obj.organization.admin_role
|
||||
if relationship == 'credentials' and isinstance(sub_obj, Credential):
|
||||
return self.user in obj.admin_role and self.user in sub_obj.use_role
|
||||
return super(JobTemplateAccess, self).can_attach(
|
||||
@ -1587,6 +1598,7 @@ class JobAccess(BaseAccess):
|
||||
select_related = ('created_by', 'modified_by', 'job_template', 'inventory',
|
||||
'project', 'project_update',)
|
||||
prefetch_related = (
|
||||
'organization',
|
||||
'unified_job_template',
|
||||
'instance_group',
|
||||
'credentials__credential_type',
|
||||
@ -1607,42 +1619,19 @@ class JobAccess(BaseAccess):
|
||||
|
||||
return qs.filter(
|
||||
Q(job_template__in=JobTemplate.accessible_objects(self.user, 'read_role')) |
|
||||
Q(inventory__organization__in=org_access_qs) |
|
||||
Q(project__organization__in=org_access_qs)).distinct()
|
||||
|
||||
def related_orgs(self, obj):
|
||||
orgs = []
|
||||
if obj.inventory and obj.inventory.organization:
|
||||
orgs.append(obj.inventory.organization)
|
||||
if obj.project and obj.project.organization and obj.project.organization not in orgs:
|
||||
orgs.append(obj.project.organization)
|
||||
return orgs
|
||||
|
||||
def org_access(self, obj, role_types=['admin_role']):
|
||||
orgs = self.related_orgs(obj)
|
||||
for org in orgs:
|
||||
for role_type in role_types:
|
||||
role = getattr(org, role_type)
|
||||
if self.user in role:
|
||||
return True
|
||||
return False
|
||||
Q(organization__in=org_access_qs)).distinct()
|
||||
|
||||
def can_add(self, data, validate_license=True):
|
||||
if validate_license:
|
||||
self.check_license()
|
||||
|
||||
if not data: # So the browseable API will work
|
||||
return True
|
||||
return self.user.is_superuser
|
||||
raise NotImplementedError('Direct job creation not possible in v2 API')
|
||||
|
||||
def can_change(self, obj, data):
|
||||
return (obj.status == 'new' and
|
||||
self.can_read(obj) and
|
||||
self.can_add(data, validate_license=False))
|
||||
raise NotImplementedError('Direct job editing not supported in v2 API')
|
||||
|
||||
@check_superuser
|
||||
def can_delete(self, obj):
|
||||
return self.org_access(obj)
|
||||
if not obj.organization:
|
||||
return False
|
||||
return self.user in obj.organization.admin_role
|
||||
|
||||
def can_start(self, obj, validate_license=True):
|
||||
if validate_license:
|
||||
@ -1662,6 +1651,7 @@ class JobAccess(BaseAccess):
|
||||
except JobLaunchConfig.DoesNotExist:
|
||||
config = None
|
||||
|
||||
# Standard permissions model (1)
|
||||
if obj.job_template and (self.user not in obj.job_template.execute_role):
|
||||
return False
|
||||
|
||||
@ -1676,24 +1666,15 @@ class JobAccess(BaseAccess):
|
||||
if JobLaunchConfigAccess(self.user).can_add({'reference_obj': config}):
|
||||
return True
|
||||
|
||||
org_access = bool(obj.inventory) and self.user in obj.inventory.organization.inventory_admin_role
|
||||
project_access = obj.project is None or self.user in obj.project.admin_role
|
||||
credential_access = all([self.user in cred.use_role for cred in obj.credentials.all()])
|
||||
# Standard permissions model (2)
|
||||
if obj.organization and self.user in obj.organization.execute_role:
|
||||
# Respect organization ownership of orphaned jobs
|
||||
return True
|
||||
elif not (obj.job_template or obj.organization):
|
||||
if self.save_messages:
|
||||
self.messages['detail'] = _('Job has been orphaned from its job template and organization.')
|
||||
|
||||
# job can be relaunched if user could make an equivalent JT
|
||||
ret = org_access and credential_access and project_access
|
||||
if not ret and self.save_messages and not self.messages:
|
||||
if not obj.job_template:
|
||||
pretext = _('Job has been orphaned from its job template.')
|
||||
elif config is None:
|
||||
pretext = _('Job was launched with unknown prompted fields.')
|
||||
else:
|
||||
pretext = _('Job was launched with prompted fields.')
|
||||
if credential_access:
|
||||
self.messages['detail'] = '{} {}'.format(pretext, _(' Organization level permissions required.'))
|
||||
else:
|
||||
self.messages['detail'] = '{} {}'.format(pretext, _(' You do not have permission to related resources.'))
|
||||
return ret
|
||||
return False
|
||||
|
||||
def get_method_capability(self, method, obj, parent_obj):
|
||||
if method == 'start':
|
||||
@ -1706,10 +1687,16 @@ class JobAccess(BaseAccess):
|
||||
def can_cancel(self, obj):
|
||||
if not obj.can_cancel:
|
||||
return False
|
||||
# Delete access allows org admins to stop running jobs
|
||||
if self.user == obj.created_by or self.can_delete(obj):
|
||||
# Users may always cancel their own jobs
|
||||
if self.user == obj.created_by:
|
||||
return True
|
||||
return obj.job_template is not None and self.user in obj.job_template.admin_role
|
||||
# Users with direct admin to JT may cancel jobs started by anyone
|
||||
if obj.job_template and self.user in obj.job_template.admin_role:
|
||||
return True
|
||||
# If orphaned, allow org JT admins to stop running jobs
|
||||
if not obj.job_template and obj.organization and self.user in obj.organization.job_template_admin_role:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class SystemJobTemplateAccess(BaseAccess):
|
||||
@ -1944,11 +1931,11 @@ class WorkflowJobNodeAccess(BaseAccess):
|
||||
# TODO: notification attachments?
|
||||
class WorkflowJobTemplateAccess(NotificationAttachMixin, BaseAccess):
|
||||
'''
|
||||
I can only see/manage Workflow Job Templates if I'm a super user
|
||||
I can see/manage Workflow Job Templates based on object roles
|
||||
'''
|
||||
|
||||
model = WorkflowJobTemplate
|
||||
select_related = ('created_by', 'modified_by', 'next_schedule',
|
||||
select_related = ('created_by', 'modified_by', 'organization', 'next_schedule',
|
||||
'admin_role', 'execute_role', 'read_role',)
|
||||
|
||||
def filtered_queryset(self):
|
||||
@ -2038,7 +2025,7 @@ class WorkflowJobAccess(BaseAccess):
|
||||
I can also cancel it if I started it
|
||||
'''
|
||||
model = WorkflowJob
|
||||
select_related = ('created_by', 'modified_by',)
|
||||
select_related = ('created_by', 'modified_by', 'organization',)
|
||||
|
||||
def filtered_queryset(self):
|
||||
return WorkflowJob.objects.filter(
|
||||
@ -2332,6 +2319,7 @@ class UnifiedJobTemplateAccess(BaseAccess):
|
||||
prefetch_related = (
|
||||
'last_job',
|
||||
'current_job',
|
||||
'organization',
|
||||
'credentials__credential_type',
|
||||
Prefetch('labels', queryset=Label.objects.all().order_by('name')),
|
||||
)
|
||||
@ -2371,6 +2359,7 @@ class UnifiedJobAccess(BaseAccess):
|
||||
prefetch_related = (
|
||||
'created_by',
|
||||
'modified_by',
|
||||
'organization',
|
||||
'unified_job_node__workflow_job',
|
||||
'unified_job_template',
|
||||
'instance_group',
|
||||
@ -2401,8 +2390,7 @@ class UnifiedJobAccess(BaseAccess):
|
||||
Q(unified_job_template_id__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) |
|
||||
Q(inventoryupdate__inventory_source__inventory__id__in=inv_pk_qs) |
|
||||
Q(adhoccommand__inventory__id__in=inv_pk_qs) |
|
||||
Q(job__inventory__organization__in=org_auditor_qs) |
|
||||
Q(job__project__organization__in=org_auditor_qs)
|
||||
Q(organization__in=org_auditor_qs)
|
||||
)
|
||||
return qs
|
||||
|
||||
|
||||
@ -56,7 +56,8 @@ from awx.main import utils
|
||||
|
||||
__all__ = ['AutoOneToOneField', 'ImplicitRoleField', 'JSONField',
|
||||
'SmartFilterField', 'OrderedManyToManyField',
|
||||
'update_role_parentage_for_instance', 'is_implicit_parent']
|
||||
'update_role_parentage_for_instance',
|
||||
'is_implicit_parent']
|
||||
|
||||
|
||||
# Provide a (better) custom error message for enum jsonschema validation
|
||||
@ -140,8 +141,9 @@ def resolve_role_field(obj, field):
|
||||
return []
|
||||
|
||||
if len(field_components) == 1:
|
||||
role_cls = str(utils.get_current_apps().get_model('main', 'Role'))
|
||||
if not str(type(obj)) == role_cls:
|
||||
# use extremely generous duck typing to accomidate all possible forms
|
||||
# of the model that may be used during various migrations
|
||||
if obj._meta.model_name != 'role' or obj._meta.app_label != 'main':
|
||||
raise Exception(smart_text('{} refers to a {}, not a Role'.format(field, type(obj))))
|
||||
ret.append(obj.id)
|
||||
else:
|
||||
@ -197,18 +199,30 @@ def update_role_parentage_for_instance(instance):
|
||||
updates the parents listing for all the roles
|
||||
of a given instance if they have changed
|
||||
'''
|
||||
changed_ct = 0
|
||||
for implicit_role_field in getattr(instance.__class__, '__implicit_role_fields'):
|
||||
changed = False
|
||||
cur_role = getattr(instance, implicit_role_field.name)
|
||||
original_parents = set(json.loads(cur_role.implicit_parents))
|
||||
new_parents = implicit_role_field._resolve_parent_roles(instance)
|
||||
cur_role.parents.remove(*list(original_parents - new_parents))
|
||||
cur_role.parents.add(*list(new_parents - original_parents))
|
||||
removals = original_parents - new_parents
|
||||
if removals:
|
||||
changed = True
|
||||
cur_role.parents.remove(*list(removals))
|
||||
additions = new_parents - original_parents
|
||||
if additions:
|
||||
changed = True
|
||||
cur_role.parents.add(*list(additions))
|
||||
new_parents_list = list(new_parents)
|
||||
new_parents_list.sort()
|
||||
new_parents_json = json.dumps(new_parents_list)
|
||||
if cur_role.implicit_parents != new_parents_json:
|
||||
changed = True
|
||||
cur_role.implicit_parents = new_parents_json
|
||||
cur_role.save()
|
||||
if changed:
|
||||
changed_ct += 1
|
||||
return changed_ct
|
||||
|
||||
|
||||
class ImplicitRoleDescriptor(ForwardManyToOneDescriptor):
|
||||
@ -256,20 +270,18 @@ class ImplicitRoleField(models.ForeignKey):
|
||||
field_names = [field_names]
|
||||
|
||||
for field_name in field_names:
|
||||
# Handle the OR syntax for role parents
|
||||
if type(field_name) == tuple:
|
||||
continue
|
||||
|
||||
if type(field_name) == bytes:
|
||||
field_name = field_name.decode('utf-8')
|
||||
|
||||
if field_name.startswith('singleton:'):
|
||||
continue
|
||||
|
||||
field_name, sep, field_attr = field_name.partition('.')
|
||||
field = getattr(cls, field_name)
|
||||
# Non existent fields will occur if ever a parent model is
|
||||
# moved inside a migration, needed for job_template_organization_field
|
||||
# migration in particular
|
||||
# consistency is assured by unit test awx.main.tests.functional
|
||||
field = getattr(cls, field_name, None)
|
||||
|
||||
if type(field) is ReverseManyToOneDescriptor or \
|
||||
if field and type(field) is ReverseManyToOneDescriptor or \
|
||||
type(field) is ManyToManyDescriptor:
|
||||
|
||||
if '.' in field_attr:
|
||||
|
||||
@ -192,21 +192,41 @@ class URLModificationMiddleware(MiddlewareMixin):
|
||||
)
|
||||
super().__init__(get_response)
|
||||
|
||||
def _named_url_to_pk(self, node, named_url):
|
||||
kwargs = {}
|
||||
if not node.populate_named_url_query_kwargs(kwargs, named_url):
|
||||
return named_url
|
||||
return str(get_object_or_404(node.model, **kwargs).pk)
|
||||
@staticmethod
|
||||
def _hijack_for_old_jt_name(node, kwargs, named_url):
|
||||
try:
|
||||
int(named_url)
|
||||
return False
|
||||
except ValueError:
|
||||
pass
|
||||
JobTemplate = node.model
|
||||
name = urllib.parse.unquote(named_url)
|
||||
return JobTemplate.objects.filter(name=name).order_by('organization__created').first()
|
||||
|
||||
def _convert_named_url(self, url_path):
|
||||
@classmethod
|
||||
def _named_url_to_pk(cls, node, resource, named_url):
|
||||
kwargs = {}
|
||||
if node.populate_named_url_query_kwargs(kwargs, named_url):
|
||||
return str(get_object_or_404(node.model, **kwargs).pk)
|
||||
if resource == 'job_templates' and '++' not in named_url:
|
||||
# special case for deprecated job template case
|
||||
# will not raise a 404 on its own
|
||||
jt = cls._hijack_for_old_jt_name(node, kwargs, named_url)
|
||||
if jt:
|
||||
return str(jt.pk)
|
||||
return named_url
|
||||
|
||||
@classmethod
|
||||
def _convert_named_url(cls, url_path):
|
||||
url_units = url_path.split('/')
|
||||
# If the identifier is an empty string, it is always invalid.
|
||||
if len(url_units) < 6 or url_units[1] != 'api' or url_units[2] not in ['v2'] or not url_units[4]:
|
||||
return url_path
|
||||
resource = url_units[3]
|
||||
if resource in settings.NAMED_URL_MAPPINGS:
|
||||
url_units[4] = self._named_url_to_pk(settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]],
|
||||
url_units[4])
|
||||
url_units[4] = cls._named_url_to_pk(
|
||||
settings.NAMED_URL_GRAPH[settings.NAMED_URL_MAPPINGS[resource]],
|
||||
resource, url_units[4])
|
||||
return '/'.join(url_units)
|
||||
|
||||
def process_request(self, request):
|
||||
|
||||
@ -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),
|
||||
]
|
||||
@ -1,6 +1,9 @@
|
||||
import logging
|
||||
from time import time
|
||||
|
||||
from django.db.models import Subquery, OuterRef
|
||||
|
||||
from awx.main.fields import update_role_parentage_for_instance
|
||||
from awx.main.models.rbac import Role, batch_role_ancestor_rebuilding
|
||||
|
||||
logger = logging.getLogger('rbac_migrations')
|
||||
@ -10,11 +13,11 @@ def create_roles(apps, schema_editor):
|
||||
'''
|
||||
Implicit role creation happens in our post_save hook for all of our
|
||||
resources. Here we iterate through all of our resource types and call
|
||||
.save() to ensure all that happens for every object in the system before we
|
||||
get busy with the actual migration work.
|
||||
.save() to ensure all that happens for every object in the system.
|
||||
|
||||
This gets run after migrate_users, which does role creation for users a
|
||||
little differently.
|
||||
This can be used whenever new roles are introduced in a migration to
|
||||
create those roles for pre-existing objects that did not previously
|
||||
have them created via signals.
|
||||
'''
|
||||
|
||||
models = [
|
||||
@ -35,7 +38,118 @@ def create_roles(apps, schema_editor):
|
||||
obj.save()
|
||||
|
||||
|
||||
def delete_all_user_roles(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', "ContentType")
|
||||
Role = apps.get_model('main', "Role")
|
||||
User = apps.get_model('auth', "User")
|
||||
user_content_type = ContentType.objects.get_for_model(User)
|
||||
for role in Role.objects.filter(content_type=user_content_type).iterator():
|
||||
role.delete()
|
||||
|
||||
|
||||
UNIFIED_ORG_LOOKUPS = {
|
||||
# Job Templates had an implicit organization via their project
|
||||
'jobtemplate': 'project',
|
||||
# Inventory Sources had an implicit organization via their inventory
|
||||
'inventorysource': 'inventory',
|
||||
# Projects had an explicit organization in their subclass table
|
||||
'project': None,
|
||||
# Workflow JTs also had an explicit organization in their subclass table
|
||||
'workflowjobtemplate': None,
|
||||
# Jobs inherited project from job templates as a convenience field
|
||||
'job': 'project',
|
||||
# Inventory Sources had an convenience field of inventory
|
||||
'inventoryupdate': 'inventory',
|
||||
# Project Updates did not have a direct organization field, obtained it from project
|
||||
'projectupdate': 'project',
|
||||
# Workflow Jobs are handled same as project updates
|
||||
# Sliced jobs are a special case, but old data is not given special treatment for simplicity
|
||||
'workflowjob': 'workflow_job_template',
|
||||
# AdHocCommands do not have a template, but still migrate them
|
||||
'adhoccommand': 'inventory'
|
||||
}
|
||||
|
||||
|
||||
def implicit_org_subquery(UnifiedClass, cls, backward=False):
|
||||
"""Returns a subquery that returns the so-called organization for objects
|
||||
in the class in question, before migration to the explicit unified org field.
|
||||
In some cases, this can still be applied post-migration.
|
||||
"""
|
||||
if cls._meta.model_name not in UNIFIED_ORG_LOOKUPS:
|
||||
return None
|
||||
cls_name = cls._meta.model_name
|
||||
source_field = UNIFIED_ORG_LOOKUPS[cls_name]
|
||||
|
||||
unified_field = UnifiedClass._meta.get_field(cls_name)
|
||||
unified_ptr = unified_field.remote_field.name
|
||||
if backward:
|
||||
qs = UnifiedClass.objects.filter(**{cls_name: OuterRef('id')}).order_by().values_list('tmp_organization')[:1]
|
||||
elif source_field is None:
|
||||
qs = cls.objects.filter(**{unified_ptr: OuterRef('id')}).order_by().values_list('organization')[:1]
|
||||
else:
|
||||
intermediary_field = cls._meta.get_field(source_field)
|
||||
intermediary_model = intermediary_field.related_model
|
||||
intermediary_reverse_rel = intermediary_field.remote_field.name
|
||||
qs = intermediary_model.objects.filter(**{
|
||||
# this filter leverages the fact that the Unified models have same pk as subclasses.
|
||||
# For instance... filters projects used in job template, where that job template
|
||||
# has same id same as UJT from the outer reference (which it does)
|
||||
intermediary_reverse_rel: OuterRef('id')}
|
||||
).order_by().values_list('organization')[:1]
|
||||
return Subquery(qs)
|
||||
|
||||
|
||||
def _migrate_unified_organization(apps, unified_cls_name, backward=False):
|
||||
"""Given a unified base model (either UJT or UJ)
|
||||
and a dict org_field_mapping which gives related model to get org from
|
||||
saves organization for those objects to the temporary migration
|
||||
variable tmp_organization on the unified model
|
||||
(optimized method)
|
||||
"""
|
||||
start = time()
|
||||
UnifiedClass = apps.get_model('main', unified_cls_name)
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
|
||||
for cls in UnifiedClass.__subclasses__():
|
||||
cls_name = cls._meta.model_name
|
||||
if backward and UNIFIED_ORG_LOOKUPS.get(cls_name, 'not-found') is not None:
|
||||
logger.debug('Not reverse migrating {}, existing data should remain valid'.format(cls_name))
|
||||
continue
|
||||
logger.debug('Migrating {} to new organization field'.format(cls_name))
|
||||
|
||||
sub_qs = implicit_org_subquery(UnifiedClass, cls, backward=backward)
|
||||
if sub_qs is None:
|
||||
logger.debug('Class {} has no organization migration'.format(cls_name))
|
||||
continue
|
||||
|
||||
this_ct = ContentType.objects.get_for_model(cls)
|
||||
if backward:
|
||||
r = cls.objects.order_by().update(organization=sub_qs)
|
||||
else:
|
||||
r = UnifiedClass.objects.order_by().filter(polymorphic_ctype=this_ct).update(tmp_organization=sub_qs)
|
||||
if r:
|
||||
logger.info('Organization migration on {} affected {} rows.'.format(cls_name, r))
|
||||
logger.info('Unified organization migration completed in %f seconds' % (time() - start))
|
||||
|
||||
|
||||
def migrate_ujt_organization(apps, schema_editor):
|
||||
'''Move organization field to UJT and UJ models'''
|
||||
_migrate_unified_organization(apps, 'UnifiedJobTemplate')
|
||||
_migrate_unified_organization(apps, 'UnifiedJob')
|
||||
|
||||
|
||||
def migrate_ujt_organization_backward(apps, schema_editor):
|
||||
'''Move organization field from UJT and UJ models back to their original places'''
|
||||
_migrate_unified_organization(apps, 'UnifiedJobTemplate', backward=True)
|
||||
_migrate_unified_organization(apps, 'UnifiedJob', backward=True)
|
||||
|
||||
|
||||
def rebuild_role_hierarchy(apps, schema_editor):
|
||||
'''
|
||||
This should be called in any migration when ownerships are changed.
|
||||
Ex. I remove a user from the admin_role of a credential.
|
||||
Ancestors are cached from parents for performance, this re-computes ancestors.
|
||||
'''
|
||||
logger.info('Computing role roots..')
|
||||
start = time()
|
||||
roots = Role.objects \
|
||||
@ -46,14 +160,57 @@ def rebuild_role_hierarchy(apps, schema_editor):
|
||||
start = time()
|
||||
Role.rebuild_role_ancestor_list(roots, [])
|
||||
stop = time()
|
||||
logger.info('Rebuild completed in %f seconds' % (stop - start))
|
||||
logger.info('Rebuild ancestors completed in %f seconds' % (stop - start))
|
||||
logger.info('Done.')
|
||||
|
||||
|
||||
def delete_all_user_roles(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', "ContentType")
|
||||
def rebuild_role_parentage(apps, schema_editor):
|
||||
'''
|
||||
This should be called in any migration when any parent_role entry
|
||||
is modified so that the cached parent fields will be updated. Ex:
|
||||
foo_role = ImplicitRoleField(
|
||||
parent_role=['bar_role'] # change to parent_role=['admin_role']
|
||||
)
|
||||
|
||||
This is like rebuild_role_hierarchy, but that method updates ancestors,
|
||||
whereas this method updates parents.
|
||||
'''
|
||||
start = time()
|
||||
seen_models = set()
|
||||
updated_ct = 0
|
||||
model_ct = 0
|
||||
noop_ct = 0
|
||||
Role = apps.get_model('main', "Role")
|
||||
User = apps.get_model('auth', "User")
|
||||
user_content_type = ContentType.objects.get_for_model(User)
|
||||
for role in Role.objects.filter(content_type=user_content_type).iterator():
|
||||
role.delete()
|
||||
for role in Role.objects.iterator():
|
||||
if not role.object_id:
|
||||
noop_ct += 1
|
||||
continue
|
||||
model_tuple = (role.content_type_id, role.object_id)
|
||||
if model_tuple in seen_models:
|
||||
continue
|
||||
seen_models.add(model_tuple)
|
||||
|
||||
# The GenericForeignKey does not work right in migrations
|
||||
# with the usage as role.content_object
|
||||
# so we do the lookup ourselves with current migration models
|
||||
ct = role.content_type
|
||||
app = ct.app_label
|
||||
ct_model = apps.get_model(app, ct.model)
|
||||
content_object = ct_model.objects.get(pk=role.object_id)
|
||||
|
||||
updated = update_role_parentage_for_instance(content_object)
|
||||
if updated:
|
||||
model_ct += 1
|
||||
logger.debug('Updated parents of {} roles of {}'.format(updated, content_object))
|
||||
else:
|
||||
noop_ct += 1
|
||||
updated_ct += updated
|
||||
|
||||
logger.debug('No changes to role parents for {} roles'.format(noop_ct))
|
||||
if updated_ct:
|
||||
logger.info('Updated parentage for {} roles of {} resources'.format(updated_ct, model_ct))
|
||||
|
||||
logger.info('Rebuild parentage completed in %f seconds' % (time() - start))
|
||||
|
||||
if updated_ct:
|
||||
rebuild_role_hierarchy(apps, schema_editor)
|
||||
|
||||
@ -426,9 +426,9 @@ class Inventory(CommonModelNameNotUnique, ResourceMixin, RelatedJobsMixin):
|
||||
'''
|
||||
def _get_related_jobs(self):
|
||||
return UnifiedJob.objects.non_polymorphic().filter(
|
||||
Q(Job___inventory=self) |
|
||||
Q(InventoryUpdate___inventory_source__inventory=self) |
|
||||
Q(AdHocCommand___inventory=self)
|
||||
Q(job__inventory=self) |
|
||||
Q(inventoryupdate__inventory=self) |
|
||||
Q(adhoccommand__inventory=self)
|
||||
)
|
||||
|
||||
|
||||
@ -808,8 +808,8 @@ class Group(CommonModelNameNotUnique, RelatedJobsMixin):
|
||||
'''
|
||||
def _get_related_jobs(self):
|
||||
return UnifiedJob.objects.non_polymorphic().filter(
|
||||
Q(Job___inventory=self.inventory) |
|
||||
Q(InventoryUpdate___inventory_source__groups=self)
|
||||
Q(job__inventory=self.inventory) |
|
||||
Q(inventoryupdate__inventory_source__groups=self)
|
||||
)
|
||||
|
||||
|
||||
@ -1277,10 +1277,14 @@ class InventorySource(UnifiedJobTemplate, InventorySourceOptions, CustomVirtualE
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in InventorySourceOptions._meta.fields) | set(
|
||||
['name', 'description', 'credentials', 'inventory']
|
||||
['name', 'description', 'organization', 'credentials', 'inventory']
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# if this is a new object, inherit organization from its inventory
|
||||
if not self.pk and self.inventory and self.inventory.organization_id and not self.organization_id:
|
||||
self.organization_id = self.inventory.organization_id
|
||||
|
||||
# If update_fields has been specified, add our field names to it,
|
||||
# if it hasn't been specified, then we're just doing a normal save.
|
||||
update_fields = kwargs.get('update_fields', [])
|
||||
|
||||
@ -199,7 +199,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
'labels', 'instance_groups', 'credentials', 'survey_spec'
|
||||
]
|
||||
FIELDS_TO_DISCARD_AT_COPY = ['vault_credential', 'credential']
|
||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name')]
|
||||
SOFT_UNIQUE_TOGETHER = [('polymorphic_ctype', 'name', 'organization')]
|
||||
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
@ -262,13 +262,17 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
)
|
||||
|
||||
admin_role = ImplicitRoleField(
|
||||
parent_role=['project.organization.job_template_admin_role', 'inventory.organization.job_template_admin_role']
|
||||
parent_role=['organization.job_template_admin_role']
|
||||
)
|
||||
execute_role = ImplicitRoleField(
|
||||
parent_role=['admin_role', 'project.organization.execute_role', 'inventory.organization.execute_role'],
|
||||
parent_role=['admin_role', 'organization.execute_role'],
|
||||
)
|
||||
read_role = ImplicitRoleField(
|
||||
parent_role=['project.organization.auditor_role', 'inventory.organization.auditor_role', 'execute_role', 'admin_role'],
|
||||
parent_role=[
|
||||
'organization.auditor_role',
|
||||
'inventory.organization.auditor_role', # partial support for old inheritance via inventory
|
||||
'execute_role', 'admin_role'
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@ -279,7 +283,7 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in JobOptions._meta.fields) | set(
|
||||
['name', 'description', 'survey_passwords', 'labels', 'credentials',
|
||||
['name', 'description', 'organization', 'survey_passwords', 'labels', 'credentials',
|
||||
'job_slice_number', 'job_slice_count']
|
||||
)
|
||||
|
||||
@ -479,13 +483,13 @@ class JobTemplate(UnifiedJobTemplate, JobOptions, SurveyJobTemplateMixin, Resour
|
||||
success_notification_templates = list(base_notification_templates.filter(
|
||||
unifiedjobtemplate_notification_templates_for_success__in=[self, self.project]))
|
||||
# Get Organization NotificationTemplates
|
||||
if self.project is not None and self.project.organization is not None:
|
||||
if self.organization is not None:
|
||||
error_notification_templates = set(error_notification_templates + list(base_notification_templates.filter(
|
||||
organization_notification_templates_for_errors=self.project.organization)))
|
||||
organization_notification_templates_for_errors=self.organization)))
|
||||
started_notification_templates = set(started_notification_templates + list(base_notification_templates.filter(
|
||||
organization_notification_templates_for_started=self.project.organization)))
|
||||
organization_notification_templates_for_started=self.organization)))
|
||||
success_notification_templates = set(success_notification_templates + list(base_notification_templates.filter(
|
||||
organization_notification_templates_for_success=self.project.organization)))
|
||||
organization_notification_templates_for_success=self.organization)))
|
||||
return dict(error=list(error_notification_templates),
|
||||
started=list(started_notification_templates),
|
||||
success=list(success_notification_templates))
|
||||
@ -588,7 +592,7 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
for virtualenv in (
|
||||
self.job_template.custom_virtualenv if self.job_template else None,
|
||||
self.project.custom_virtualenv,
|
||||
self.project.organization.custom_virtualenv if self.project.organization else None
|
||||
self.organization.custom_virtualenv if self.organization else None
|
||||
):
|
||||
if virtualenv:
|
||||
return virtualenv
|
||||
@ -741,8 +745,8 @@ class Job(UnifiedJob, JobOptions, SurveyJobMixin, JobNotificationMixin, TaskMana
|
||||
|
||||
@property
|
||||
def preferred_instance_groups(self):
|
||||
if self.project is not None and self.project.organization is not None:
|
||||
organization_groups = [x for x in self.project.organization.instance_groups.all()]
|
||||
if self.organization is not None:
|
||||
organization_groups = [x for x in self.organization.instance_groups.all()]
|
||||
else:
|
||||
organization_groups = []
|
||||
if self.inventory is not None:
|
||||
@ -1144,7 +1148,7 @@ class SystemJobTemplate(UnifiedJobTemplate, SystemJobOptions):
|
||||
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return ['name', 'description', 'job_type', 'extra_vars']
|
||||
return ['name', 'description', 'organization', 'job_type', 'extra_vars']
|
||||
|
||||
def get_absolute_url(self, request=None):
|
||||
return reverse('api:system_job_template_detail', kwargs={'pk': self.pk}, request=request)
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
# Django
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.utils.timezone import now as tz_now
|
||||
@ -106,12 +105,7 @@ class Organization(CommonModel, NotificationFieldsModel, ResourceMixin, CustomVi
|
||||
RelatedJobsMixin
|
||||
'''
|
||||
def _get_related_jobs(self):
|
||||
project_ids = self.projects.all().values_list('id')
|
||||
return UnifiedJob.objects.non_polymorphic().filter(
|
||||
Q(Job___project__in=project_ids) |
|
||||
Q(ProjectUpdate___project__in=project_ids) |
|
||||
Q(InventoryUpdate___inventory_source__inventory__organization=self)
|
||||
)
|
||||
return UnifiedJob.objects.non_polymorphic().filter(organization=self)
|
||||
|
||||
|
||||
class Team(CommonModelNameNotUnique, ResourceMixin):
|
||||
|
||||
@ -254,13 +254,6 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
app_label = 'main'
|
||||
ordering = ('id',)
|
||||
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='projects',
|
||||
)
|
||||
scm_update_on_launch = models.BooleanField(
|
||||
default=False,
|
||||
help_text=_('Update the project when a job is launched that uses the project.'),
|
||||
@ -329,7 +322,7 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
return set(f.name for f in ProjectOptions._meta.fields) | set(
|
||||
['name', 'description']
|
||||
['name', 'description', 'organization']
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@ -450,8 +443,8 @@ class Project(UnifiedJobTemplate, ProjectOptions, ResourceMixin, CustomVirtualEn
|
||||
'''
|
||||
def _get_related_jobs(self):
|
||||
return UnifiedJob.objects.non_polymorphic().filter(
|
||||
models.Q(Job___project=self) |
|
||||
models.Q(ProjectUpdate___project=self)
|
||||
models.Q(job__project=self) |
|
||||
models.Q(projectupdate__project=self)
|
||||
)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
|
||||
@ -157,6 +157,14 @@ class UnifiedJobTemplate(PolymorphicModel, CommonModelNameNotUnique, Notificatio
|
||||
default='ok',
|
||||
editable=False,
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=polymorphic.SET_NULL,
|
||||
related_name='%(class)ss',
|
||||
help_text=_('The organization used to determine access to this template.'),
|
||||
)
|
||||
credentials = models.ManyToManyField(
|
||||
'Credential',
|
||||
related_name='%(class)ss',
|
||||
@ -700,6 +708,14 @@ class UnifiedJob(PolymorphicModel, PasswordFieldsModel, CommonModelNameNotUnique
|
||||
on_delete=polymorphic.SET_NULL,
|
||||
help_text=_('The Rampart/Instance group the job was run under'),
|
||||
)
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=polymorphic.SET_NULL,
|
||||
related_name='%(class)ss',
|
||||
help_text=_('The organization used to determine access to this unified job.'),
|
||||
)
|
||||
credentials = models.ManyToManyField(
|
||||
'Credential',
|
||||
related_name='%(class)ss',
|
||||
|
||||
@ -335,7 +335,7 @@ class WorkflowJobOptions(LaunchTimeConfigBase):
|
||||
@classmethod
|
||||
def _get_unified_job_field_names(cls):
|
||||
r = set(f.name for f in WorkflowJobOptions._meta.fields) | set(
|
||||
['name', 'description', 'survey_passwords', 'labels', 'limit', 'scm_branch']
|
||||
['name', 'description', 'organization', 'survey_passwords', 'labels', 'limit', 'scm_branch']
|
||||
)
|
||||
r.remove('char_prompts') # needed due to copying launch config to launch config
|
||||
return r
|
||||
@ -382,13 +382,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl
|
||||
class Meta:
|
||||
app_label = 'main'
|
||||
|
||||
organization = models.ForeignKey(
|
||||
'Organization',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='workflows',
|
||||
)
|
||||
ask_inventory_on_launch = AskForField(
|
||||
blank=True,
|
||||
default=False,
|
||||
|
||||
@ -183,7 +183,6 @@ def connect_computed_field_signals():
|
||||
|
||||
connect_computed_field_signals()
|
||||
|
||||
post_save.connect(save_related_job_templates, sender=Project)
|
||||
post_save.connect(save_related_job_templates, sender=Inventory)
|
||||
m2m_changed.connect(rebuild_role_ancestor_list, Role.parents.through)
|
||||
m2m_changed.connect(rbac_activity_stream, Role.members.through)
|
||||
|
||||
@ -159,7 +159,8 @@ def mk_job_template(name, job_type='run',
|
||||
extra_vars = json.dumps(extra_vars)
|
||||
|
||||
jt = JobTemplate(name=name, job_type=job_type, extra_vars=extra_vars,
|
||||
webhook_service=webhook_service, playbook='helloworld.yml')
|
||||
webhook_service=webhook_service, playbook='helloworld.yml',
|
||||
organization=organization)
|
||||
|
||||
jt.inventory = inventory
|
||||
if jt.inventory is None:
|
||||
|
||||
@ -255,7 +255,7 @@ def create_job_template(name, roles=None, persisted=True, webhook_service='', **
|
||||
jt = mk_job_template(name, project=proj, inventory=inv, credential=cred,
|
||||
network_credential=net_cred, cloud_credential=cloud_cred,
|
||||
job_type=job_type, spec=spec, extra_vars=extra_vars,
|
||||
persisted=persisted, webhook_service=webhook_service)
|
||||
persisted=persisted, webhook_service=webhook_service, organization=org)
|
||||
|
||||
if 'jobs' in kwargs:
|
||||
for i in kwargs['jobs']:
|
||||
|
||||
@ -278,6 +278,7 @@ def test_multi_vault_preserved_on_put(get, put, admin_user, job_template, vault_
|
||||
job_template.credentials.add(vault_credential, vault2)
|
||||
assert job_template.credentials.count() == 2 # sanity check
|
||||
r = get(job_template.get_absolute_url(), admin_user, expect=200)
|
||||
r.data.pop('organization') # so that it passes validation
|
||||
# should be a no-op PUT request
|
||||
put(
|
||||
job_template.get_absolute_url(),
|
||||
|
||||
@ -39,6 +39,26 @@ def test_extra_credentials(get, organization_factory, job_template_factory, cred
|
||||
@pytest.mark.django_db
|
||||
def test_job_relaunch_permission_denied_response(
|
||||
post, get, inventory, project, credential, net_credential, machine_credential):
|
||||
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project, ask_credential_on_launch=True)
|
||||
jt.credentials.add(machine_credential)
|
||||
jt_user = User.objects.create(username='jobtemplateuser')
|
||||
jt.execute_role.members.add(jt_user)
|
||||
with impersonate(jt_user):
|
||||
job = jt.create_unified_job()
|
||||
|
||||
# User capability is shown for this
|
||||
r = get(job.get_absolute_url(), jt_user, expect=200)
|
||||
assert r.data['summary_fields']['user_capabilities']['start']
|
||||
|
||||
# Job has prompted extra_credential, launch denied w/ message
|
||||
job.launch_config.credentials.add(net_credential)
|
||||
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
|
||||
assert 'launched with prompted fields which you do not have access to' in r.data['detail']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_relaunch_prompts_not_accepted_response(
|
||||
post, get, inventory, project, credential, net_credential, machine_credential):
|
||||
jt = JobTemplate.objects.create(name='testjt', inventory=inventory, project=project)
|
||||
jt.credentials.add(machine_credential)
|
||||
jt_user = User.objects.create(username='jobtemplateuser')
|
||||
@ -53,8 +73,7 @@ def test_job_relaunch_permission_denied_response(
|
||||
# Job has prompted extra_credential, launch denied w/ message
|
||||
job.launch_config.credentials.add(net_credential)
|
||||
r = post(reverse('api:job_relaunch', kwargs={'pk':job.pk}), {}, jt_user, expect=403)
|
||||
assert 'launched with prompted fields' in r.data['detail']
|
||||
assert 'do not have permission' in r.data['detail']
|
||||
assert 'no longer accepts the prompts provided for this job' in r.data['detail']
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -201,7 +220,8 @@ def test_block_unprocessed_events(delete, admin_user, mocker):
|
||||
def test_block_related_unprocessed_events(mocker, organization, project, delete, admin_user):
|
||||
job_template = JobTemplate.objects.create(
|
||||
project=project,
|
||||
playbook='helloworld.yml'
|
||||
playbook='helloworld.yml',
|
||||
organization=organization
|
||||
)
|
||||
time_of_finish = parse("Thu Feb 23 14:17:24 2012 -0500")
|
||||
Job.objects.create(
|
||||
@ -209,7 +229,8 @@ def test_block_related_unprocessed_events(mocker, organization, project, delete,
|
||||
status='finished',
|
||||
finished=time_of_finish,
|
||||
job_template=job_template,
|
||||
project=project
|
||||
project=project,
|
||||
organization=organization
|
||||
)
|
||||
view = RelatedJobsPreventDeleteMixin()
|
||||
time_of_request = time_of_finish + relativedelta(seconds=2)
|
||||
|
||||
@ -6,7 +6,7 @@ import pytest
|
||||
# AWX
|
||||
from awx.api.serializers import JobTemplateSerializer
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate
|
||||
from awx.main.models import Job, JobTemplate, CredentialType, WorkflowJobTemplate, Organization
|
||||
from awx.main.migrations import _save_password_keys as save_password_keys
|
||||
|
||||
# Django
|
||||
@ -30,16 +30,55 @@ def test_create(post, project, machine_credential, inventory, alice, grant_proje
|
||||
project.use_role.members.add(alice)
|
||||
if grant_inventory:
|
||||
inventory.use_role.members.add(alice)
|
||||
project.organization.job_template_admin_role.members.add(alice)
|
||||
|
||||
r = post(reverse('api:job_template_list'), {
|
||||
'name': 'Some name',
|
||||
'project': project.id,
|
||||
'inventory': inventory.id,
|
||||
'playbook': 'helloworld.yml',
|
||||
'organization': project.organization_id
|
||||
}, alice)
|
||||
assert r.status_code == expect
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_creation_uniqueness_rules(post, project, inventory, admin_user):
|
||||
orgA = Organization.objects.create(name='orga')
|
||||
orgB = Organization.objects.create(name='orgb')
|
||||
create_data = {
|
||||
'name': 'this_unique_name',
|
||||
'project': project.pk,
|
||||
'inventory': inventory.pk,
|
||||
'playbook': 'helloworld.yml',
|
||||
'organization': orgA.pk
|
||||
}
|
||||
post(
|
||||
url=reverse('api:job_template_list'),
|
||||
data=create_data,
|
||||
user=admin_user,
|
||||
expect=201
|
||||
)
|
||||
r = post(
|
||||
url=reverse('api:job_template_list'),
|
||||
data=create_data,
|
||||
user=admin_user,
|
||||
expect=400
|
||||
)
|
||||
msg = str(r.data['__all__'][0])
|
||||
assert "JobTemplate with this (" in msg
|
||||
assert ") combination already exists" in msg
|
||||
|
||||
# can create JT with same name, only if it is in different org
|
||||
create_data['organization'] = orgB.pk
|
||||
post(
|
||||
url=reverse('api:job_template_list'),
|
||||
data=create_data,
|
||||
user=admin_user,
|
||||
expect=201
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_extra_credential_creation(get, post, organization_factory, job_template_factory, credentialtype_aws):
|
||||
objs = organization_factory("org", superusers=['admin'])
|
||||
@ -524,13 +563,14 @@ def test_callback_disallowed_null_inventory(project):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_branch_error(project, inventory, post, admin_user):
|
||||
def test_job_template_branch_error(project, inventory, organization, post, admin_user):
|
||||
r = post(
|
||||
url=reverse('api:job_template_list'),
|
||||
data={
|
||||
"name": "fooo",
|
||||
"inventory": inventory.pk,
|
||||
"project": project.pk,
|
||||
"organization": organization.pk,
|
||||
"playbook": "helloworld.yml",
|
||||
"scm_branch": "foobar"
|
||||
},
|
||||
@ -541,13 +581,14 @@ def test_job_template_branch_error(project, inventory, post, admin_user):
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_branch_prompt_error(project, inventory, post, admin_user):
|
||||
def test_job_template_branch_prompt_error(project, inventory, post, organization, admin_user):
|
||||
r = post(
|
||||
url=reverse('api:job_template_list'),
|
||||
data={
|
||||
"name": "fooo",
|
||||
"inventory": inventory.pk,
|
||||
"project": project.pk,
|
||||
"organization": organization.pk,
|
||||
"playbook": "helloworld.yml",
|
||||
"ask_scm_branch_on_launch": True
|
||||
},
|
||||
|
||||
@ -2,6 +2,8 @@ import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
|
||||
from awx.main.models import Project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def organization_resource_creator(organization, user):
|
||||
@ -19,21 +21,26 @@ def organization_resource_creator(organization, user):
|
||||
for i in range(inventories):
|
||||
inventory = organization.inventories.create(name="associated-inv %s" % i)
|
||||
for i in range(projects):
|
||||
organization.projects.create(name="test-proj %s" % i,
|
||||
description="test-proj-desc")
|
||||
Project.objects.create(
|
||||
name="test-proj %s" % i,
|
||||
description="test-proj-desc",
|
||||
organization=organization
|
||||
)
|
||||
# Mix up the inventories and projects used by the job templates
|
||||
i_proj = 0
|
||||
i_inv = 0
|
||||
for i in range(job_templates):
|
||||
project = organization.projects.all()[i_proj]
|
||||
project = Project.objects.filter(organization=organization)[i_proj]
|
||||
# project = organization.projects.all()[i_proj]
|
||||
inventory = organization.inventories.all()[i_inv]
|
||||
project.jobtemplates.create(name="test-jt %s" % i,
|
||||
description="test-job-template-desc",
|
||||
inventory=inventory,
|
||||
playbook="test_playbook.yml")
|
||||
playbook="test_playbook.yml",
|
||||
organization=organization)
|
||||
i_proj += 1
|
||||
i_inv += 1
|
||||
if i_proj >= organization.projects.count():
|
||||
if i_proj >= Project.objects.filter(organization=organization).count():
|
||||
i_proj = 0
|
||||
if i_inv >= organization.inventories.count():
|
||||
i_inv = 0
|
||||
@ -179,12 +186,14 @@ def test_scan_JT_counted(resourced_organization, user, get):
|
||||
@pytest.mark.django_db
|
||||
def test_JT_not_double_counted(resourced_organization, user, get):
|
||||
admin_user = user('admin', True)
|
||||
proj = Project.objects.filter(organization=resourced_organization).all()[0]
|
||||
# Add a run job template to the org
|
||||
resourced_organization.projects.all()[0].jobtemplates.create(
|
||||
proj.jobtemplates.create(
|
||||
job_type='run',
|
||||
inventory=resourced_organization.inventories.all()[0],
|
||||
project=resourced_organization.projects.all()[0],
|
||||
name='double-linked-job-template')
|
||||
project=proj,
|
||||
name='double-linked-job-template',
|
||||
organization=resourced_organization)
|
||||
counts_dict = COUNTS_PRIMES
|
||||
counts_dict['job_templates'] += 1
|
||||
|
||||
@ -197,38 +206,3 @@ def test_JT_not_double_counted(resourced_organization, user, get):
|
||||
detail_response = get(reverse('api:organization_detail', kwargs={'pk': resourced_organization.pk}), admin_user)
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.data['summary_fields']['related_field_counts'] == counts_dict
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_JT_associated_with_project(organizations, project, user, get):
|
||||
# Check that adding a project to an organization gets the project's JT
|
||||
# included in the organization's JT count
|
||||
external_admin = user('admin', True)
|
||||
two_orgs = organizations(2)
|
||||
organization = two_orgs[0]
|
||||
other_org = two_orgs[1]
|
||||
|
||||
unrelated_inv = other_org.inventories.create(name='not-in-organization')
|
||||
organization.projects.add(project)
|
||||
project.jobtemplates.create(name="test-jt",
|
||||
description="test-job-template-desc",
|
||||
inventory=unrelated_inv,
|
||||
playbook="test_playbook.yml")
|
||||
|
||||
response = get(reverse('api:organization_list'), external_admin)
|
||||
assert response.status_code == 200
|
||||
|
||||
org_id = organization.id
|
||||
counts = {}
|
||||
for org_json in response.data['results']:
|
||||
working_id = org_json['id']
|
||||
counts[working_id] = org_json['summary_fields']['related_field_counts']
|
||||
|
||||
assert counts[org_id] == {
|
||||
'users': 0,
|
||||
'admins': 0,
|
||||
'job_templates': 1,
|
||||
'projects': 1,
|
||||
'inventories': 0,
|
||||
'teams': 0
|
||||
}
|
||||
|
||||
@ -61,7 +61,7 @@ class TestJobTemplateCopyEdit:
|
||||
def jt_copy_edit(self, job_template_factory, project):
|
||||
objects = job_template_factory(
|
||||
'copy-edit-job-template',
|
||||
project=project)
|
||||
project=project, organization=project.organization)
|
||||
return objects.job_template
|
||||
|
||||
def fake_context(self, user):
|
||||
@ -129,9 +129,8 @@ class TestJobTemplateCopyEdit:
|
||||
|
||||
# random user given JT and project admin abilities
|
||||
jt_copy_edit.admin_role.members.add(rando)
|
||||
jt_copy_edit.save()
|
||||
jt_copy_edit.project.admin_role.members.add(rando)
|
||||
jt_copy_edit.project.save()
|
||||
jt_copy_edit.organization.job_template_admin_role.members.add(rando)
|
||||
|
||||
serializer = JobTemplateSerializer(jt_copy_edit, context=self.fake_context(rando))
|
||||
response = serializer.to_representation(jt_copy_edit)
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from awx.api.versioning import reverse
|
||||
from awx.main import models
|
||||
from awx.main.utils import get_type_for_model
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -9,3 +11,111 @@ def test_aliased_forward_reverse_field_searches(instance, options, get, admin):
|
||||
response = options(url, None, admin)
|
||||
assert 'job_template__search' in response.data['related_search_fields']
|
||||
get(reverse("api:unified_job_template_list") + "?job_template__search=anything", user=admin, expect=200)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.parametrize('model', (
|
||||
'Project',
|
||||
'JobTemplate',
|
||||
'WorkflowJobTemplate'
|
||||
))
|
||||
class TestUnifiedOrganization:
|
||||
|
||||
def data_for_model(self, model, orm_style=False):
|
||||
data = {
|
||||
'name': 'foo',
|
||||
'organization': None
|
||||
}
|
||||
if model == 'JobTemplate':
|
||||
proj = models.Project.objects.create(
|
||||
name="test-proj",
|
||||
playbook_files=['helloworld.yml']
|
||||
)
|
||||
if orm_style:
|
||||
data['project_id'] = proj.id
|
||||
else:
|
||||
data['project'] = proj.id
|
||||
data['playbook'] = 'helloworld.yml'
|
||||
data['ask_inventory_on_launch'] = True
|
||||
return data
|
||||
|
||||
def test_organization_required_on_creation(self, model, admin_user, post):
|
||||
cls = getattr(models, model)
|
||||
data = self.data_for_model(model)
|
||||
r = post(
|
||||
url=reverse('api:{}_list'.format(get_type_for_model(cls))),
|
||||
data=data,
|
||||
user=admin_user,
|
||||
expect=400
|
||||
)
|
||||
assert 'organization' in r.data
|
||||
assert 'required for new object' in r.data['organization'][0]
|
||||
# Surprising behavior - not providing the key can often give
|
||||
# different behavior from giving it as null on create
|
||||
data.pop('organization')
|
||||
r = post(
|
||||
url=reverse('api:{}_list'.format(get_type_for_model(cls))),
|
||||
data=data,
|
||||
user=admin_user,
|
||||
expect=400
|
||||
)
|
||||
assert 'organization' in r.data
|
||||
assert 'required' in r.data['organization'][0]
|
||||
|
||||
def test_organization_blank_on_edit_of_orphan(self, model, admin_user, patch):
|
||||
cls = getattr(models, model)
|
||||
data = self.data_for_model(model, orm_style=True)
|
||||
obj = cls.objects.create(**data)
|
||||
patch(
|
||||
url=obj.get_absolute_url(),
|
||||
data={'name': 'foooooo'},
|
||||
user=admin_user,
|
||||
expect=200
|
||||
)
|
||||
obj.refresh_from_db()
|
||||
assert obj.name == 'foooooo'
|
||||
|
||||
def test_organization_blank_on_edit_of_orphan_as_nonsuperuser(self, model, rando, patch):
|
||||
"""Test case reflects historical bug where ordinary users got weird error
|
||||
message when editing an orphaned project
|
||||
"""
|
||||
cls = getattr(models, model)
|
||||
data = self.data_for_model(model, orm_style=True)
|
||||
obj = cls.objects.create(**data)
|
||||
if model == 'JobTemplate':
|
||||
obj.project.admin_role.members.add(rando)
|
||||
obj.admin_role.members.add(rando)
|
||||
patch(
|
||||
url=obj.get_absolute_url(),
|
||||
data={'name': 'foooooo'},
|
||||
user=rando,
|
||||
expect=200
|
||||
)
|
||||
obj.refresh_from_db()
|
||||
assert obj.name == 'foooooo'
|
||||
|
||||
def test_organization_blank_on_edit_of_normal(self, model, admin_user, patch, organization):
|
||||
cls = getattr(models, model)
|
||||
data = self.data_for_model(model, orm_style=True)
|
||||
data['organization'] = organization
|
||||
obj = cls.objects.create(**data)
|
||||
patch(
|
||||
url=obj.get_absolute_url(),
|
||||
data={'name': 'foooooo'},
|
||||
user=admin_user,
|
||||
expect=200
|
||||
)
|
||||
obj.refresh_from_db()
|
||||
assert obj.name == 'foooooo'
|
||||
|
||||
def test_organization_cannot_change_to_null(self, model, admin_user, patch, organization):
|
||||
cls = getattr(models, model)
|
||||
data = self.data_for_model(model, orm_style=True)
|
||||
data['organization'] = organization
|
||||
obj = cls.objects.create(**data)
|
||||
patch(
|
||||
url=obj.get_absolute_url(),
|
||||
data={'organization': None},
|
||||
user=admin_user,
|
||||
expect=400
|
||||
)
|
||||
|
||||
@ -75,24 +75,26 @@ def user():
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def check_jobtemplate(project, inventory, credential):
|
||||
def check_jobtemplate(project, inventory, credential, organization):
|
||||
jt = JobTemplate.objects.create(
|
||||
job_type='check',
|
||||
project=project,
|
||||
inventory=inventory,
|
||||
name='check-job-template'
|
||||
name='check-job-template',
|
||||
organization=organization
|
||||
)
|
||||
jt.credentials.add(credential)
|
||||
return jt
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def deploy_jobtemplate(project, inventory, credential):
|
||||
def deploy_jobtemplate(project, inventory, credential, organization):
|
||||
jt = JobTemplate.objects.create(
|
||||
job_type='run',
|
||||
project=project,
|
||||
inventory=inventory,
|
||||
name='deploy-job-template'
|
||||
name='deploy-job-template',
|
||||
organization=organization
|
||||
)
|
||||
jt.credentials.add(credential)
|
||||
return jt
|
||||
@ -180,8 +182,8 @@ def project_factory(organization):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_factory(job_template, admin):
|
||||
def factory(job_template=job_template, initial_state='new', created_by=admin):
|
||||
def job_factory(jt_linked, admin):
|
||||
def factory(job_template=jt_linked, initial_state='new', created_by=admin):
|
||||
return job_template.create_unified_job(_eager_fields={
|
||||
'status': initial_state, 'created_by': created_by})
|
||||
return factory
|
||||
@ -701,11 +703,8 @@ def ad_hoc_command_factory(inventory, machine_credential, admin):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def job_template(organization):
|
||||
jt = JobTemplate(name='test-job_template')
|
||||
jt.save()
|
||||
|
||||
return jt
|
||||
def job_template():
|
||||
return JobTemplate.objects.create(name='test-job_template')
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -717,20 +716,16 @@ def job_template_labels(organization, job_template):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jt_linked(job_template_factory, credential, net_credential, vault_credential):
|
||||
def jt_linked(organization, project, inventory, machine_credential, credential, net_credential, vault_credential):
|
||||
'''
|
||||
A job template with a reasonably complete set of related objects to
|
||||
test RBAC and other functionality affected by related objects
|
||||
'''
|
||||
objects = job_template_factory(
|
||||
'testJT', organization='org1', project='proj1', inventory='inventory1',
|
||||
credential='cred1')
|
||||
jt = objects.job_template
|
||||
jt.credentials.add(vault_credential)
|
||||
jt.save()
|
||||
# Add AWS cloud credential and network credential
|
||||
jt.credentials.add(credential)
|
||||
jt.credentials.add(net_credential)
|
||||
jt = JobTemplate.objects.create(
|
||||
project=project, inventory=inventory, playbook='helloworld.yml',
|
||||
organization=organization
|
||||
)
|
||||
jt.credentials.add(machine_credential, vault_credential, credential, net_credential)
|
||||
return jt
|
||||
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ from awx.main.models import (
|
||||
CredentialType,
|
||||
Inventory,
|
||||
InventorySource,
|
||||
Project,
|
||||
User
|
||||
)
|
||||
|
||||
@ -99,8 +100,8 @@ class TestRolesAssociationEntries:
|
||||
).count() == 1, 'In loop %s' % i
|
||||
|
||||
def test_model_associations_are_recorded(self, organization):
|
||||
proj1 = organization.projects.create(name='proj1')
|
||||
proj2 = organization.projects.create(name='proj2')
|
||||
proj1 = Project.objects.create(name='proj1', organization=organization)
|
||||
proj2 = Project.objects.create(name='proj2', organization=organization)
|
||||
proj2.use_role.parents.add(proj1.admin_role)
|
||||
assert ActivityStream.objects.filter(role=proj1.admin_role, project=proj2).count() == 1
|
||||
|
||||
|
||||
@ -29,18 +29,19 @@ def test_prevent_slicing():
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_awx_custom_virtualenv(inventory, project, machine_credential):
|
||||
def test_awx_custom_virtualenv(inventory, project, machine_credential, organization):
|
||||
jt = JobTemplate.objects.create(
|
||||
name='my-jt',
|
||||
inventory=inventory,
|
||||
project=project,
|
||||
playbook='helloworld.yml'
|
||||
playbook='helloworld.yml',
|
||||
organization=organization
|
||||
)
|
||||
jt.credentials.add(machine_credential)
|
||||
job = jt.create_unified_job()
|
||||
|
||||
job.project.organization.custom_virtualenv = '/venv/fancy-org'
|
||||
job.project.organization.save()
|
||||
job.organization.custom_virtualenv = '/venv/fancy-org'
|
||||
job.organization.save()
|
||||
assert job.ansible_virtualenv_path == '/venv/fancy-org'
|
||||
|
||||
job.project.custom_virtualenv = '/venv/fancy-proj'
|
||||
|
||||
@ -39,3 +39,9 @@ def test_foreign_key_change_changes_modified_by(project, organization):
|
||||
assert project._get_fields_snapshot()['organization_id'] == organization.id
|
||||
project.organization = Organization(name='foo', pk=41)
|
||||
assert project._get_fields_snapshot()['organization_id'] == 41
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_project_related_jobs(project):
|
||||
update = project.create_unified_job()
|
||||
assert update.id in [u.id for u in project._get_related_jobs()]
|
||||
|
||||
@ -11,10 +11,11 @@ from awx.main.tasks import deep_copy_model_obj
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_copy(post, get, project, inventory, machine_credential, vault_credential,
|
||||
def test_job_template_copy(post, get, project, inventory, organization, machine_credential, vault_credential,
|
||||
credential, alice, job_template_with_survey_passwords, admin):
|
||||
job_template_with_survey_passwords.project = project
|
||||
job_template_with_survey_passwords.inventory = inventory
|
||||
job_template_with_survey_passwords.organization = organization
|
||||
job_template_with_survey_passwords.save()
|
||||
job_template_with_survey_passwords.credentials.add(credential)
|
||||
job_template_with_survey_passwords.credentials.add(machine_credential)
|
||||
@ -22,6 +23,7 @@ def test_job_template_copy(post, get, project, inventory, machine_credential, va
|
||||
job_template_with_survey_passwords.admin_role.members.add(alice)
|
||||
project.admin_role.members.add(alice)
|
||||
inventory.admin_role.members.add(alice)
|
||||
organization.job_template_admin_role.members.add(alice)
|
||||
assert get(
|
||||
reverse('api:job_template_copy', kwargs={'pk': job_template_with_survey_passwords.pk}),
|
||||
alice, expect=200
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
from unittest import mock
|
||||
|
||||
from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate
|
||||
from awx.main.models import AdHocCommand, InventoryUpdate, Job, JobTemplate, ProjectUpdate, Organization
|
||||
from awx.main.models.ha import Instance, InstanceGroup
|
||||
from awx.main.tasks import apply_cluster_membership_policies
|
||||
from awx.api.versioning import reverse
|
||||
@ -253,7 +253,7 @@ def test_inherited_instance_group_membership(instance_group_factory, default_ins
|
||||
j.inventory = inventory
|
||||
ig_org = instance_group_factory("basicA", [default_instance_group.instances.first()])
|
||||
ig_inv = instance_group_factory("basicB", [default_instance_group.instances.first()])
|
||||
j.project.organization.instance_groups.add(ig_org)
|
||||
j.organization.instance_groups.add(ig_org)
|
||||
j.inventory.instance_groups.add(ig_inv)
|
||||
assert ig_org in j.preferred_instance_groups
|
||||
assert ig_inv in j.preferred_instance_groups
|
||||
@ -320,13 +320,14 @@ class TestInstanceGroupOrdering:
|
||||
assert pu.preferred_instance_groups == [ig_tmp, ig_org]
|
||||
|
||||
def test_job_instance_groups(self, instance_group_factory, inventory, project, default_instance_group):
|
||||
jt = JobTemplate.objects.create(inventory=inventory, project=project)
|
||||
job = Job.objects.create(inventory=inventory, job_template=jt, project=project)
|
||||
org = Organization.objects.create(name='foo')
|
||||
jt = JobTemplate.objects.create(inventory=inventory, project=project, organization=org)
|
||||
job = Job.objects.create(inventory=inventory, job_template=jt, project=project, organization=org)
|
||||
assert job.preferred_instance_groups == [default_instance_group]
|
||||
ig_org = instance_group_factory("OrgIstGrp", [default_instance_group.instances.first()])
|
||||
ig_inv = instance_group_factory("InvIstGrp", [default_instance_group.instances.first()])
|
||||
ig_tmp = instance_group_factory("TmpIstGrp", [default_instance_group.instances.first()])
|
||||
project.organization.instance_groups.add(ig_org)
|
||||
jt.organization.instance_groups.add(ig_org)
|
||||
inventory.instance_groups.add(ig_inv)
|
||||
assert job.preferred_instance_groups == [ig_inv, ig_org]
|
||||
job.job_template.instance_groups.add(ig_tmp)
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import pytest
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@ -26,7 +27,7 @@ def setup_module(module):
|
||||
|
||||
|
||||
def teardown_module(module):
|
||||
# settings_registry will be persistent states unless we explicitly clean them up.
|
||||
# settings_registry will be persistent states unless we explicitly clean them up.
|
||||
settings_registry.unregister('NAMED_URL_FORMATS')
|
||||
settings_registry.unregister('NAMED_URL_GRAPH_NODES')
|
||||
|
||||
@ -58,10 +59,25 @@ def test_organization(get, admin_user):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template(get, admin_user):
|
||||
test_jt = JobTemplate.objects.create(name='test_jt')
|
||||
test_org = Organization.objects.create(name='test_org')
|
||||
test_jt = JobTemplate.objects.create(name='test_jt', organization=test_org)
|
||||
url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk})
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
assert response.data['related']['named_url'].endswith('/test_jt/')
|
||||
assert response.data['related']['named_url'].endswith('/test_jt++test_org/')
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_old_way(get, admin_user, mocker):
|
||||
test_org = Organization.objects.create(name='test_org')
|
||||
test_jt = JobTemplate.objects.create(name='test_jt ♥', organization=test_org)
|
||||
url = reverse('api:job_template_detail', kwargs={'pk': test_jt.pk})
|
||||
|
||||
response = get(url, user=admin_user, expect=200)
|
||||
new_url = response.data['related']['named_url']
|
||||
old_url = '/'.join([url.rsplit('/', 2)[0], test_jt.name, ''])
|
||||
|
||||
assert URLModificationMiddleware._convert_named_url(new_url) == url
|
||||
assert URLModificationMiddleware._convert_named_url(old_url) == url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
|
||||
@ -213,34 +213,14 @@ def test_project_credential_protection(post, put, project, organization, scm_cre
|
||||
}, org_admin, expect=403
|
||||
)
|
||||
post(
|
||||
reverse('api:project_list'), {
|
||||
'name': 'should not create',
|
||||
'organization':organization.id,
|
||||
reverse('api:project_list'), {
|
||||
'name': 'should not create',
|
||||
'organization':organization.id,
|
||||
'credential': scm_credential.id
|
||||
}, org_admin, expect=403
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
def test_create_project_null_organization(post, organization, admin):
|
||||
post(reverse('api:project_list'), { 'name': 't', 'organization': None}, admin, expect=201)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
def test_create_project_null_organization_xfail(post, organization, org_admin):
|
||||
post(reverse('api:project_list'), { 'name': 't', 'organization': None}, org_admin, expect=403)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
def test_patch_project_null_organization(patch, organization, project, admin):
|
||||
patch(reverse('api:project_detail', kwargs={'pk':project.id,}), { 'name': 't', 'organization': organization.id}, admin, expect=200)
|
||||
|
||||
|
||||
@pytest.mark.django_db()
|
||||
def test_patch_project_null_organization_xfail(patch, project, org_admin):
|
||||
patch(reverse('api:project_detail', kwargs={'pk':project.id,}), { 'name': 't', 'organization': None}, org_admin, expect=400)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_cannot_schedule_manual_project(manual_project, admin_user, post):
|
||||
response = post(
|
||||
|
||||
@ -29,7 +29,8 @@ def normal_job(deploy_jobtemplate):
|
||||
return Job.objects.create(
|
||||
job_template=deploy_jobtemplate,
|
||||
project=deploy_jobtemplate.project,
|
||||
inventory=deploy_jobtemplate.inventory
|
||||
inventory=deploy_jobtemplate.inventory,
|
||||
organization=deploy_jobtemplate.organization
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -89,8 +89,8 @@ def test_slice_job(slice_job_factory, rando):
|
||||
@pytest.mark.django_db
|
||||
class TestJobRelaunchAccess:
|
||||
@pytest.fixture
|
||||
def job_no_prompts(self, machine_credential, inventory):
|
||||
jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory)
|
||||
def job_no_prompts(self, machine_credential, inventory, organization):
|
||||
jt = JobTemplate.objects.create(name='test-job_template', inventory=inventory, organization=organization)
|
||||
jt.credentials.add(machine_credential)
|
||||
return jt.create_unified_job()
|
||||
|
||||
@ -119,6 +119,13 @@ class TestJobRelaunchAccess:
|
||||
job_no_prompts.job_template.execute_role.members.add(rando)
|
||||
assert rando.can_access(Job, 'start', job_no_prompts)
|
||||
|
||||
def test_orphan_relaunch_via_organization(self, job_no_prompts, rando, organization):
|
||||
"JT for job has been deleted, relevant organization roles will allow management"
|
||||
organization.execute_role.members.add(rando)
|
||||
job_no_prompts.job_template.delete()
|
||||
job_no_prompts.job_template = None # Django should do this for us, but it does not
|
||||
assert rando.can_access(Job, 'start', job_no_prompts)
|
||||
|
||||
def test_no_relaunch_without_prompted_fields_access(self, job_with_prompts, rando):
|
||||
"Has JT execute_role but no use_role on inventory & credential - deny relaunch"
|
||||
job_with_prompts.job_template.execute_role.members.add(rando)
|
||||
|
||||
@ -24,6 +24,29 @@ def test_job_template_access_superuser(check_license, user, deploy_jobtemplate):
|
||||
assert access.can_add({})
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestImplicitAccess:
|
||||
def test_org_execute(self, jt_linked, rando):
|
||||
assert rando not in jt_linked.execute_role
|
||||
jt_linked.organization.execute_role.members.add(rando)
|
||||
assert rando in jt_linked.execute_role
|
||||
|
||||
def test_org_admin(self, jt_linked, rando):
|
||||
assert rando not in jt_linked.execute_role
|
||||
jt_linked.organization.job_template_admin_role.members.add(rando)
|
||||
assert rando in jt_linked.execute_role
|
||||
|
||||
def test_org_auditor(self, jt_linked, rando):
|
||||
assert rando not in jt_linked.read_role
|
||||
jt_linked.organization.auditor_role.members.add(rando)
|
||||
assert rando in jt_linked.read_role
|
||||
|
||||
def test_deprecated_inventory_read(self, jt_linked, rando):
|
||||
assert rando not in jt_linked.read_role
|
||||
jt_linked.inventory.organization.execute_role.members.add(rando)
|
||||
assert rando in jt_linked.read_role
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_access_read_level(jt_linked, rando):
|
||||
ssh_cred = jt_linked.machine_credential
|
||||
@ -45,22 +68,21 @@ def test_job_template_access_read_level(jt_linked, rando):
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_job_template_access_use_level(jt_linked, rando):
|
||||
ssh_cred = jt_linked.machine_credential
|
||||
vault_cred = jt_linked.vault_credentials[0]
|
||||
|
||||
access = JobTemplateAccess(rando)
|
||||
jt_linked.project.use_role.members.add(rando)
|
||||
jt_linked.inventory.use_role.members.add(rando)
|
||||
ssh_cred.use_role.members.add(rando)
|
||||
vault_cred.use_role.members.add(rando)
|
||||
|
||||
jt_linked.organization.job_template_admin_role.members.add(rando)
|
||||
proj_pk = jt_linked.project.pk
|
||||
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk))
|
||||
assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk))
|
||||
assert access.can_add(dict(vault_credential=vault_cred.pk, project=proj_pk))
|
||||
org_pk = jt_linked.organization_id
|
||||
|
||||
assert access.can_change(jt_linked, {'job_type': 'check', 'project': proj_pk})
|
||||
assert access.can_change(jt_linked, {'job_type': 'check', 'inventory': None})
|
||||
|
||||
for cred in jt_linked.credentials.all():
|
||||
assert not access.can_unattach(jt_linked, cred, 'credentials', {})
|
||||
assert access.can_unattach(jt_linked, cred, 'credentials', {})
|
||||
|
||||
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk, organization=org_pk))
|
||||
assert access.can_add(dict(project=proj_pk, organization=org_pk))
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -69,22 +91,21 @@ def test_job_template_access_admin(role_names, jt_linked, rando):
|
||||
ssh_cred = jt_linked.machine_credential
|
||||
|
||||
access = JobTemplateAccess(rando)
|
||||
# Appoint this user as admin of the organization
|
||||
#jt_linked.inventory.organization.admin_role.members.add(rando)
|
||||
|
||||
assert not access.can_read(jt_linked)
|
||||
assert not access.can_delete(jt_linked)
|
||||
|
||||
for role_name in role_names:
|
||||
role = getattr(jt_linked.inventory.organization, role_name)
|
||||
role.members.add(rando)
|
||||
# Appoint this user as admin of the organization
|
||||
jt_linked.organization.admin_role.members.add(rando)
|
||||
org_pk = jt_linked.organization.id
|
||||
|
||||
# Assign organization permission in the same way the create view does
|
||||
organization = jt_linked.inventory.organization
|
||||
ssh_cred.admin_role.parents.add(organization.admin_role)
|
||||
|
||||
proj_pk = jt_linked.project.pk
|
||||
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk))
|
||||
assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk))
|
||||
assert access.can_add(dict(inventory=jt_linked.inventory.pk, project=proj_pk, organization=org_pk))
|
||||
assert access.can_add(dict(credential=ssh_cred.pk, project=proj_pk, organization=org_pk))
|
||||
|
||||
for cred in jt_linked.credentials.all():
|
||||
assert access.can_unattach(jt_linked, cred, 'credentials', {})
|
||||
@ -148,26 +169,46 @@ class TestOrphanJobTemplate:
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.job_permissions
|
||||
def test_job_template_creator_access(project, rando, post):
|
||||
def test_job_template_creator_access(project, organization, rando, post):
|
||||
project.use_role.members.add(rando)
|
||||
organization.job_template_admin_role.members.add(rando)
|
||||
response = post(url=reverse('api:job_template_list'), data=dict(
|
||||
name='newly-created-jt',
|
||||
ask_inventory_on_launch=True,
|
||||
project=project.pk,
|
||||
organization=organization.id,
|
||||
playbook='helloworld.yml'
|
||||
), user=rando, expect=201)
|
||||
|
||||
project.admin_role.members.add(rando)
|
||||
with mock.patch(
|
||||
'awx.main.models.projects.ProjectOptions.playbooks',
|
||||
new_callable=mock.PropertyMock(return_value=['helloworld.yml'])):
|
||||
response = post(reverse('api:job_template_list'), dict(
|
||||
name='newly-created-jt',
|
||||
job_type='run',
|
||||
ask_inventory_on_launch=True,
|
||||
ask_credential_on_launch=True,
|
||||
project=project.pk,
|
||||
playbook='helloworld.yml'
|
||||
), rando)
|
||||
|
||||
assert response.status_code == 201
|
||||
jt_pk = response.data['id']
|
||||
jt_obj = JobTemplate.objects.get(pk=jt_pk)
|
||||
# Creating a JT should place the creator in the admin role
|
||||
assert rando in jt_obj.admin_role
|
||||
assert rando in jt_obj.admin_role.members.all()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.job_permissions
|
||||
@pytest.mark.parametrize('lacking', ['project', 'inventory', 'organization'])
|
||||
def test_job_template_insufficient_creator_permissions(lacking, project, inventory, organization, rando, post):
|
||||
if lacking != 'project':
|
||||
project.use_role.members.add(rando)
|
||||
else:
|
||||
project.read_role.members.add(rando)
|
||||
if lacking != 'organization':
|
||||
organization.job_template_admin_role.members.add(rando)
|
||||
else:
|
||||
organization.member_role.members.add(rando)
|
||||
if lacking != 'inventory':
|
||||
inventory.use_role.members.add(rando)
|
||||
else:
|
||||
inventory.read_role.members.add(rando)
|
||||
post(url=reverse('api:job_template_list'), data=dict(
|
||||
name='newly-created-jt',
|
||||
inventory=inventory.id,
|
||||
project=project.pk,
|
||||
organization=organization.id,
|
||||
playbook='helloworld.yml'
|
||||
), user=rando, expect=403)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@ -239,7 +280,7 @@ class TestJobTemplateSchedules:
|
||||
@pytest.mark.django_db
|
||||
def test_jt_org_ownership_change(user, jt_linked):
|
||||
admin1 = user('admin1')
|
||||
org1 = jt_linked.project.organization
|
||||
org1 = jt_linked.organization
|
||||
org1.admin_role.members.add(admin1)
|
||||
a1_access = JobTemplateAccess(admin1)
|
||||
|
||||
@ -254,10 +295,8 @@ def test_jt_org_ownership_change(user, jt_linked):
|
||||
assert not a2_access.can_read(jt_linked)
|
||||
|
||||
|
||||
jt_linked.project.organization = org2
|
||||
jt_linked.project.save()
|
||||
jt_linked.inventory.organization = org2
|
||||
jt_linked.inventory.save()
|
||||
jt_linked.organization = org2
|
||||
jt_linked.save()
|
||||
|
||||
assert a2_access.can_read(jt_linked)
|
||||
assert not a1_access.can_read(jt_linked)
|
||||
|
||||
64
awx/main/tests/functional/test_rbac_migration.py
Normal file
64
awx/main/tests/functional/test_rbac_migration.py
Normal 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
|
||||
@ -62,10 +62,11 @@ class TestWorkflowJobTemplateAccess:
|
||||
@pytest.mark.django_db
|
||||
class TestWorkflowJobTemplateNodeAccess:
|
||||
|
||||
def test_no_jt_access_to_edit(self, wfjt_node, org_admin):
|
||||
def test_no_jt_access_to_edit(self, wfjt_node, rando):
|
||||
# without access to the related job template, admin to the WFJT can
|
||||
# not change the prompted parameters
|
||||
access = WorkflowJobTemplateNodeAccess(org_admin)
|
||||
wfjt_node.workflow_job_template.admin_role.members.add(rando)
|
||||
access = WorkflowJobTemplateNodeAccess(rando)
|
||||
assert not access.can_change(wfjt_node, {'job_type': 'check'})
|
||||
|
||||
def test_node_edit_allowed(self, wfjt_node, org_admin):
|
||||
|
||||
@ -30,6 +30,7 @@ def job_template(mocker):
|
||||
mock_jt.host_config_key = '9283920492'
|
||||
mock_jt.validation_errors = mock_JT_resource_data
|
||||
mock_jt.webhook_service = ''
|
||||
mock_jt.organization_id = None
|
||||
return mock_jt
|
||||
|
||||
|
||||
|
||||
@ -65,6 +65,14 @@ def test_cancel_job_explanation(unified_job):
|
||||
unified_job.save.assert_called_with(update_fields=['cancel_flag', 'start_args', 'status', 'job_explanation'])
|
||||
|
||||
|
||||
def test_organization_copy_to_jobs():
|
||||
'''
|
||||
All unified job types should infer their organization from their template organization
|
||||
'''
|
||||
for cls in UnifiedJobTemplate.__subclasses__():
|
||||
assert 'organization' in cls._get_unified_job_field_names()
|
||||
|
||||
|
||||
def test_log_representation():
|
||||
'''
|
||||
Common representation used inside of log messages
|
||||
|
||||
@ -148,7 +148,9 @@ def job_template_with_ids(job_template_factory):
|
||||
'testJT', project=proj, inventory=inv, credential=credential,
|
||||
cloud_credential=cloud_cred, network_credential=net_cred,
|
||||
persisted=False)
|
||||
return jt_objects.job_template
|
||||
jt = jt_objects.job_template
|
||||
jt.organization = Organization(id=1, pk=1, name='fooOrg')
|
||||
return jt
|
||||
|
||||
|
||||
def test_superuser(mocker):
|
||||
@ -180,21 +182,24 @@ def test_jt_existing_values_are_nonsensitive(job_template_with_ids, user_unit):
|
||||
def test_change_jt_sensitive_data(job_template_with_ids, mocker, user_unit):
|
||||
"""Assure that can_add is called with all ForeignKeys."""
|
||||
|
||||
job_template_with_ids.admin_role = Role()
|
||||
class RoleReturnsTrue(Role):
|
||||
def __contains__(self, accessor):
|
||||
return True
|
||||
|
||||
job_template_with_ids.admin_role = RoleReturnsTrue()
|
||||
job_template_with_ids.organization.job_template_admin_role = RoleReturnsTrue()
|
||||
|
||||
inv2 = Inventory()
|
||||
inv2.use_role = RoleReturnsTrue()
|
||||
data = {'inventory': inv2}
|
||||
|
||||
data = {'inventory': job_template_with_ids.inventory.id + 1}
|
||||
access = JobTemplateAccess(user_unit)
|
||||
|
||||
mock_add = mock.MagicMock(return_value=False)
|
||||
with mock.patch('awx.main.models.rbac.Role.__contains__', return_value=True):
|
||||
with mocker.patch('awx.main.access.JobTemplateAccess.can_add', mock_add):
|
||||
with mocker.patch('awx.main.access.JobTemplateAccess.can_read', return_value=True):
|
||||
assert not access.can_change(job_template_with_ids, data)
|
||||
assert not access.changes_are_non_sensitive(job_template_with_ids, data)
|
||||
|
||||
mock_add.assert_called_once_with({
|
||||
'inventory': data['inventory'],
|
||||
'project': job_template_with_ids.project.id
|
||||
})
|
||||
job_template_with_ids.inventory.use_role = RoleReturnsTrue()
|
||||
job_template_with_ids.project.use_role = RoleReturnsTrue()
|
||||
assert access.can_change(job_template_with_ids, data)
|
||||
|
||||
|
||||
def mock_raise_none(self, add_host=False, feature=None, check_expiration=True):
|
||||
|
||||
@ -2,10 +2,17 @@
|
||||
import pytest
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.apps import apps
|
||||
from django.db.models.fields.related import ForeignKey
|
||||
from django.db.models.fields.related_descriptors import (
|
||||
ReverseManyToOneDescriptor,
|
||||
ForwardManyToOneDescriptor
|
||||
)
|
||||
|
||||
from rest_framework.serializers import ValidationError as DRFValidationError
|
||||
|
||||
from awx.main.models import Credential, CredentialType, BaseModel
|
||||
from awx.main.fields import JSONSchemaField
|
||||
from awx.main.fields import JSONSchemaField, ImplicitRoleField, ImplicitRoleDescriptor
|
||||
|
||||
|
||||
@pytest.mark.parametrize('schema, given, message', [
|
||||
@ -194,3 +201,57 @@ def test_credential_creation_validation_failure(inputs):
|
||||
with pytest.raises(Exception) as e:
|
||||
field.validate(inputs, cred)
|
||||
assert e.type in (ValidationError, DRFValidationError)
|
||||
|
||||
|
||||
def test_implicit_role_field_parents():
|
||||
"""This assures that every ImplicitRoleField only references parents
|
||||
which are relationships that actually exist
|
||||
"""
|
||||
app_models = apps.get_app_config('main').get_models()
|
||||
for cls in app_models:
|
||||
for field in cls._meta.get_fields():
|
||||
if not isinstance(field, ImplicitRoleField):
|
||||
continue
|
||||
|
||||
if not field.parent_role:
|
||||
continue
|
||||
|
||||
field_names = field.parent_role
|
||||
if type(field_names) is not list:
|
||||
field_names = [field_names]
|
||||
|
||||
for field_name in field_names:
|
||||
# this type of specification appears to have been considered
|
||||
# at some point, but does not exist in the app and would
|
||||
# need support and tests built out for it
|
||||
assert not isinstance(field_name, tuple)
|
||||
# also used to be a thing before py3 upgrade
|
||||
assert not isinstance(field_name, bytes)
|
||||
# this is always coherent
|
||||
if field_name.startswith('singleton:'):
|
||||
continue
|
||||
# separate out parent role syntax
|
||||
field_name, sep, field_attr = field_name.partition('.')
|
||||
# now make primary assertion, that specified paths exist
|
||||
assert hasattr(cls, field_name)
|
||||
|
||||
# inspect in greater depth
|
||||
second_field = cls._meta.get_field(field_name)
|
||||
second_field_descriptor = getattr(cls, field_name)
|
||||
# all supported linkage types
|
||||
assert isinstance(second_field_descriptor, (
|
||||
ReverseManyToOneDescriptor, # not currently used
|
||||
ImplicitRoleDescriptor,
|
||||
ForwardManyToOneDescriptor
|
||||
))
|
||||
# only these links are supported
|
||||
if field_attr:
|
||||
if isinstance(second_field_descriptor, ReverseManyToOneDescriptor):
|
||||
assert type(second_field) is ForeignKey
|
||||
rel_model = cls._meta.get_field(field_name).related_model
|
||||
third_field = getattr(rel_model, field_attr)
|
||||
# expecting for related_model.foo_role, test role field type
|
||||
assert isinstance(third_field, ImplicitRoleDescriptor)
|
||||
else:
|
||||
# expecting simple format of foo_role
|
||||
assert type(second_field) is ImplicitRoleField
|
||||
|
||||
@ -43,6 +43,19 @@ function(NotificationsList, i18n) {
|
||||
column: 1,
|
||||
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
|
||||
},
|
||||
organization: {
|
||||
label: i18n._('Organization'),
|
||||
type: 'lookup',
|
||||
list: 'OrganizationList',
|
||||
sourceModel: 'organization',
|
||||
basePath: 'organizations',
|
||||
sourceField: 'name',
|
||||
dataTitle: i18n._('Organization'),
|
||||
dataContainer: 'body',
|
||||
dataPlacement: 'right',
|
||||
ngDisabled: '!(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)',
|
||||
awLookupWhen: '(job_template_obj.summary_fields.user_capabilities.edit || canAddJobTemplate)'
|
||||
},
|
||||
job_type: {
|
||||
label: i18n._('Job Type'),
|
||||
type: 'select',
|
||||
|
||||
@ -7,7 +7,7 @@ from awxkit.utils import (
|
||||
suppress,
|
||||
update_payload,
|
||||
PseudoNamespace)
|
||||
from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate
|
||||
from awxkit.api.pages import Credential, Inventory, Project, UnifiedJobTemplate, Organization
|
||||
from awxkit.api.mixins import HasCreate, HasInstanceGroups, HasNotifications, HasSurvey, HasCopy, DSAdapter
|
||||
from awxkit.api.resources import resources
|
||||
import awxkit.exceptions as exc
|
||||
@ -23,7 +23,7 @@ class JobTemplate(
|
||||
HasSurvey,
|
||||
UnifiedJobTemplate):
|
||||
|
||||
optional_dependencies = [Inventory, Credential, Project]
|
||||
optional_dependencies = [Organization, Inventory, Credential, Project]
|
||||
|
||||
def launch(self, payload={}):
|
||||
"""Launch the job_template using related->launch endpoint."""
|
||||
@ -129,6 +129,7 @@ class JobTemplate(
|
||||
playbook='ping.yml',
|
||||
credential=Credential,
|
||||
inventory=Inventory,
|
||||
organization=Organization,
|
||||
project=None,
|
||||
**kwargs):
|
||||
if not project:
|
||||
@ -148,12 +149,18 @@ class JobTemplate(
|
||||
project = self.ds.project if project else None
|
||||
inventory = self.ds.inventory if inventory else None
|
||||
credential = self.ds.credential if credential else None
|
||||
# if the created project has an organization, and the parameters
|
||||
# specified no organization, then borrow the one from the project
|
||||
if hasattr(project.ds, 'organization') and organization is Organization:
|
||||
self.ds.organization = project.ds.organization
|
||||
organization = self.ds.organization
|
||||
|
||||
payload = self.payload(
|
||||
name=name,
|
||||
description=description,
|
||||
job_type=job_type,
|
||||
playbook=playbook,
|
||||
organization=organization,
|
||||
credential=credential,
|
||||
inventory=inventory,
|
||||
project=project,
|
||||
@ -169,11 +176,12 @@ class JobTemplate(
|
||||
playbook='ping.yml',
|
||||
credential=Credential,
|
||||
inventory=Inventory,
|
||||
organization=Organization,
|
||||
project=None,
|
||||
**kwargs):
|
||||
payload, credential = self.create_payload(name=name, description=description, job_type=job_type,
|
||||
playbook=playbook, credential=credential, inventory=inventory,
|
||||
project=project, **kwargs)
|
||||
project=project, organization=organization, **kwargs)
|
||||
ret = self.update_identity(
|
||||
JobTemplates(
|
||||
self.connection).post(payload))
|
||||
|
||||
@ -12,7 +12,7 @@ from . import page
|
||||
|
||||
class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, UnifiedJobTemplate):
|
||||
|
||||
optional_dependencies = [Organization]
|
||||
dependencies = [Organization]
|
||||
|
||||
def launch(self, payload={}):
|
||||
"""Launch using related->launch endpoint."""
|
||||
@ -71,14 +71,14 @@ class WorkflowJobTemplate(HasCopy, HasCreate, HasNotifications, HasSurvey, Unifi
|
||||
|
||||
return payload
|
||||
|
||||
def create_payload(self, name='', description='', organization=None, **kwargs):
|
||||
def create_payload(self, name='', description='', organization=Organization, **kwargs):
|
||||
self.create_and_update_dependencies(*filter_by_class((organization, Organization)))
|
||||
organization = self.ds.organization if organization else None
|
||||
payload = self.payload(name=name, description=description, organization=organization, **kwargs)
|
||||
payload.ds = DSAdapter(self.__class__.__name__, self._dependency_store)
|
||||
return payload
|
||||
|
||||
def create(self, name='', description='', organization=None, **kwargs):
|
||||
def create(self, name='', description='', organization=Organization, **kwargs):
|
||||
payload = self.create_payload(name=name, description=description, organization=organization, **kwargs)
|
||||
return self.update_identity(WorkflowJobTemplates(self.connection).post(payload))
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user