Parcourir la source

Merge pull request #23300 from netbox-community/23233-datasource-sync-permission

Fixes #23233: Use sync permission for DataSource /sync endpoint
bctiemann il y a 1 jour
Parent
commit
c9d78c13a6
2 fichiers modifiés avec 73 ajouts et 4 suppressions
  1. 13 3
      netbox/core/api/views.py
  2. 60 1
      netbox/core/tests/test_api.py

+ 13 - 3
netbox/core/api/views.py

@@ -18,7 +18,7 @@ from core import filtersets
 from core.jobs import SyncDataSourceJob
 from core.jobs import SyncDataSourceJob
 from core.models import *
 from core.models import *
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job
-from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
+from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenSyncPermission
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.pagination import LimitOffsetListPagination
 from netbox.api.pagination import LimitOffsetListPagination
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
 from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
@@ -40,10 +40,17 @@ class DataSourceViewSet(NetBoxModelViewSet):
     serializer_class = serializers.DataSourceSerializer
     serializer_class = serializers.DataSourceSerializer
     filterset_class = filtersets.DataSourceFilterSet
     filterset_class = filtersets.DataSourceFilterSet
 
 
+    def get_permissions(self):
+        if self.action == 'sync':
+            return [TokenSyncPermission()]
+        return super().get_permissions()
+
+    @extend_schema(request=None, responses={200: serializers.DataSourceSerializer})
     @action(detail=True, methods=['post'])
     @action(detail=True, methods=['post'])
     def sync(self, request, pk):
     def sync(self, request, pk):
         """
         """
-        Enqueue a job to synchronize the DataSource.
+        Enqueue a job to synchronize the DataSource. The brief representation of the DataSource is returned if the
+        user does not have permission to view it.
         """
         """
         datasource = get_object_or_404(DataSource, pk=pk)
         datasource = get_object_or_404(DataSource, pk=pk)
 
 
@@ -53,7 +60,10 @@ class DataSourceViewSet(NetBoxModelViewSet):
         # Enqueue the sync job
         # Enqueue the sync job
         SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
         SyncDataSourceJob.enqueue(instance=datasource, user=request.user)
 
 
-        serializer = serializers.DataSourceSerializer(datasource, context={'request': request})
+        # Return the full representation only if the user may view the DataSource, as it includes backend
+        # parameters (e.g. credentials).
+        nested = not request.user.has_perm('core.view_datasource', obj=datasource)
+        serializer = serializers.DataSourceSerializer(datasource, nested=nested, context={'request': request})
 
 
         return Response(serializer.data)
         return Response(serializer.data)
 
 

+ 60 - 1
netbox/core/tests/test_api.py

@@ -1,6 +1,8 @@
 import uuid
 import uuid
+from unittest.mock import patch
 
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.test import override_settings
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django_rq import get_queue
 from django_rq import get_queue
@@ -11,7 +13,7 @@ from rq.job import JobStatus
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 from rq.registry import FailedJobRegistry, StartedJobRegistry
 
 
 from users.constants import TOKEN_PREFIX
 from users.constants import TOKEN_PREFIX
-from users.models import Token
+from users.models import ObjectPermission, Token
 from utilities.testing import APITestCase, APIViewTestCases, GraphQLQueryTest, TestCase, create_tags
 from utilities.testing import APITestCase, APIViewTestCases, GraphQLQueryTest, TestCase, create_tags
 from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.mixins import RQQueueTestMixin
 from utilities.testing.utils import disable_logging
 from utilities.testing.utils import disable_logging
@@ -154,6 +156,63 @@ class DataSourceTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(list(data_source.tags.values_list('slug', flat=True)), [])
         self.assertEqual(list(data_source.tags.values_list('slug', flat=True)), [])
 
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+    def test_sync(self):
+        """
+        The sync action is governed by the sync permission. The full representation is returned only if the user
+        may view the data source.
+        """
+        data_source = DataSource.objects.create(
+            name='Data Source 5',
+            type='git',
+            source_url='https://example.invalid/repo.git',
+            parameters={'username': 'sync-test', 'password': 'dummy-password'},
+        )
+        forbidden = DataSource.objects.get(name='Data Source 2')
+        url = reverse('core-api:datasource-sync', kwargs={'pk': data_source.pk})
+        url_forbidden = reverse('core-api:datasource-sync', kwargs={'pk': forbidden.pk})
+
+        # Attempt to sync without permission
+        with patch('core.api.views.SyncDataSourceJob') as sync_job:
+            response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        sync_job.enqueue.assert_not_called()
+
+        # Grant a constrained sync permission. Neither add nor view is granted.
+        obj_perm = ObjectPermission(name='Sync permission', constraints={'name': 'Data Source 5'}, actions=['sync'])
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(DataSource))
+
+        # Attempt to sync a non-permitted data source
+        with patch('core.api.views.SyncDataSourceJob') as sync_job:
+            response = self.client.post(url_forbidden, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        sync_job.enqueue.assert_not_called()
+
+        # Sync a permitted data source
+        with patch('core.api.views.SyncDataSourceJob') as sync_job:
+            response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        sync_job.enqueue.assert_called_once()
+        self.assertEqual(sorted(response.data), self.brief_fields)
+
+        # With view permission, the full representation is returned
+        self.add_permissions('core.view_datasource')
+        with patch('core.api.views.SyncDataSourceJob') as sync_job:
+            response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        sync_job.enqueue.assert_called_once()
+        self.assertEqual(response.data['parameters'], data_source.parameters)
+
+        # A read-only token cannot sync
+        self.token.write_enabled = False
+        self.token.save()
+        with patch('core.api.views.SyncDataSourceJob') as sync_job:
+            response = self.client.post(url, **self.header)
+        self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
+        sync_job.enqueue.assert_not_called()
+
     def assert_only_source_1(self, data):
     def assert_only_source_1(self, data):
         """The JSON lookup returns exactly the source carrying the matching value."""
         """The JSON lookup returns exactly the source carrying the matching value."""
         ids = sorted(result['id'] for result in data['data_source_list'])
         ids = sorted(result['id'] for result in data['data_source_list'])