Repository: nautobot/nautobot

Issue:
[1.3] Scheduled-job approval/dry-run REST API doesn't enforce object-based permissions yet
### Environment
* Python version:
* Nautobot version: `next` after #1457

### Expected Behavior

REST API for scheduled job approval/dry-run/deny needs to enforce the same set of action and object-based permissions as the UI views do. Tests need to be added to verify these permissions, similar to what was added in https://github.com/nautobot/nautobot/pull/1457/commits/7c4e47442972b5d45c45a8a299df7b71c6899796 for the UI views.

<!-- What happened instead? -->
### Observed Behavior

These REST API endpoints require `extras.run_job` permission for all three actions, do not require `extras.approve_job` permission, and do not enforce object-based permissions on `extras.run_job`.


Discussion:

Resolving patch:
diff --git a/nautobot/extras/api/views.py b/nautobot/extras/api/views.py
--- a/nautobot/extras/api/views.py
+++ b/nautobot/extras/api/views.py
@@ -55,7 +55,7 @@
     Webhook,
 )
 from nautobot.extras.models import CustomField, CustomFieldChoice
-from nautobot.extras.jobs import get_job, run_job
+from nautobot.extras.jobs import run_job
 from nautobot.extras.utils import get_worker_count
 from nautobot.utilities.exceptions import CeleryWorkerNotRunningException
 from nautobot.utilities.api import get_serializer_for_model
@@ -622,6 +622,27 @@ class ScheduledJobViewSet(ReadOnlyModelViewSet):
     serializer_class = serializers.ScheduledJobSerializer
     filterset_class = filters.ScheduledJobFilterSet
 
+    def restrict_queryset(self, request, *args, **kwargs):
+        """
+        Apply special permissions as queryset filter on the /approve/, /deny/, and /dry-run/ endpoints.
+
+        Otherwise, same as ModelViewSetMixin.
+        """
+        action_to_method = {"approve": "change", "deny": "delete", "dry-run": "view"}
+        if request.user.is_authenticated and self.action in action_to_method:
+            self.queryset = self.queryset.restrict(request.user, action_to_method[self.action])
+        else:
+            super().restrict_queryset(request, *args, **kwargs)
+
+    class ScheduledJobChangePermissions(TokenPermissions):
+        """
+        As nautobot.core.api.authentication.TokenPermissions, but enforcing change_scheduledjob not add_scheduledjob.
+        """
+
+        perms_map = {
+            "POST": ["%(app_label)s.change_%(model_name)s"],
+        }
+
     @swagger_auto_schema(
         method="post",
         responses={"200": serializers.ScheduledJobSerializer},
@@ -635,12 +656,12 @@ class ScheduledJobViewSet(ReadOnlyModelViewSet):
             )
         ],
     )
-    @action(detail=True, methods=["post"])
+    @action(detail=True, methods=["post"], permission_classes=[ScheduledJobChangePermissions])
     def approve(self, request, pk):
-        if not request.user.has_perm("extras.run_job"):
-            raise PermissionDenied()
+        scheduled_job = get_object_or_404(self.queryset, pk=pk)
 
-        scheduled_job = get_object_or_404(ScheduledJob, pk=pk)
+        if not Job.objects.check_perms(request.user, instance=scheduled_job.job_model, action="approve"):
+            raise PermissionDenied("You do not have permission to approve this request.")
 
         # Mark the scheduled_job as approved, allowing the schedular to schedule the job execution task
         if request.user == scheduled_job.user:
@@ -664,47 +685,61 @@ def approve(self, request, pk):
 
         return Response(serializer.data)
 
+    class ScheduledJobDeletePermissions(TokenPermissions):
+        """
+        As nautobot.core.api.authentication.TokenPermissions, but enforcing delete_scheduledjob not add_scheduledjob.
+        """
+
+        perms_map = {
+            "POST": ["%(app_label)s.delete_%(model_name)s"],
+        }
+
     @swagger_auto_schema(
         method="post",
         request_body=no_body,
     )
-    @action(detail=True, methods=["post"])
+    @action(detail=True, methods=["post"], permission_classes=[ScheduledJobDeletePermissions])
     def deny(self, request, pk):
-        if not request.user.has_perm("extras.run_job"):
-            raise PermissionDenied()
-
         scheduled_job = get_object_or_404(ScheduledJob, pk=pk)
 
+        if not Job.objects.check_perms(request.user, instance=scheduled_job.job_model, action="approve"):
+            raise PermissionDenied("You do not have permission to deny this request.")
+
         scheduled_job.delete()
 
         return Response(None)
 
+    class ScheduledJobViewPermissions(TokenPermissions):
+        """
+        As nautobot.core.api.authentication.TokenPermissions, but enforcing view_scheduledjob not add_scheduledjob.
+        """
+
+        perms_map = {
+            "POST": ["%(app_label)s.view_%(model_name)s"],
+        }
+
     @swagger_auto_schema(
         method="post",
         responses={"200": serializers.JobResultSerializer},
         request_body=no_body,
     )
