diff --git a/docs/apps/mdm.md b/docs/apps/mdm.md index b70710bc74..83044aee62 100644 --- a/docs/apps/mdm.md +++ b/docs/apps/mdm.md @@ -276,6 +276,146 @@ Content in ASM/ABM *Apps and Books > "AppName" > Manage Licenses* that is assign ## HTTP API +### `/api/mdm/dep/devices/` + + * method: `GET` + * required permission: `mdm.view_depdevice` + * available filters: + * `device_family` + * `enrollment` + * `profile_status` + * `profile_uuid` + * `serial_number` + * `virtual_server` + * available orderings: + * `created_at` + * `last_op_date` + * `updated_at` + * pagination: + * `limit` (max `500`, `50` by default) + * `offset` + +Use this endpoint to list the DEP devices. + +Example: + +```bash +curl -H "Authorization: Token $ZTL_API_TOKEN" \ + "https://$ZTL_FQDN/api/mdm/dep/devices/?last_op_type=added&ordering=-last_op_date" \ + | python3 -m json.tool +``` + +Response: + +```json +{ + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 14, + "virtual_server": 7, + "serial_number": "XXXXXXXXXXXX", + "asset_tag": "", + "color": "SPACE GRAY", + "description": "MBP 13.3 SPG/8C CPU/8C GPU", + "device_family": "Mac", + "model": "MacBook Pro 13\"", + "os": "OSX", + "device_assigned_by": "admin@example.com", + "device_assigned_date": "2024-12-02T16:52:49", + "last_op_type": "modified", + "last_op_date": "2024-12-02T16:52:49", + "profile_status": "pushed", + "profile_uuid": "464921fa-a370-4bad-9a6f-9e3a8a73d94a", + "profile_push_time": "2024-12-02T16:02:47", + "enrollment": 4, + "created_at": "2024-07-29T19:13:12.160287", + "updated_at": "2024-12-03T16:46:26.703479" + } + ] +} +``` + +### `/api/mdm/dep/devices//` + + * methods: `GET`, `PUT` + * required permission: `mdm.view_depdevice`, `mdm.change_depdevice` + +Use this endpoint to get a DEP device detail information and change its enrollment. + +Example to get the DEP device detail information: + +```bash +curl -H "Authorization: Token $ZTL_API_TOKEN" \ + https://$ZTL_FQDN/api/mdm/dep/devices/14/ \ + | python3 -m json.tool +``` + +Response: + +```json +{ + "id": 14, + "virtual_server": 7, + "serial_number": "XXXXXXXXXXXX", + "asset_tag": "", + "color": "SPACE GRAY", + "description": "MBP 13.3 SPG/8C CPU/8C GPU", + "device_family": "Mac", + "model": "MacBook Pro 13\"", + "os": "OSX", + "device_assigned_by": "admin@example.com", + "device_assigned_date": "2024-12-02T16:52:49", + "last_op_type": "modified", + "last_op_date": "2024-12-02T16:52:49", + "profile_status": "pushed", + "profile_uuid": "464921fa-a370-4bad-9a6f-9e3a8a73d94a", + "profile_push_time": "2024-12-02T16:02:47", + "enrollment": 4, + "created_at": "2024-07-29T19:13:12.160287", + "updated_at": "2024-12-03T16:46:26.703479" +} +``` + +Example to assign an enrollment/profile: + +```bash +curl -XPUT \ + -H "Authorization: Token $ZTL_API_TOKEN" \ + -H "Content-Type: application/json" -d '{"enrollment": 4}' \ + https://$ZTL_FQDN/api/mdm/dep/devices/14/ \ + | python3 -m json.tool +``` + +Response: + +```json +{ + "id": 14, + "virtual_server": 7, + "serial_number": "XXXXXXXXXXXX", + "asset_tag": "", + "color": "SPACE GRAY", + "description": "MBP 13.3 SPG/8C CPU/8C GPU", + "device_family": "Mac", + "model": "MacBook Pro 13\"", + "os": "OSX", + "device_assigned_by": "admin@example.com", + "device_assigned_date": "2024-12-02T16:52:49", + "last_op_type": "modified", + "last_op_date": "2024-12-02T16:52:49", + "profile_status": "pushed", + "profile_uuid": "464921fa-a370-4bad-9a6f-9e3a8a73d94a", + "profile_push_time": "2024-12-02T16:02:47", + "enrollment": 4, + "created_at": "2024-07-29T19:13:12.160287", + "updated_at": "2024-12-03T16:46:26.703479" +} +``` + + ### `/api/mdm/dep/virtual_servers//sync_devices/` * method: `POST` diff --git a/tests/mdm/test_api_dep_views.py b/tests/mdm/test_api_dep_views.py index e28e28817d..170325d89d 100644 --- a/tests/mdm/test_api_dep_views.py +++ b/tests/mdm/test_api_dep_views.py @@ -1,16 +1,22 @@ from functools import reduce import operator +from unittest.mock import patch +from urllib.parse import urlencode from django.contrib.auth.models import Group, Permission from django.db.models import Q from django.urls import reverse from django.utils.crypto import get_random_string from django.test import TestCase, override_settings from accounts.models import APIToken, User -from .utils import force_dep_virtual_server +from zentral.contrib.inventory.models import MetaBusinessUnit +from zentral.contrib.mdm.dep_client import DEPClientError +from .utils import force_dep_device, force_dep_enrollment, force_dep_virtual_server @override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') class APIViewsTestCase(TestCase): + maxDiff = None + @classmethod def setUpTestData(cls): cls.service_account = User.objects.create( @@ -23,6 +29,8 @@ def setUpTestData(cls): cls.service_account.groups.set([cls.group]) cls.user.groups.set([cls.group]) cls.api_key = APIToken.objects.update_or_create_for_user(cls.service_account) + cls.mbu = MetaBusinessUnit.objects.create(name=get_random_string(12)) + cls.mbu.create_enrollment_business_unit() # utility methods @@ -47,11 +55,23 @@ def login_redirect(self, url): response = self.client.get(url) self.assertRedirects(response, "{u}?next={n}".format(u=reverse("login"), n=url)) - def post(self, url, include_token=True): + def _make_query(self, verb, url, data=None, include_token=True): kwargs = {} + if data is not None: + kwargs["content_type"] = "application/json" + kwargs["data"] = data if include_token: kwargs["HTTP_AUTHORIZATION"] = f"Token {self.api_key}" - return self.client.post(url, **kwargs) + return getattr(self.client, verb)(url, **kwargs) + + def get(self, url, include_token=True): + return self._make_query("get", url, include_token=include_token) + + def post(self, url, include_token=True): + return self._make_query("post", url, include_token=include_token) + + def put(self, url, data=None, include_token=True): + return self._make_query("put", url, data=data, include_token=include_token) # dep_virtual_server_sync_devices @@ -90,3 +110,259 @@ def test_user_dep_virtual_server_sync_devices(self): response = self.client.post(reverse("mdm_api:dep_virtual_server_sync_devices", args=(dep_server.pk,))) self.assertEqual(response.status_code, 201) self.assertEqual(sorted(response.json().keys()), ['task_id', 'task_result_url']) + + # list dep devices + + def test_list_dep_devices_unauthorized(self): + response = self.get(reverse("mdm_api:dep_devices"), include_token=False) + self.assertEqual(response.status_code, 401) + + def test_list_dep_devices_permission_denied(self): + response = self.get(reverse("mdm_api:dep_devices")) + self.assertEqual(response.status_code, 403) + + def test_list_dep_devices_by_enrollment(self): + self.set_permissions("mdm.view_depdevice") + force_dep_device() # filtered out + dep_device = force_dep_device() + dep_device.enrollment = force_dep_enrollment(self.mbu) + dep_device.save() + response = self.get(reverse("mdm_api:dep_devices") + + "?" + urlencode({"enrollment": dep_device.enrollment.pk})) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {'count': 1, + 'next': None, + 'previous': None, + 'results': [ + {'asset_tag': dep_device.asset_tag, + 'color': 'SPACE GRAY', + 'created_at': dep_device.created_at.isoformat(), + 'description': 'IPHONE X SPACE GRAY 64GB-ZDD', + 'device_assigned_by': 'support@zentral.com', + 'device_assigned_date': dep_device.device_assigned_date.isoformat(), + 'device_family': 'iPhone', + 'enrollment': dep_device.enrollment.pk, + 'id': dep_device.pk, + 'last_op_date': dep_device.last_op_date.isoformat(), + 'last_op_type': 'added', + 'model': 'iPhone X', + 'os': 'iOS', + 'profile_push_time': None, + 'profile_status': 'empty', + 'profile_uuid': None, + 'serial_number': dep_device.serial_number, + 'updated_at': dep_device.updated_at.isoformat(), + 'virtual_server': dep_device.virtual_server.pk} + ]} + ) + + def test_list_dep_devices_by_serial_number(self): + self.set_permissions("mdm.view_depdevice") + force_dep_device() # filtered out + dep_device = force_dep_device() + response = self.get(reverse("mdm_api:dep_devices") + + "?" + urlencode({"serial_number": dep_device.serial_number})) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {'count': 1, + 'next': None, + 'previous': None, + 'results': [ + {'asset_tag': dep_device.asset_tag, + 'color': 'SPACE GRAY', + 'created_at': dep_device.created_at.isoformat(), + 'description': 'IPHONE X SPACE GRAY 64GB-ZDD', + 'device_assigned_by': 'support@zentral.com', + 'device_assigned_date': dep_device.device_assigned_date.isoformat(), + 'device_family': 'iPhone', + 'enrollment': None, + 'id': dep_device.pk, + 'last_op_date': dep_device.last_op_date.isoformat(), + 'last_op_type': 'added', + 'model': 'iPhone X', + 'os': 'iOS', + 'profile_push_time': None, + 'profile_status': 'empty', + 'profile_uuid': None, + 'serial_number': dep_device.serial_number, + 'updated_at': dep_device.updated_at.isoformat(), + 'virtual_server': dep_device.virtual_server.pk} + ]} + ) + + def test_list_dep_devices_by_virtual_server(self): + self.set_permissions("mdm.view_depdevice") + force_dep_device() # filtered out + dep_device = force_dep_device() + response = self.get(reverse("mdm_api:dep_devices") + + "?" + urlencode({"virtual_server": dep_device.virtual_server.pk})) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {'count': 1, + 'next': None, + 'previous': None, + 'results': [ + {'asset_tag': dep_device.asset_tag, + 'color': 'SPACE GRAY', + 'created_at': dep_device.created_at.isoformat(), + 'description': 'IPHONE X SPACE GRAY 64GB-ZDD', + 'device_assigned_by': 'support@zentral.com', + 'device_assigned_date': dep_device.device_assigned_date.isoformat(), + 'device_family': 'iPhone', + 'enrollment': None, + 'id': dep_device.pk, + 'last_op_date': dep_device.last_op_date.isoformat(), + 'last_op_type': 'added', + 'model': 'iPhone X', + 'os': 'iOS', + 'profile_push_time': None, + 'profile_status': 'empty', + 'profile_uuid': None, + 'serial_number': dep_device.serial_number, + 'updated_at': dep_device.updated_at.isoformat(), + 'virtual_server': dep_device.virtual_server.pk} + ]} + ) + + def test_list_dep_devices_ordering(self): + self.set_permissions("mdm.view_depdevice") + force_dep_device() # filtered out + dep_device = force_dep_device() + force_dep_device() # filtered out + response = self.get(reverse("mdm_api:dep_devices") + + "?" + urlencode({"ordering": "-created_at", + "limit": 1, + "offset": 1})) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {'count': 3, + 'next': 'http://testserver/api/mdm/dep/devices/?limit=1&offset=2&ordering=-created_at', + 'previous': 'http://testserver/api/mdm/dep/devices/?limit=1&ordering=-created_at', + 'results': [ + {'asset_tag': dep_device.asset_tag, + 'color': 'SPACE GRAY', + 'created_at': dep_device.created_at.isoformat(), + 'description': 'IPHONE X SPACE GRAY 64GB-ZDD', + 'device_assigned_by': 'support@zentral.com', + 'device_assigned_date': dep_device.device_assigned_date.isoformat(), + 'device_family': 'iPhone', + 'enrollment': None, + 'id': dep_device.pk, + 'last_op_date': dep_device.last_op_date.isoformat(), + 'last_op_type': 'added', + 'model': 'iPhone X', + 'os': 'iOS', + 'profile_push_time': None, + 'profile_status': 'empty', + 'profile_uuid': None, + 'serial_number': dep_device.serial_number, + 'updated_at': dep_device.updated_at.isoformat(), + 'virtual_server': dep_device.virtual_server.pk} + ]} + ) + + # get dep device + + def test_get_dep_device_unauthorized(self): + dep_device = force_dep_device() + response = self.get(reverse("mdm_api:dep_device", args=(dep_device.pk,)), include_token=False) + self.assertEqual(response.status_code, 401) + + def test_get_dep_device_permission_denied(self): + dep_device = force_dep_device() + response = self.get(reverse("mdm_api:dep_device", args=(dep_device.pk,))) + self.assertEqual(response.status_code, 403) + + def test_get_dep_device(self): + dep_device = force_dep_device() + self.set_permissions("mdm.view_depdevice") + response = self.get(reverse("mdm_api:dep_device", args=(dep_device.pk,))) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {'asset_tag': dep_device.asset_tag, + 'color': 'SPACE GRAY', + 'created_at': dep_device.created_at.isoformat(), + 'description': 'IPHONE X SPACE GRAY 64GB-ZDD', + 'device_assigned_by': 'support@zentral.com', + 'device_assigned_date': dep_device.device_assigned_date.isoformat(), + 'device_family': 'iPhone', + 'enrollment': None, + 'id': dep_device.pk, + 'last_op_date': dep_device.last_op_date.isoformat(), + 'last_op_type': 'added', + 'model': 'iPhone X', + 'os': 'iOS', + 'profile_push_time': None, + 'profile_status': 'empty', + 'profile_uuid': None, + 'serial_number': dep_device.serial_number, + 'updated_at': dep_device.updated_at.isoformat(), + 'virtual_server': dep_device.virtual_server.pk} + ) + + # update dep device + + def test_update_dep_device_unauthorized(self): + dep_device = force_dep_device() + response = self.put(reverse("mdm_api:dep_device", args=(dep_device.pk,)), include_token=False) + self.assertEqual(response.status_code, 401) + + def test_update_dep_device_permission_denied(self): + dep_device = force_dep_device() + response = self.put(reverse("mdm_api:dep_device", args=(dep_device.pk,))) + self.assertEqual(response.status_code, 403) + + @patch("zentral.contrib.mdm.serializers.assign_dep_device_profile") + def test_update_dep_device(self, assign_dep_device_profile): + dep_device = force_dep_device() + enrollment = force_dep_enrollment(self.mbu) + self.set_permissions("mdm.change_depdevice") + response = self.put(reverse("mdm_api:dep_device", args=(dep_device.pk,)), + data={"enrollment": enrollment.pk}) + self.assertEqual(response.status_code, 200) + dep_device.refresh_from_db() + self.assertEqual( + response.json(), + {'asset_tag': dep_device.asset_tag, + 'color': 'SPACE GRAY', + 'created_at': dep_device.created_at.isoformat(), + 'description': 'IPHONE X SPACE GRAY 64GB-ZDD', + 'device_assigned_by': 'support@zentral.com', + 'device_assigned_date': dep_device.device_assigned_date.isoformat(), + 'device_family': 'iPhone', + 'enrollment': enrollment.pk, + 'id': dep_device.pk, + 'last_op_date': dep_device.last_op_date.isoformat(), + 'last_op_type': 'added', + 'model': 'iPhone X', + 'os': 'iOS', + 'profile_push_time': None, + 'profile_status': 'empty', + 'profile_uuid': None, + 'serial_number': dep_device.serial_number, + 'updated_at': dep_device.updated_at.isoformat(), + 'virtual_server': dep_device.virtual_server.pk} + ) + assign_dep_device_profile.assert_called_once_with(dep_device, enrollment) + + @patch("zentral.contrib.mdm.serializers.assign_dep_device_profile") + def test_update_dep_device_error(self, assign_dep_device_profile): + assign_dep_device_profile.side_effect = DEPClientError("YOLO") + dep_device = force_dep_device() + enrollment = force_dep_enrollment(self.mbu) + self.set_permissions("mdm.change_depdevice") + response = self.put(reverse("mdm_api:dep_device", args=(dep_device.pk,)), + data={"enrollment": enrollment.pk}) + self.assertEqual(response.status_code, 400) + dep_device.refresh_from_db() + self.assertEqual( + response.json(), + {'enrollment': 'Could not assign enrollment to device'}, + ) + assign_dep_device_profile.assert_called_once_with(dep_device, enrollment) diff --git a/zentral/contrib/mdm/api_urls.py b/zentral/contrib/mdm/api_urls.py index 4b6bfea9c1..cd69b74182 100644 --- a/zentral/contrib/mdm/api_urls.py +++ b/zentral/contrib/mdm/api_urls.py @@ -3,6 +3,7 @@ from .api_views import (ArtifactDetail, ArtifactList, BlueprintDetail, BlueprintList, BlueprintArtifactDetail, BlueprintArtifactList, + DEPDeviceDetail, DEPDeviceList, EnterpriseAppDetail, EnterpriseAppList, EnrolledDeviceList, LocationList, LocationAssetList, @@ -52,6 +53,8 @@ path('dep/virtual_servers//sync_devices/', DEPVirtualServerSyncDevicesView.as_view(), name="dep_virtual_server_sync_devices"), + path('dep/devices/', DEPDeviceList.as_view(), name="dep_devices"), + path('dep/devices//', DEPDeviceDetail.as_view(), name="dep_device"), path('devices/', EnrolledDeviceList.as_view(), name="enrolled_devices"), path('devices//block/', BlockEnrolledDevice.as_view(), name="block_enrolled_device"), path('devices//unblock/', UnblockEnrolledDevice.as_view(), name="unblock_enrolled_device"), diff --git a/zentral/contrib/mdm/api_views/dep.py b/zentral/contrib/mdm/api_views/dep.py index b06cecd623..44cb8f52cc 100644 --- a/zentral/contrib/mdm/api_views/dep.py +++ b/zentral/contrib/mdm/api_views/dep.py @@ -1,13 +1,18 @@ from django.shortcuts import get_object_or_404 from django.urls import reverse +from django_filters import rest_framework as filters from rest_framework import status from rest_framework.authentication import SessionAuthentication +from rest_framework.filters import OrderingFilter +from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView +from rest_framework.pagination import LimitOffsetPagination from rest_framework.views import APIView from rest_framework.response import Response from accounts.api_authentication import APITokenAuthentication -from zentral.contrib.mdm.models import DEPVirtualServer +from zentral.contrib.mdm.models import DEPDevice, DEPVirtualServer from zentral.contrib.mdm.tasks import sync_dep_virtual_server_devices_task -from zentral.utils.drf import DjangoPermissionRequired +from zentral.contrib.mdm.serializers import DEPDeviceSerializer +from zentral.utils.drf import DefaultDjangoModelPermissions, DjangoPermissionRequired class DEPVirtualServerSyncDevicesView(APIView): @@ -21,3 +26,29 @@ def post(self, request, *args, **kwargs): return Response({"task_id": result.id, "task_result_url": reverse("base_api:task_result", args=(result.id,))}, status=status.HTTP_201_CREATED) + + +class MaxLimitOffsetPagination(LimitOffsetPagination): + default_limit = 50 + max_limit = 500 + + +class DEPDeviceList(ListAPIView): + queryset = DEPDevice.objects.all().order_by("-created_at") + serializer_class = DEPDeviceSerializer + permission_classes = [DefaultDjangoModelPermissions] + filter_backends = (filters.DjangoFilterBackend, OrderingFilter) + filterset_fields = ( + 'device_family', + 'enrollment', 'profile_status', 'profile_uuid', + 'serial_number', 'virtual_server' + ) + ordering_fields = ('created_at', 'last_op_date', 'updated_at') + ordering = ['-created_at'] + pagination_class = MaxLimitOffsetPagination + + +class DEPDeviceDetail(RetrieveUpdateAPIView): + queryset = DEPDevice.objects.all() + serializer_class = DEPDeviceSerializer + permission_classes = [DefaultDjangoModelPermissions] diff --git a/zentral/contrib/mdm/serializers.py b/zentral/contrib/mdm/serializers.py index 968029239d..ebd57f157f 100644 --- a/zentral/contrib/mdm/serializers.py +++ b/zentral/contrib/mdm/serializers.py @@ -1,4 +1,5 @@ import base64 +import logging import os from django.core.files import File from django.db import transaction @@ -10,8 +11,10 @@ from .app_manifest import download_package, read_package_info, validate_configuration from .artifacts import update_blueprint_serialized_artifacts from .crypto import generate_push_certificate_key_bytes, load_push_certificate_and_key +from .dep import assign_dep_device_profile, DEPClientError from .models import (Artifact, ArtifactVersion, ArtifactVersionTag, Blueprint, BlueprintArtifact, BlueprintArtifactTag, + DEPDevice, DEPEnrollment, DeviceCommand, EnrolledDevice, EnterpriseApp, FileVaultConfig, Location, LocationAsset, @@ -25,6 +28,9 @@ from .scep.static import StaticChallengeSerializer +logger = logging.getLogger("zentral.contrib.mdm.serializers") + + class DeviceCommandSerializer(serializers.ModelSerializer): class Meta: model = DeviceCommand @@ -113,6 +119,45 @@ def validate(self, data): return data +class DEPDeviceSerializer(serializers.ModelSerializer): + class Meta: + model = DEPDevice + fields = [ + "id", + "virtual_server", "serial_number", + "asset_tag", "color", + "description", "device_family", + "model", "os", + "device_assigned_by", "device_assigned_date", + "last_op_type", "last_op_date", + "profile_status", "profile_uuid", "profile_push_time", + "enrollment", + "created_at", "updated_at", + ] + read_only_fields = [ + "id", + "virtual_server", "serial_number", + "asset_tag", "color", + "description", "device_family", + "model", "os", + "device_assigned_by", "device_assigned_date", + "last_op_type", "last_op_date", + "profile_status", "profile_uuid", "profile_push_time", + "created_at", "updated_at", + ] + + def update(self, instance, validated_data): + enrollment = validated_data.pop("enrollment") + try: + assign_dep_device_profile(instance, enrollment) + except DEPClientError: + logger.exception("Could not assign enrollment to device") + raise serializers.ValidationError({"enrollment": "Could not assign enrollment to device"}) + else: + instance.enrollment = enrollment + return super().update(instance, validated_data) + + class OTAEnrollmentSerializer(serializers.ModelSerializer): enrollment_secret = EnrollmentSecretSerializer(many=False)