From d9f3fed06f56fd9f253195ed13e0a89071ad6847 Mon Sep 17 00:00:00 2001 From: beeankha Date: Thu, 8 Aug 2019 16:36:42 -0400 Subject: [PATCH] Update UJ/UJT endpoints, update approval RBAC, update approval timeout --- awx/api/serializers.py | 19 +++------- awx/api/views/__init__.py | 4 +-- awx/main/access.py | 22 ++++++------ .../migrations/0083_v360_workflow_approval.py | 7 +++- awx/main/models/__init__.py | 1 - awx/main/models/workflow.py | 35 ++++++++++++++----- awx/main/scheduler/task_manager.py | 13 ++----- 7 files changed, 53 insertions(+), 48 deletions(-) diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 0095b3695c..e38a770335 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -3414,15 +3414,16 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer): can_approve_or_deny = serializers.SerializerMethodField() approval_expiration = serializers.SerializerMethodField() + timed_out = serializers.ReadOnlyField() class Meta: model = WorkflowApproval - fields = (['*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration']) + fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out',) def get_approval_expiration(self, obj): if obj.status != 'pending' or obj.timeout == 0: return None - return now() + timedelta(seconds=obj.timeout) + return obj.created + timedelta(seconds=obj.timeout) def get_can_approve_or_deny(self, obj): request = self.context.get('request', None) @@ -3442,20 +3443,8 @@ class WorkflowApprovalSerializer(UnifiedJobSerializer): class WorkflowApprovalListSerializer(WorkflowApprovalSerializer, UnifiedJobListSerializer): - can_approve_or_deny = serializers.SerializerMethodField() - approval_expiration = serializers.SerializerMethodField() - class Meta: - fields = ('*', '-execution_node', '-controller_node', 'can_approve_or_deny', 'approval_expiration') - - def get_approval_expiration(self, obj): - if obj.status != 'pending' or obj.timeout == 0: - return None - return now() + timedelta(seconds=obj.timeout) - - def get_can_approve_or_deny(self, obj): - request = self.context.get('request', None) - return request.user.can_access(WorkflowApproval, 'approve_or_deny', obj) is True + fields = ('*', '-controller_node', '-execution_node', 'can_approve_or_deny', 'approval_expiration', 'timed_out',) class WorkflowApprovalTemplateSerializer(UnifiedJobTemplateSerializer): diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index d53438cb48..78f0d1485a 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4461,7 +4461,7 @@ class WorkflowApprovalApprove(RetrieveAPIView): raise PermissionDenied(detail=_("User does not have permission to approve or deny this workflow.")) if obj.status != 'pending': return Response("This workflow step has already been approved or denied.", status=status.HTTP_400_BAD_REQUEST) - obj.approve() + obj.approve(request) return Response(status=status.HTTP_204_NO_CONTENT) @@ -4476,7 +4476,7 @@ class WorkflowApprovalDeny(RetrieveAPIView): raise PermissionDenied(detail=_("User does not have permission to approve or deny this workflow.")) if obj.status != 'pending': return Response("This workflow step has already been approved or denied.", status=status.HTTP_400_BAD_REQUEST) - obj.deny() + obj.deny(request) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/awx/main/access.py b/awx/main/access.py index ea4e2938f7..59dfbc1b29 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2016,7 +2016,7 @@ class WorkflowJobTemplateAccess(NotificationAttachMixin, BaseAccess): if self.user not in cred.use_role: missing_credentials.append(cred.name) ujt = node.unified_job_template - if ujt and not self.user.can_access(UnifiedJobTemplate, 'start', ujt, validate_license=False): + if ujt and not isinstance(ujt, WorkflowApprovalTemplate) and not self.user.can_access(UnifiedJobTemplate, 'start', ujt, validate_license=False): missing_ujt.append(ujt.name) if missing_ujt: self.messages['templates_unable_to_copy'] = missing_ujt @@ -2379,13 +2379,17 @@ class UnifiedJobTemplateAccess(BaseAccess): Q(pk__in=self.model.accessible_pk_qs(self.user, 'read_role')) | Q(inventorysource__inventory__id__in=Inventory._accessible_pk_qs( Inventory, self.user, 'read_role')) - ) # &&&&&& (filter out approvals from UJT endpoint here...?) + ) def can_start(self, obj, validate_license=True): access_class = access_registry[obj.__class__] access_instance = access_class(self.user) return access_instance.can_start(obj, validate_license=validate_license) + def get_queryset(self): + return super(UnifiedJobTemplateAccess, self).get_queryset().filter( + workflowapprovaltemplate__isnull=True) + class UnifiedJobAccess(BaseAccess): ''' @@ -2429,9 +2433,13 @@ class UnifiedJobAccess(BaseAccess): Q(adhoccommand__inventory__id__in=inv_pk_qs) | Q(job__inventory__organization__in=org_auditor_qs) | Q(job__project__organization__in=org_auditor_qs) - ) # &&&&&& (for filtering out approvals from UJ endpoint...?) + ) return qs + def get_queryset(self): + return super(UnifiedJobAccess, self).get_queryset().filter( + workflowapproval__isnull=True) + class ScheduleAccess(BaseAccess): ''' @@ -2796,10 +2804,6 @@ class WorkflowApprovalAccess(BaseAccess): unified_job_node__workflow_job__unified_job_template__in=WorkflowJobTemplate.accessible_pk_qs( self.user, 'read_role')) - def get_queryset(self): - return super(WorkflowApprovalAccess, self).get_queryset().exclude( - workflow_approval_template__isnull=True) - def can_approve_or_deny(self, obj): if obj.status != 'pending': return False @@ -2831,10 +2835,6 @@ class WorkflowApprovalTemplateAccess(BaseAccess): workflowjobtemplatenodes__workflow_job_template__in=WorkflowJobTemplate.accessible_pk_qs( self.user, 'read_role')) - def get_queryset(self): - return super(WorkflowApprovalTemplateAccess, self).get_queryset().filter( - approvals__isnull=False) - for cls in BaseAccess.__subclasses__(): access_registry[cls.model] = cls diff --git a/awx/main/migrations/0083_v360_workflow_approval.py b/awx/main/migrations/0083_v360_workflow_approval.py index 8e76fa56dc..eeb73d83b6 100644 --- a/awx/main/migrations/0083_v360_workflow_approval.py +++ b/awx/main/migrations/0083_v360_workflow_approval.py @@ -29,7 +29,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='workflowjobtemplate', name='approval_role', - field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['singleton:system_auditor', 'organization.approval_role', 'admin_role'], related_name='+', to='main.Role'), + field=awx.main.fields.ImplicitRoleField(editable=False, null='True', on_delete=django.db.models.deletion.CASCADE, parent_role=['organization.approval_role', 'admin_role'], related_name='+', to='main.Role'), preserve_default='True', ), migrations.AlterField( @@ -75,4 +75,9 @@ class Migration(migrations.Migration): name='timeout', field=models.IntegerField(blank=True, default=0, help_text='The amount of time (in seconds) before the approval node expires and fails.'), ), + migrations.AddField( + model_name='workflowapproval', + name='timed_out', + field=models.BooleanField(default=False, help_text='Shows when an approval node (with a timeout assigned to it) has timed out.'), + ), ] diff --git a/awx/main/models/__init__.py b/awx/main/models/__init__.py index 6988951ce2..88631aa94a 100644 --- a/awx/main/models/__init__.py +++ b/awx/main/models/__init__.py @@ -203,7 +203,6 @@ activity_stream_registrar.connect(WorkflowJobTemplate) activity_stream_registrar.connect(WorkflowJobTemplateNode) activity_stream_registrar.connect(WorkflowJob) activity_stream_registrar.connect(WorkflowApproval) -# activity_stream_registrar.connect(WorkflowApprovalTemplate) activity_stream_registrar.connect(OAuth2Application) activity_stream_registrar.connect(OAuth2AccessToken) diff --git a/awx/main/models/workflow.py b/awx/main/models/workflow.py index 8e07d79b41..bd5a2b2d21 100644 --- a/awx/main/models/workflow.py +++ b/awx/main/models/workflow.py @@ -2,6 +2,7 @@ # All Rights Reserved. # Python +import json import logging # Django @@ -31,10 +32,12 @@ from awx.main.models.mixins import ( RelatedJobsMixin, ) from awx.main.models.jobs import LaunchTimeConfigBase, LaunchTimeConfig, JobTemplate +from awx.main.models.activity_stream import ActivityStream from awx.main.models.credential import Credential from awx.main.redact import REPLACE_STR from awx.main.fields import JSONField -from awx.main.utils import schedule_task_manager +from awx.main.utils import model_to_dict, schedule_task_manager + from copy import copy from urllib.parse import urljoin @@ -397,7 +400,6 @@ class WorkflowJobTemplate(UnifiedJobTemplate, WorkflowJobOptions, SurveyJobTempl 'approval_role', ]) approval_role = ImplicitRoleField(parent_role=[ - 'singleton:' + ROLE_SINGLETON_SYSTEM_AUDITOR, 'organization.approval_role', 'admin_role', ]) @@ -655,6 +657,11 @@ class WorkflowApproval(UnifiedJob): default=0, help_text=_("The amount of time (in seconds) before the approval node expires and fails."), ) + timed_out = models.BooleanField( + default=False, + help_text=_("Shows when an approval node (with a timeout assigned to it) has timed out.") + ) + @classmethod def _get_unified_job_template_class(cls): @@ -671,19 +678,31 @@ class WorkflowApproval(UnifiedJob): return 'workflow_approval_template' def approve(self, request=None): + from awx.main.signals import model_serializer_mapping # circular import self.status = 'successful' self.save() + changes = model_to_dict(self, model_serializer_mapping()) + changes['status']=['pending', 'successful'] + ActivityStream( + operation='update', + object1='workflow_approval', + actor=request.user, + changes=json.dumps(changes), + ).save() schedule_task_manager() return reverse('api:workflow_approval_approve', kwargs={'pk': self.pk}, request=request) def deny(self, request=None): + from awx.main.signals import model_serializer_mapping # circular import self.status = 'failed' self.save() + changes = model_to_dict(self, model_serializer_mapping()) + changes['status']=['pending', 'failed'] + ActivityStream( + operation='update', + object1='workflow_approval', + actor=request.user, + changes=json.dumps(changes), + ).save() schedule_task_manager() return reverse('api:workflow_approval_deny', kwargs={'pk': self.pk}, request=request) - - # &&&&&& Possible placeholder for websocket support - # def websocket_emit_data(self): - # websocket_data = super(WorkflowApproval, self).websocket_emit_data() - # websocket_data.update(dict(project_id=self.project.id)) # ????? - # return websocket_data diff --git a/awx/main/scheduler/task_manager.py b/awx/main/scheduler/task_manager.py index fe316c22ea..82bafeaadd 100644 --- a/awx/main/scheduler/task_manager.py +++ b/awx/main/scheduler/task_manager.py @@ -519,26 +519,19 @@ class TaskManager(): if not found_acceptable_queue: logger.debug("{} couldn't be scheduled on graph, waiting for next cycle".format(task.log_format)) - def timeout_approval_node(self): # Add websocket stuff for when it transitions to "timed out" (maybe a websocket_emit_status() call) + def timeout_approval_node(self): workflow_approvals = WorkflowApproval.objects.filter(status='pending') now = tz_now() for task in workflow_approvals: - # TODO: copy the timeout to the job itself at launch time, not the template <---- Started to implement steps to do this, but unsure... approval_timeout_seconds = timedelta(seconds=task.timeout) if task.timeout == 0: continue if (now - task.created) >= approval_timeout_seconds: logger.info("The approval node {} ({}) has expired after {} seconds.".format(task.name, task.pk, task.timeout)) + task.timed_out = True task.status = 'failed' task.job_explanation = _("This approval node has timed out.") - task.save(update_fields=['status', 'job_explanation']) - - # &&&&&& Placeholder for websocket support - # def detect_pending_approval(self): - # workflow_approvals = WorkflowApproval.objects.filter(status='pending').prefetch_related('workflow_approval_template') - # for task in workflow_approvals: - # if task.status == 'pending': - # workflow_approvals.websocket_emit_status(task.status) + task.save(update_fields=['status', 'job_explanation', 'timed_out']) def calculate_capacity_consumed(self, tasks): self.graph = InstanceGroup.objects.capacity_values(tasks=tasks, graph=self.graph)