diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 98e8d03ff8..7eec9473c6 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -31,6 +31,7 @@ from django.utils.encoding import force_str from django.utils.text import capfirst from django.utils.timezone import now from django.core.validators import RegexValidator, MaxLengthValidator +from django.db.models import Q # Django REST Framework from rest_framework.exceptions import ValidationError, PermissionDenied @@ -4576,16 +4577,47 @@ class BulkJobNodeSerializer(serializers.Serializer): class BulkJobLaunchSerializer(serializers.Serializer): - name = serializers.CharField(max_length=512, required=False) # limited by max name of jobs - jobs = BulkJobNodeSerializer(many=True, allow_empty=False, max_length=1000) + name = serializers.CharField(max_length=512, write_only=True, required=False) # limited by max name of jobs + jobs = BulkJobNodeSerializer(many=True, allow_empty=False, write_only=True, max_length=1000) + description: serializers.CharField(write_only=True, required=False, allow_blank=False) + extra_vars: serializers.CharField(write_only=True, required=False, allow_blank=False) + organization = serializers.PrimaryKeyRelatedField( + queryset=Organization.objects.all(), + required=False, + default=None, + allow_null=True, + help_text=_('Inherit permissions from organization roles.'), + ) + # inventory: "", # Here we can use PrimaryKeyRelatedField so it will automagically do rbac/turn into object + limit: serializers.CharField(write_only=True, required=False, allow_blank=False) + scm_branch: serializers.CharField(write_only=True, required=False, allow_blank=False) + # webhook_service: null, # Here we can use PrimaryKeyRelatedField so it will automagically do rbac/turn into object, I think, I'm actually not sure how to use this + # webhook_credential: null, # Here we can use PrimaryKeyRelatedField so it will automagically do rbac/turn into object I think, I'm actually not sure how to use this + skip_tags: serializers.CharField(write_only=True, required=False, allow_blank=False) + job_tags: serializers.CharField(write_only=True, required=False, allow_blank=False) + is_bulk_job: serializers.BooleanField(default=True) class Meta: - fields = ('name', 'jobs') + fields = ('name', 'jobs', 'description', 'limit') read_only_fields = () def validate(self, attrs): request = self.context.get('request', None) + # validate Organization + # - If the orgs is not set, set it to the org of the launching user + # - If the user is part of multiple orgs, throw a validation error saying user is part of multiple orgs, please provide one + + if 'organization' not in attrs or attrs['organization'] == None or attrs['oganization'] == '': + if Organization.accessible_pk_qs(request.user, 'read_role').count() == 1: + for tup in Organization.accessible_pk_qs(request.user, 'read_role').all(): + attrs['organization'] = tup[0] + elif Organization.accessible_pk_qs(request.user, 'read_role').count() > 1: + raise serializers.ValidationError(_(f"User has permission to multiple Organizations, please set one of them in the request")) + else: + raise serializers.ValidationError(_(f"User not part of any organization, please assign an organization to assign to the bulk job")) + requested_org = {attrs['organization']} + identifiers = set() for node in attrs['jobs']: if 'identifier' in node: @@ -4610,12 +4642,15 @@ class BulkJobLaunchSerializer(serializers.Serializer): [requested_use_labels.add(label) for label in job['labels']] if 'instance_groups' in job: [requested_use_instance_groups.add(instance_group) for instance_group in job['instance_groups']] - if 'execution_environment' in job: - [requested_use_execution_environments.add(execution_env) for execution_env in job['execution_environment']] # If we are not a superuser, check we have permissions # TODO: As we add other related items, we need to add them here if request and not request.user.is_superuser: + allowed_orgs = set() + if requested_org: + [allowed_orgs.add(tup[0]) for tup in Organization.accessible_pk_qs(request.user, 'read_role').all()] + if requested_org not in allowed_orgs: + ValidationError(_(f"Organization {requested_org} not found")) allowed_ujts = set() [allowed_ujts.add(tup[0]) for tup in UnifiedJobTemplate.accessible_pk_qs(request.user, 'execute_role').all()] [allowed_ujts.add(tup[0]) for tup in UnifiedJobTemplate.accessible_pk_qs(request.user, 'admin_role').all()] @@ -4640,7 +4675,7 @@ class BulkJobLaunchSerializer(serializers.Serializer): raise serializers.ValidationError(_(f"Credentials {not_allowed} not found.")) if requested_use_labels: - accessible_use_labels = {tup[0] for tup in Label.objects.all()} + accessible_use_labels = {tup.id for tup in Label.objects.all()} if requested_use_labels - accessible_use_labels: not_allowed = requested_use_labels - accessible_use_labels raise serializers.ValidationError(_(f"Labels {not_allowed} not found")) @@ -4649,14 +4684,21 @@ class BulkJobLaunchSerializer(serializers.Serializer): # only org admins are allowed to see instance groups organization_admin_qs = Organization.accessible_pk_qs(request.user, 'admin_role').all() if organization_admin_qs: - accessible_use_instance_groups = {tup[0] for tup in InstanceGroup.objects.all()} + accessible_use_instance_groups = {tup.id for tup in InstanceGroup.objects.all()} if requested_use_instance_groups - accessible_use_instance_groups: not_allowed = requested_use_instance_groups - accessible_use_instance_groups raise serializers.ValidationError(_(f"Instance Groups {not_allowed} not found")) - # TODO: Figure out the Execution environment RBAC - # For execution environment, need to figure out the RBAC part. Seems like any user part of an organization can see/use all the execution - # of that orgnization. So we need to filter out the ee's based on request.user organization. + if requested_use_execution_environments: + accessible_execution_env = { + tup.id + for tup in ExecutionEnvironment.objects.filter( + Q(organization__in=Organization.accessible_pk_qs(request.user, 'read_role')) | Q(organization__isnull=True) + ).distinct() + } + if requested_use_execution_environments - accessible_execution_env: + not_allowed = requested_use_execution_environments - accessible_execution_env + raise serializers.ValidationError(_(f"Execution Environments {not_allowed} not found")) # all of the unified job templates and related items have now been checked, we can now grab the objects from the DB # TODO: As we add more related objects like Label, InstanceGroup, etc we need to add them here @@ -4667,6 +4709,7 @@ class BulkJobLaunchSerializer(serializers.Serializer): "credentials": {obj.id: obj for obj in Credential.objects.filter(id__in=requested_use_credentials)}, "labels": {obj.id: obj for obj in Label.objects.filter(id__in=requested_use_labels)}, "instance_groups": {obj.id: obj for obj in InstanceGroup.objects.filter(id__in=requested_use_instance_groups)}, + "execution_environment": {obj.id: obj for obj in ExecutionEnvironment.objects.filter(id__in=requested_use_execution_environments)}, } # This loop is generalized so we should only have to add related items to the key_to_obj_map @@ -4685,6 +4728,9 @@ class BulkJobLaunchSerializer(serializers.Serializer): objectified_jobs.append(objectified_job) attrs['jobs'] = objectified_jobs + # map the organization object + for obj in Organization.objects.filter(id__in=requested_org): + attrs['organization'] = obj return attrs def create(self, validated_data): diff --git a/awx/api/templates/api/bulk_job_launch_view.md b/awx/api/templates/api/bulk_job_launch_view.md new file mode 100644 index 0000000000..b6afee6ca5 --- /dev/null +++ b/awx/api/templates/api/bulk_job_launch_view.md @@ -0,0 +1,16 @@ +# Bulk Job Launch + +This endpoint allows the client to launch multiple UnifiedJobTemplates at a time, along side any launch time parameters that they would normally set at launch time. + +Example: + +``` +{ +"name": "my bulk job", +"jobs": [ + {"unified_job_template": 7, "inventory": 2}, + {"unified_job_template": 7, "credentials": [3]} +] + +} +``` diff --git a/awx/api/urls/urls.py b/awx/api/urls/urls.py index 49283ceffe..80f23dd2f3 100644 --- a/awx/api/urls/urls.py +++ b/awx/api/urls/urls.py @@ -30,6 +30,8 @@ from awx.api.views import ( OAuth2TokenList, ApplicationOAuth2TokenList, OAuth2ApplicationDetail, +) +from awx.api.views.bulk import ( BulkJobLaunchView, BulkView, ) diff --git a/awx/api/views/__init__.py b/awx/api/views/__init__.py index 14dc76367a..4d5f98d2c2 100644 --- a/awx/api/views/__init__.py +++ b/awx/api/views/__init__.py @@ -4301,41 +4301,6 @@ class WorkflowApprovalDetail(UnifiedJobDeletionMixin, RetrieveDestroyAPIView): serializer_class = serializers.WorkflowApprovalSerializer -from rest_framework.decorators import api_view - - -class BulkJobLaunchView(APIView): - _ignore_model_permissions = True - permission_classes = [IsAuthenticated] - serializer_class = serializers.BulkJobLaunchSerializer - allowed_methods = ['GET', 'POST', 'OPTIONS'] - - def get(self, request): - # TODO Return something sensible here, like the defaults - bulkjob_serializer = serializers.BulkJobLaunchSerializer(data={}, context={'request': request}) - bulkjob_serializer.is_valid() - return Response(bulkjob_serializer.errors, status=status.HTTP_200_OK) - - def post(self, request): - bulkjob_serializer = serializers.BulkJobLaunchSerializer(data=request.data, context={'request': request}) - if bulkjob_serializer.is_valid(): - result = bulkjob_serializer.create(bulkjob_serializer.validated_data) - return Response(result, status=status.HTTP_201_CREATED) - return Response(bulkjob_serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - -class BulkView(APIView): - _ignore_model_permissions = True - permission_classes = [IsAuthenticated] - allowed_methods = ['GET'] - - def get(self, request, format=None): - '''List top level resources''' - data = OrderedDict() - data['bulk_job_launch'] = reverse('api:bulk_job_launch', request=request) - return Response(data) - - class WorkflowApprovalApprove(RetrieveAPIView): model = models.WorkflowApproval serializer_class = serializers.WorkflowApprovalViewSerializer diff --git a/awx/api/views/bulk.py b/awx/api/views/bulk.py index d0f4046f98..e4dd1e62c2 100644 --- a/awx/api/views/bulk.py +++ b/awx/api/views/bulk.py @@ -16,6 +16,25 @@ from awx.api import ( ) +class BulkJobLaunchView(GenericAPIView): + _ignore_model_permissions = True + permission_classes = [IsAuthenticated] + serializer_class = serializers.BulkJobLaunchSerializer + allowed_methods = ['GET', 'POST', 'OPTIONS'] + + def get(self, request): + data = OrderedDict() + data['detail'] = "Specify a list of unified job templates to launch alongside their launchtime parameters" + return Response(data, status=status.HTTP_200_OK) + + def post(self, request): + bulkjob_serializer = serializers.BulkJobLaunchSerializer(data=request.data, context={'request': request}) + if bulkjob_serializer.is_valid(): + result = bulkjob_serializer.create(bulkjob_serializer.validated_data) + return Response(result, status=status.HTTP_201_CREATED) + return Response(bulkjob_serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + class BulkView(APIView): _ignore_model_permissions = True permission_classes = [IsAuthenticated] @@ -29,6 +48,7 @@ class BulkView(APIView): '''List top level resources''' data = OrderedDict() data['bulk_host_create'] = reverse('api:bulk_host_create', request=request) + data['bulk_job_launch'] = reverse('api:bulk_job_launch', request=request) return Response(data) diff --git a/awx/main/access.py b/awx/main/access.py index 4d6bdf2c55..fa3d0c7677 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -2123,7 +2123,11 @@ class WorkflowJobAccess(BaseAccess): ) def filtered_queryset(self): - return WorkflowJob.objects.filter(unified_job_template__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) + return WorkflowJob.objects.filter( + Q(unified_job_template__in=UnifiedJobTemplate.accessible_pk_qs(self.user, 'read_role')) + | Q(created_by__in=str(self.user.id), is_bulk_job=True) + | Q(organization__in=Organization.objects.filter(Q(admin_role__members=self.user)), is_bulk_job=True) + ) def can_add(self, data): # Old add-start system for launching jobs is being depreciated, and