-    @action(detail=True, url_path="dry-run", methods=["post"])
+    @action(detail=True, url_path="dry-run", methods=["post"], permission_classes=[ScheduledJobViewPermissions])
     def dry_run(self, request, pk):
-        if not request.user.has_perm("extras.run_job"):
-            raise PermissionDenied()
-
         scheduled_job = get_object_or_404(ScheduledJob, pk=pk)
-        # TODO enforce object-level permissions on run_job here as well?
-        job_class = get_job(scheduled_job.job_class)
-        if job_class is None:
-            raise Http404
-        job = job_class()
-        grouping, module, class_name = job_class.class_path.split("/", 2)
+        job_model = scheduled_job.job_model
+        if job_model is None or not job_model.runnable:
+            raise MethodNotAllowed("This job cannot be dry-run at this time.")
+        if not Job.objects.check_perms(request.user, instance=job_model, action="run"):
+            raise PermissionDenied("You do not have permission to run this job.")
 
         # Immediately enqueue the job with commit=False
         job_content_type = ContentType.objects.get(app_label="extras", model="job")
         job_result = JobResult.enqueue_job(
             run_job,
-            job.class_path,
+            job_model.class_path,
             job_content_type,
-            scheduled_job.user,
-            data=scheduled_job.kwargs["data"],
+            request.user,
+            data=scheduled_job.kwargs.get("data", {}),
             request=copy_safe_request(request),
             commit=False,  # force a dry-run
         )
diff --git a/nautobot/extras/views.py b/nautobot/extras/views.py
--- a/nautobot/extras/views.py
+++ b/nautobot/extras/views.py
@@ -1148,11 +1148,7 @@ def post(self, request, pk):
             if not (
                 self.queryset.check_perms(request.user, instance=scheduled_job, action="delete")
                 and job_model is not None
-                and (
-                    JobModel.objects.check_perms(request.user, instance=job_model, action="approve")
-                    # A user can deny (revoke) their own approval requests, even without general job-approval permission
-                    or request.user == scheduled_job.user
-                )
+                and JobModel.objects.check_perms(request.user, instance=job_model, action="approve")
             ):
                 messages.error(request, "You do not have permission to deny this request.")
             else:


