diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 1487fe17e3..679d23aeee 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -1283,7 +1283,9 @@ class CustomInventoryScriptSerializer(BaseSerializer): if obj is None: return ret request = self.context.get('request', None) - if request.user not in obj.admin_role: + if request.user not in obj.admin_role and \ + not request.user.is_superuser and \ + not request.user.is_system_auditor: ret['script'] = None return ret diff --git a/awx/api/views.py b/awx/api/views.py index 64ea980f66..11ae8f6766 100644 --- a/awx/api/views.py +++ b/awx/api/views.py @@ -653,7 +653,7 @@ class OrganizationList(OrganizationCountsMixin, ListCreateAPIView): def get_queryset(self): qs = Organization.accessible_objects(self.request.user, 'read_role') - qs = qs.select_related('admin_role', 'auditor_role', 'member_role') + qs = qs.select_related('admin_role', 'auditor_role', 'member_role', 'read_role') return qs def create(self, request, *args, **kwargs): @@ -2184,14 +2184,6 @@ class JobTemplateDetail(RetrieveUpdateDestroyAPIView): serializer_class = JobTemplateSerializer always_allow_superuser = False - def destroy(self, request, *args, **kwargs): - obj = self.get_object() - can_delete = request.user.can_access(JobTemplate, 'delete', obj) - if not can_delete: - raise PermissionDenied("Cannot delete job template.") - return super(JobTemplateDetail, self).destroy(request, *args, **kwargs) - - class JobTemplateLaunch(RetrieveAPIView, GenericAPIView): model = JobTemplate diff --git a/awx/main/access.py b/awx/main/access.py index af05c58be6..5b2ee91851 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -25,7 +25,7 @@ from awx.main.conf import tower_settings __all__ = ['get_user_queryset', 'check_user_access', 'user_accessible_objects', - 'user_admin_role',] + 'user_admin_role', 'StateConflict',] PERMISSION_TYPES = [ PERM_INVENTORY_ADMIN, @@ -57,6 +57,8 @@ access_registry = { # ... } +class StateConflict(ValidationError): + status_code = 409 def register_access(model_class, access_class): access_classes = access_registry.setdefault(model_class, []) @@ -315,11 +317,15 @@ class OrganizationAccess(BaseAccess): if not is_change_possible: return False active_jobs = [] - active_jobs.extend(Job.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)) - active_jobs.extend(ProjectUpdate.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)) - active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__inventory__organization=obj, status__in=ACTIVE_STATES)) + active_jobs.extend([dict(type="job", id=o.id) + for o in Job.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)]) + active_jobs.extend([dict(type="project_update", id=o.id) + for o in ProjectUpdate.objects.filter(project__in=obj.projects.all(), status__in=ACTIVE_STATES)]) + active_jobs.extend([dict(type="inventory_update", id=o.id) + for o in InventoryUpdate.objects.filter(inventory_source__inventory__organization=obj, status__in=ACTIVE_STATES)]) if len(active_jobs) > 0: - raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + raise StateConflict({"conflict": "Resource is being used by running jobs", + "active_jobs": active_jobs}) return True class InventoryAccess(BaseAccess): @@ -387,10 +393,13 @@ class InventoryAccess(BaseAccess): if not is_can_admin: return False active_jobs = [] - active_jobs.extend(Job.objects.filter(inventory=obj, status__in=ACTIVE_STATES)) - active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__inventory=obj, status__in=ACTIVE_STATES)) + active_jobs.extend([dict(type="job", id=o.id) + for o in Job.objects.filter(inventory=obj, status__in=ACTIVE_STATES)]) + active_jobs.extend([dict(type="inventory_update", id=o.id) + for o in InventoryUpdate.objects.filter(inventory_source__inventory=obj, status__in=ACTIVE_STATES)]) if len(active_jobs) > 0: - raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + raise StateConflict({"conflict": "Resource is being used by running jobs", + "active_jobs": active_jobs}) return True def can_run_ad_hoc_commands(self, obj): @@ -508,9 +517,11 @@ class GroupAccess(BaseAccess): if not is_delete_allowed: return False active_jobs = [] - active_jobs.extend(InventoryUpdate.objects.filter(inventory_source__in=obj.inventory_sources.all(), status__in=ACTIVE_STATES)) + active_jobs.extend([dict(type="inventory_update", id=o.id) + for o in InventoryUpdate.objects.filter(inventory_source__in=obj.inventory_sources.all(), status__in=ACTIVE_STATES)]) if len(active_jobs) > 0: - raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + raise StateConflict({"conflict": "Resource is being used by running jobs", + "active_jobs": active_jobs}) return True class InventorySourceAccess(BaseAccess): @@ -765,10 +776,13 @@ class ProjectAccess(BaseAccess): if not is_change_allowed: return False active_jobs = [] - active_jobs.extend(Job.objects.filter(project=obj, status__in=ACTIVE_STATES)) - active_jobs.extend(ProjectUpdate.objects.filter(project=obj, status__in=ACTIVE_STATES)) + active_jobs.extend([dict(type="job", id=o.id) + for o in Job.objects.filter(project=obj, status__in=ACTIVE_STATES)]) + active_jobs.extend([dict(type="project_update", id=o.id) + for o in ProjectUpdate.objects.filter(project=obj, status__in=ACTIVE_STATES)]) if len(active_jobs) > 0: - raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + raise StateConflict({"conflict": "Resource is being used by running jobs", + "active_jobs": active_jobs}) return True @check_superuser @@ -989,14 +1003,15 @@ class JobTemplateAccess(BaseAccess): return True - @check_superuser def can_delete(self, obj): - is_delete_allowed = self.user in obj.admin_role + is_delete_allowed = self.user.is_superuser or self.user in obj.admin_role if not is_delete_allowed: return False - active_jobs = obj.jobs.filter(status__in=ACTIVE_STATES) + active_jobs = [dict(type="job", id=o.id) + for o in obj.jobs.filter(status__in=ACTIVE_STATES)] if len(active_jobs) > 0: - raise ValidationError("Delete not allowed while there are jobs running. Number of jobs {}".format(len(active_jobs))) + raise StateConflict({"conflict": "Resource is being used by running jobs", + "active_jobs": active_jobs}) return True class JobAccess(BaseAccess): diff --git a/awx/main/migrations/0026_v300_credential_unique.py b/awx/main/migrations/0026_v300_credential_unique.py index b354ce3d62..3c1d714327 100644 --- a/awx/main/migrations/0026_v300_credential_unique.py +++ b/awx/main/migrations/0026_v300_credential_unique.py @@ -20,7 +20,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='credential', name='read_role', - field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor', b'use_role', b'admin_role', b'organization.auditor_role'], to='main.Role', null=b'True'), + field=awx.main.fields.ImplicitRoleField(related_name='+', parent_role=[b'singleton:system_auditor', b'organization.auditor_role', b'use_role', b'admin_role'], to='main.Role', null=b'True'), ), migrations.RunPython(migration_utils.set_current_apps_for_migrations), migrations.RunPython(rbac.rebuild_role_hierarchy), diff --git a/awx/main/tests/functional/api/test_job_template.py b/awx/main/tests/functional/api/test_job_template.py index 14c92c4a54..cab2e53731 100644 --- a/awx/main/tests/functional/api/test_job_template.py +++ b/awx/main/tests/functional/api/test_job_template.py @@ -335,3 +335,15 @@ def test_jt_without_project(inventory): data["job_type"] = "scan" serializer = JobTemplateSerializer(data=data) assert serializer.is_valid() + +@pytest.mark.django_db +def test_disallow_template_delete_on_running_job(job_template_factory, delete, admin_user): + objects = job_template_factory('jt', + credential='c', + job_type="run", + project='p', + inventory='i', + organization='o') + objects.job_template.create_unified_job() + delete_response = delete(reverse('api:job_template_detail', args=[objects.job_template.pk]), user=admin_user) + assert delete_response.status_code == 409 diff --git a/awx/main/tests/unit/api/test_serializers.py b/awx/main/tests/unit/api/test_serializers.py index a8fb25d005..2496ba9a2d 100644 --- a/awx/main/tests/unit/api/test_serializers.py +++ b/awx/main/tests/unit/api/test_serializers.py @@ -1,14 +1,31 @@ # Python import pytest import mock +from mock import PropertyMock import json # AWX -from awx.api.serializers import JobTemplateSerializer, JobSerializer, JobOptionsSerializer -from awx.main.models import Label, Job +from awx.api.serializers import ( + JobTemplateSerializer, + JobSerializer, + JobOptionsSerializer, + CustomInventoryScriptSerializer, +) +from awx.main.models import ( + Label, + Job, + CustomInventoryScript, + User, +) #DRF +from rest_framework.request import Request from rest_framework import serializers +from rest_framework.test import ( + APIRequestFactory, + force_authenticate, +) + def mock_JT_resource_data(): return ({}, []) @@ -189,3 +206,30 @@ class TestJobTemplateSerializerValidation(object): for ev in self.bad_extra_vars: with pytest.raises(serializers.ValidationError): serializer.validate_extra_vars(ev) + +class TestCustomInventoryScriptSerializer(object): + + @pytest.mark.parametrize("superuser,sysaudit,admin_role,value", + ((True, False, False, '#!/python'), + (False, True, False, '#!/python'), + (False, False, True, '#!/python'), + (False, False, False, None))) + def test_to_representation_orphan(self, superuser, sysaudit, admin_role, value): + with mock.patch.object(CustomInventoryScriptSerializer, 'get_summary_fields', return_value={}): + User.add_to_class('is_system_auditor', sysaudit) + user = User(username="root", is_superuser=superuser) + roles = [user] if admin_role else [] + + with mock.patch('awx.main.models.CustomInventoryScript.admin_role', new_callable=PropertyMock, return_value=roles): + cis = CustomInventoryScript(pk=1, script='#!/python') + serializer = CustomInventoryScriptSerializer() + + factory = APIRequestFactory() + wsgi_request = factory.post("/inventory_script/1", {'id':1}, format="json") + force_authenticate(wsgi_request, user) + + request = Request(wsgi_request) + serializer.context['request'] = request + + representation = serializer.to_representation(cis) + assert representation['script'] == value diff --git a/awx/sso/backends.py b/awx/sso/backends.py index 3d5edb7ec9..91999034d5 100644 --- a/awx/sso/backends.py +++ b/awx/sso/backends.py @@ -201,7 +201,7 @@ def _update_m2m_from_groups(user, ldap_user, rel, opts, remove=True): rel.remove(user) -@receiver(populate_user) +@receiver(populate_user, dispatch_uid='populate-ldap-user') def on_populate_user(sender, **kwargs): ''' Handle signal from LDAP backend to populate the user object. Update user @@ -239,7 +239,7 @@ def on_populate_user(sender, **kwargs): team, created = Team.objects.get_or_create(name=team_name, organization=org) users_opts = team_opts.get('users', None) remove = bool(team_opts.get('remove', True)) - _update_m2m_from_groups(user, ldap_user, team.member_role.users, users_opts, + _update_m2m_from_groups(user, ldap_user, team.member_role.members, users_opts, remove) # Update user profile to store LDAP DN. diff --git a/awx/ui/client/src/app.js b/awx/ui/client/src/app.js index 0698a2fdbb..e25d53ac51 100644 --- a/awx/ui/client/src/app.js +++ b/awx/ui/client/src/app.js @@ -255,16 +255,6 @@ var tower = angular.module('Tower', [ }); }); }] - }, - onExit: function(){ - // close the job launch modal - // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" - // Destroy the dialog - if($("#job-launch-modal").hasClass('ui-dialog-content')) { - $('#job-launch-modal').dialog('destroy'); - } - // Remove the directive from the page (if it's there) - $('#content-container').find('submit-job').remove(); } }). @@ -274,16 +264,6 @@ var tower = angular.module('Tower', [ controller: JobsListController, ncyBreadcrumb: { label: "JOBS" - }, - onExit: function(){ - // close the job launch modal - // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" - // Destroy the dialog - if($("#job-launch-modal").hasClass('ui-dialog-content')) { - $('#job-launch-modal').dialog('destroy'); - } - // Remove the directive from the page (if it's there) - $('#content-container').find('submit-job').remove(); } }). diff --git a/awx/ui/client/src/controllers/Jobs.js b/awx/ui/client/src/controllers/Jobs.js index 27cbd6f8f5..a7408259a2 100644 --- a/awx/ui/client/src/controllers/Jobs.js +++ b/awx/ui/client/src/controllers/Jobs.js @@ -66,6 +66,7 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa scope: jobs_scope, list: AllJobsList, id: 'active-jobs', + pageSize: 20, url: GetBasePath('unified_jobs') + '?status__in=pending,waiting,running,completed,failed,successful,error,canceled&order_by=-finished', searchParams: search_params, spinner: false @@ -77,15 +78,14 @@ export function JobsListController ($rootScope, $log, $scope, $compile, $statePa parent_scope: $scope, scope: scheduled_scope, list: ScheduledJobsList, + pageSize: 20, id: 'scheduled-jobs-tab', searchSize: 'col-lg-4 col-md-4 col-sm-4 col-xs-12', url: GetBasePath('schedules') + '?next_run__isnull=false' }); $scope.refreshJobs = function() { - jobs_scope.search('queued_job'); - jobs_scope.search('running_job'); - jobs_scope.search('completed_job'); + jobs_scope.search('all_job'); scheduled_scope.search('schedule'); }; diff --git a/awx/ui/client/src/forms/Credentials.js b/awx/ui/client/src/forms/Credentials.js index f23852cebc..fe61186cce 100644 --- a/awx/ui/client/src/forms/Credentials.js +++ b/awx/ui/client/src/forms/Credentials.js @@ -107,11 +107,6 @@ export default init: false }, autocomplete: false, - subCheckbox: { - variable: 'secret_key_ask', - text: 'Ask at runtime?', - ngChange: 'ask(\'secret_key\', \'undefined\')' - }, clear: false, hasShowInputButton: true, apiField: 'password', diff --git a/awx/ui/client/src/helpers/JobDetail.js b/awx/ui/client/src/helpers/JobDetail.js index dde9264109..3b7dcca1ce 100644 --- a/awx/ui/client/src/helpers/JobDetail.js +++ b/awx/ui/client/src/helpers/JobDetail.js @@ -687,7 +687,7 @@ export default scope.plays = []; url = scope.job.url + 'job_plays/?page_size=' + scope.playsMaxRows + '&order=id'; - url += (scope.search_play_name) ? '&play__icontains=' + scope.search_play_name : ''; + url += (scope.search_play_name) ? '&play__icontains=' + encodeURIComponent(scope.search_play_name) : ''; url += (scope.search_play_status === 'failed') ? '&failed=true' : ''; scope.playsLoading = true; Rest.setUrl(url); @@ -786,7 +786,7 @@ export default scope.tasks = []; if (scope.selectedPlay) { url = scope.job.url + 'job_tasks/?event_id=' + scope.selectedPlay; - url += (scope.search_task_name) ? '&task__icontains=' + scope.search_task_name : ''; + url += (scope.search_task_name) ? '&task__icontains=' + encodeURIComponent(scope.search_task_name) : ''; url += (scope.search_task_status === 'failed') ? '&failed=true' : ''; url += '&page_size=' + scope.tasksMaxRows + '&order=id'; scope.plays.every(function(p, idx) { diff --git a/awx/ui/client/src/helpers/JobTemplates.js b/awx/ui/client/src/helpers/JobTemplates.js index 55a4a9aa5f..2947c05546 100644 --- a/awx/ui/client/src/helpers/JobTemplates.js +++ b/awx/ui/client/src/helpers/JobTemplates.js @@ -25,7 +25,6 @@ angular.module('JobTemplatesHelper', ['Utilities']) return function(params) { var scope = params.scope, - CredentialList = _.cloneDeep(CredentialList), defaultUrl = GetBasePath('job_templates'), // generator = GenerateForm, form = JobTemplateForm(), @@ -37,6 +36,8 @@ angular.module('JobTemplatesHelper', ['Utilities']) // checkSCMStatus, getPlaybooks, callback, // choicesCount = 0; + CredentialList = _.cloneDeep(CredentialList); + // The form uses awPopOverWatch directive to 'watch' scope.callback_help for changes. Each time the // popover is activated, a function checks the value of scope.callback_help before constructing the content. scope.setCallbackHelp = function() { diff --git a/awx/ui/client/src/job-detail/host-events/host-events.controller.js b/awx/ui/client/src/job-detail/host-events/host-events.controller.js index 55abf51e04..f56111bdb6 100644 --- a/awx/ui/client/src/job-detail/host-events/host-events.controller.js +++ b/awx/ui/client/src/job-detail/host-events/host-events.controller.js @@ -25,8 +25,8 @@ host_name: $scope.hostName, }; if ($scope.searchStr && $scope.searchStr !== ''){ - params.or__play__icontains = $scope.searchStr; - params.or__task__icontains = $scope.searchStr; + params.or__play__icontains = encodeURIComponent($scope.searchStr); + params.or__task__icontains = encodeURIComponent($scope.searchStr); } switch($scope.activeFilter){ diff --git a/awx/ui/client/src/job-detail/host-summary/host-summary.controller.js b/awx/ui/client/src/job-detail/host-summary/host-summary.controller.js index 34763c838e..d5c826ac35 100644 --- a/awx/ui/client/src/job-detail/host-summary/host-summary.controller.js +++ b/awx/ui/client/src/job-detail/host-summary/host-summary.controller.js @@ -104,7 +104,7 @@ Wait('start'); JobDetailService.getJobHostSummaries($stateParams.id, { page_size: page_size, - host_name__icontains: $scope.searchTerm, + host_name__icontains: encodeURIComponent($scope.searchTerm), }).success(function(res){ $scope.hosts = res.results; $scope.next = res.next; diff --git a/awx/ui/client/src/job-detail/host-summary/host-summary.route.js b/awx/ui/client/src/job-detail/host-summary/host-summary.route.js index f7722462a0..a2de70e5d4 100644 --- a/awx/ui/client/src/job-detail/host-summary/host-summary.route.js +++ b/awx/ui/client/src/job-detail/host-summary/host-summary.route.js @@ -17,15 +17,5 @@ export default { }, ncyBreadcrumb: { skip: true // Never display this state in breadcrumb. - }, - onExit: function(){ - // close the job launch modal - // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" - // Destroy the dialog - if($("#job-launch-modal").hasClass('ui-dialog-content')) { - $('#job-launch-modal').dialog('destroy'); - } - // Remove the directive from the page (if it's there) - $('#content-container').find('submit-job').remove(); } }; diff --git a/awx/ui/client/src/job-detail/job-detail.route.js b/awx/ui/client/src/job-detail/job-detail.route.js index bf21fb8e5a..dda2722511 100644 --- a/awx/ui/client/src/job-detail/job-detail.route.js +++ b/awx/ui/client/src/job-detail/job-detail.route.js @@ -30,15 +30,5 @@ export default { }] }, templateUrl: templateUrl('job-detail/job-detail'), - controller: 'JobDetailController', - onExit: function(){ - // close the job launch modal - // using an onExit event to handle cases where the user navs away using the url bar / back and not modal "X" - // Destroy the dialog - if($("#job-launch-modal").hasClass('ui-dialog-content')) { - $('#job-launch-modal').dialog('destroy'); - } - // Remove the directive from the page (if it's there) - $('#content-container').find('submit-job').remove(); - } + controller: 'JobDetailController' }; diff --git a/awx/ui/client/src/job-submission/job-submission.directive.js b/awx/ui/client/src/job-submission/job-submission.directive.js index bd1c90ff92..26a3d9c826 100644 --- a/awx/ui/client/src/job-submission/job-submission.directive.js +++ b/awx/ui/client/src/job-submission/job-submission.directive.js @@ -89,6 +89,12 @@ export default [ 'templateUrl', 'CreateDialog', 'Wait', 'CreateSelect2', 'ParseT } }; + scope.$on("$stateChangeStart", function() { + scope.$evalAsync(function( scope ) { + scope.clearDialog(); + }); + }); + scope.init(); } diff --git a/awx/ui/client/src/job-submission/job-submission.partial.html b/awx/ui/client/src/job-submission/job-submission.partial.html index 95ca9df877..3f69234d91 100644 --- a/awx/ui/client/src/job-submission/job-submission.partial.html +++ b/awx/ui/client/src/job-submission/job-submission.partial.html @@ -54,9 +54,9 @@