Test patch:
diff --git a/nautobot/extras/tests/test_api.py b/nautobot/extras/tests/test_api.py
--- a/nautobot/extras/tests/test_api.py
+++ b/nautobot/extras/tests/test_api.py
@@ -1540,10 +1540,12 @@ class ScheduledJobTest(
     @classmethod
     def setUpTestData(cls):
         user = User.objects.create(username="user1", is_active=True)
+        job_model = Job.objects.get_for_class_path("local/test_pass/TestPass")
         ScheduledJob.objects.create(
             name="test1",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=job_model.class_path,
+            job_model=job_model,
             interval=JobExecutionType.TYPE_IMMEDIATELY,
             user=user,
             approval_required=True,
@@ -1551,8 +1553,9 @@ def setUpTestData(cls):
         )
         ScheduledJob.objects.create(
             name="test2",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=job_model.class_path,
+            job_model=job_model,
             interval=JobExecutionType.TYPE_IMMEDIATELY,
             user=user,
             approval_required=True,
@@ -1560,8 +1563,9 @@ def setUpTestData(cls):
         )
         ScheduledJob.objects.create(
             name="test3",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=job_model.class_path,
+            job_model=job_model,
             interval=JobExecutionType.TYPE_IMMEDIATELY,
             user=user,
             approval_required=True,
@@ -1578,38 +1582,56 @@ def test_options_returns_expected_choices(self):
 class JobApprovalTest(APITestCase):
     @classmethod
     def setUpTestData(cls):
-        user = User.objects.create(username="user1", is_active=True)
+        cls.additional_user = User.objects.create(username="user1", is_active=True)
+        cls.job_model = Job.objects.get_for_class_path("local/test_pass/TestPass")
+        cls.job_model.enabled = True
+        cls.job_model.save()
         cls.scheduled_job = ScheduledJob.objects.create(
             name="test",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=cls.job_model.class_path,
+            job_model=cls.job_model,
             interval=JobExecutionType.TYPE_IMMEDIATELY,
-            user=user,
+            user=cls.additional_user,
             approval_required=True,
             start_time=now(),
         )
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_approve_job_anonymous(self):
-        url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": 1})
-        response = self.client.post(url, **self.header)
+        url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
+        response = self.client.post(url)
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_approve_job_without_permission(self):
-        url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": 1})
+        url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
         with disable_warnings("django.request"):
             response = self.client.post(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_approve_job_without_approve_job_permission(self):
+        self.add_permissions("extras.view_scheduledjob", "extras.change_scheduledjob")
+        url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
+        response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_approve_job_without_change_scheduledjob_permission(self):
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob")
+        url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
+        response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_approve_job_same_user(self):
-        self.add_permissions("extras.run_job")
-        self.add_permissions("extras.add_scheduledjob")
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
         scheduled_job = ScheduledJob.objects.create(
             name="test",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=self.job_model.class_path,
+            job_model=self.job_model,
             interval=JobExecutionType.TYPE_IMMEDIATELY,
             user=self.user,
             approval_required=True,
@@ -1621,24 +1643,22 @@ def test_approve_job_same_user(self):
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_approve_job(self):
-        self.add_permissions("extras.run_job")
-        self.add_permissions("extras.add_scheduledjob")
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
         url = reverse("extras-api:scheduledjob-approve", kwargs={"pk": self.scheduled_job.pk})
         response = self.client.post(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_approve_job_in_past(self):
-        self.add_permissions("extras.run_job")
-        self.add_permissions("extras.add_scheduledjob")
-        user = User.objects.get(username="user1")
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
         scheduled_job = ScheduledJob.objects.create(
             name="test",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=self.job_model.class_path,
+            job_model=self.job_model,
             interval=JobExecutionType.TYPE_FUTURE,
             one_off=True,
-            user=user,
+            user=self.additional_user,
             approval_required=True,
             start_time=now(),
         )
@@ -1648,16 +1668,15 @@ def test_approve_job_in_past(self):
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_approve_job_in_past_force(self):
-        self.add_permissions("extras.run_job")
-        self.add_permissions("extras.add_scheduledjob")
-        user = User.objects.get(username="user1")
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.change_scheduledjob")
         scheduled_job = ScheduledJob.objects.create(
             name="test",
-            task="-",
-            job_class="-",
+            task="nautobot.extras.jobs.scheduled_job_handler",
+            job_class=self.job_model.class_path,
+            job_model=self.job_model,
             interval=JobExecutionType.TYPE_FUTURE,
             one_off=True,
-            user=user,
+            user=self.additional_user,
             approval_required=True,
             start_time=now(),
         )
@@ -1667,32 +1686,51 @@ def test_approve_job_in_past_force(self):
 
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_deny_job_without_permission(self):
-        url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": 1})
+        url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
         with disable_warnings("django.request"):
             response = self.client.post(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_deny_job_without_approve_job_permission(self):
+        self.add_permissions("extras.view_scheduledjob", "extras.delete_scheduledjob")
+        url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
+        response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_deny_job_without_delete_scheduledjob_permission(self):
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob")
+        url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
+        response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_deny_job(self):
-        self.add_permissions("extras.run_job")
-        self.add_permissions("extras.add_scheduledjob")
+        self.add_permissions("extras.approve_job", "extras.view_scheduledjob", "extras.delete_scheduledjob")
         url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
         response = self.client.post(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertIsNone(ScheduledJob.objects.filter(pk=self.scheduled_job.pk).first())
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
     def test_dry_run_job_without_permission(self):
-        url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": 1})
+        url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
         with disable_warnings("django.request"):
             response = self.client.post(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
+    def test_dry_run_job_without_run_job_permission(self):
+        self.add_permissions("extras.view_scheduledjob")
+        url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
+        response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_dry_run_job(self):
-        self.add_permissions("extras.run_job")
-        self.add_permissions("extras.add_scheduledjob")
-        url = reverse("extras-api:scheduledjob-deny", kwargs={"pk": self.scheduled_job.pk})
+        self.add_permissions("extras.run_job", "extras.view_scheduledjob")
+        url = reverse("extras-api:scheduledjob-dry-run", kwargs={"pk": self.scheduled_job.pk})
         response = self.client.post(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
diff --git a/nautobot/extras/tests/test_views.py b/nautobot/extras/tests/test_views.py
--- a/nautobot/extras/tests/test_views.py
+++ b/nautobot/extras/tests/test_views.py
@@ -1104,19 +1104,6 @@ def test_post_dry_run_success(self, _):
         self.assertEqual(job_result.user, self.user)
         self.assertRedirects(response, reverse("extras:job_jobresult", kwargs={"pk": job_result.pk}))
 
-    @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
-    def test_post_deny_same_user_permitted(self):
-        """A user can revoke their own request even without specific approver permissions to do so."""
-        self.add_permissions("extras.view_scheduledjob")
-        self.add_permissions("extras.delete_scheduledjob")
-        instance = self._get_queryset().first()
-        data = {"_deny": True}
-
-        response = self.client.post(self._get_url("view", instance), data)
-        self.assertRedirects(response, reverse("extras:scheduledjob_approval_queue_list"))
-        # Request was deleted
-        self.assertEqual(0, len(ScheduledJob.objects.filter(pk=instance.pk)))
-
     @override_settings(EXEMPT_VIEW_PERMISSIONS=["*"])
     def test_post_deny_different_user_lacking_permissions(self):
         """A user needs both delete_scheduledjob and approve_job permissions to deny a job request."""