diff --git a/tests/mdm/test_api_blueprints_views.py b/tests/mdm/test_api_blueprints_views.py index 542f8ea5a9..b7e51906a2 100644 --- a/tests/mdm/test_api_blueprints_views.py +++ b/tests/mdm/test_api_blueprints_views.py @@ -9,7 +9,7 @@ from accounts.models import APIToken, User from zentral.contrib.mdm.models import Blueprint from zentral.core.events.base import AuditEvent -from .utils import force_blueprint, force_blueprint_artifact, force_filevault_config +from .utils import force_blueprint, force_blueprint_artifact, force_filevault_config, force_recovery_password_config @override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') @@ -97,6 +97,7 @@ def test_list_blueprints(self): 'collect_certificates': 0, 'collect_profiles': 0, 'filevault_config': None, + 'recovery_password_config': None, 'created_at': blueprint.created_at.isoformat(), 'updated_at': blueprint.updated_at.isoformat()}] ) @@ -104,7 +105,9 @@ def test_list_blueprints(self): def test_list_blueprints_name_filter(self): force_blueprint() filevault_config = force_filevault_config() - blueprint = force_blueprint(filevault_config=filevault_config) + recovery_password_config = force_recovery_password_config() + blueprint = force_blueprint(filevault_config=filevault_config, + recovery_password_config=recovery_password_config) self.set_permissions("mdm.view_blueprint") response = self.get(reverse("mdm_api:blueprints"), data={"name": blueprint.name}) self.assertEqual(response.status_code, 200) @@ -117,6 +120,7 @@ def test_list_blueprints_name_filter(self): 'collect_certificates': 0, 'collect_profiles': 0, 'filevault_config': filevault_config.pk, + 'recovery_password_config': recovery_password_config.pk, 'created_at': blueprint.created_at.isoformat(), 'updated_at': blueprint.updated_at.isoformat()}] ) @@ -148,6 +152,7 @@ def test_get_blueprint(self): 'collect_certificates': 0, 'collect_profiles': 0, 'filevault_config': None, + 'recovery_password_config': None, 'created_at': blueprint.created_at.isoformat(), 'updated_at': blueprint.updated_at.isoformat()} ) @@ -184,6 +189,7 @@ def test_create_blueprint(self, post_event): 'collect_certificates': 0, 'collect_profiles': 0, 'filevault_config': None, + 'recovery_password_config': None, 'created_at': blueprint.created_at.isoformat(), 'updated_at': blueprint.updated_at.isoformat()} ) @@ -230,6 +236,7 @@ def test_update_blueprint_permission_denied(self): def test_update_blueprint(self, post_event): blueprint = force_blueprint() filevault_config = force_filevault_config() + recovery_password_config = force_recovery_password_config() prev_value = blueprint.serialize_for_event() self.set_permissions("mdm.change_blueprint") new_name = get_random_string(12) @@ -240,7 +247,8 @@ def test_update_blueprint(self, post_event): "collect_apps": 1, "collect_certificates": 2, "collect_profiles": 2, - "filevault_config": filevault_config.pk}) + "filevault_config": filevault_config.pk, + "recovery_password_config": recovery_password_config.pk}) self.assertEqual(response.status_code, 200) self.assertEqual(len(callbacks), 1) blueprint.refresh_from_db() @@ -249,6 +257,8 @@ def test_update_blueprint(self, post_event): self.assertEqual(blueprint.collect_apps, 1) self.assertEqual(blueprint.collect_certificates, 2) self.assertEqual(blueprint.collect_profiles, 2) + self.assertEqual(blueprint.filevault_config, filevault_config) + self.assertEqual(blueprint.recovery_password_config, recovery_password_config) self.assertEqual( response.json(), {'id': blueprint.pk, @@ -258,6 +268,7 @@ def test_update_blueprint(self, post_event): 'collect_certificates': 2, 'collect_profiles': 2, 'filevault_config': filevault_config.pk, + 'recovery_password_config': recovery_password_config.pk, 'created_at': blueprint.created_at.isoformat(), 'updated_at': blueprint.updated_at.isoformat()} ) @@ -277,6 +288,8 @@ def test_update_blueprint(self, post_event): "collect_certificates": 'ALL', "collect_profiles": 'ALL', "filevault_config": {"name": filevault_config.name, "pk": filevault_config.pk}, + "recovery_password_config": {"name": recovery_password_config.name, + "pk": recovery_password_config.pk}, "created_at": blueprint.created_at, "updated_at": blueprint.updated_at }, diff --git a/tests/mdm/test_api_enrolled_devices_views.py b/tests/mdm/test_api_enrolled_devices_views.py new file mode 100644 index 0000000000..4fdd82aa8b --- /dev/null +++ b/tests/mdm/test_api_enrolled_devices_views.py @@ -0,0 +1,142 @@ +from functools import reduce +import operator +from unittest.mock import patch +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 zentral.contrib.inventory.models import MetaBusinessUnit +from zentral.contrib.mdm.events import FileVaultPRKViewedEvent, RecoveryPasswordViewedEvent +from .utils import force_dep_enrollment_session + + +@override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') +class APIViewsTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.service_account = User.objects.create( + username=get_random_string(12), + email="{}@zentral.io".format(get_random_string(12)), + is_service_account=True + ) + cls.user = User.objects.create_user("godzilla", "godzilla@zentral.io", get_random_string(12)) + cls.group = Group.objects.create(name=get_random_string(12)) + 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.dep_enrollment_session, _, _ = force_dep_enrollment_session( + cls.mbu, authenticated=True, completed=True + ) + cls.enrolled_device = cls.dep_enrollment_session.enrolled_device + + # utility methods + + def set_permissions(self, *permissions): + if permissions: + permission_filter = reduce(operator.or_, ( + Q(content_type__app_label=app_label, codename=codename) + for app_label, codename in ( + permission.split(".") + for permission in permissions + ) + )) + self.group.permissions.set(list(Permission.objects.filter(permission_filter))) + else: + self.group.permissions.clear() + + def login(self, *permissions): + self.set_permissions(*permissions) + self.client.force_login(self.user) + + def login_redirect(self, url): + response = self.client.get(url) + self.assertRedirects(response, "{u}?next={n}".format(u=reverse("login"), n=url)) + + def get(self, url, include_token=True): + kwargs = {} + if include_token: + kwargs["HTTP_AUTHORIZATION"] = f"Token {self.api_key}" + return self.client.get(url, **kwargs) + + # enrolled_device_filevault_prk + + def test_enrolled_device_filevault_prk_unauthorized(self): + response = self.get(reverse("mdm_api:enrolled_device_filevault_prk", args=(self.enrolled_device.pk,)), + include_token=False) + self.assertEqual(response.status_code, 401) + + def test_enrolled_device_filevault_prk_permission_denied(self): + response = self.get(reverse("mdm_api:enrolled_device_filevault_prk", args=(self.enrolled_device.pk,))) + self.assertEqual(response.status_code, 403) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_enrolled_device_filevault_prk_null(self, post_event): + self.set_permissions("mdm.view_filevault_prk") + response = self.get(reverse("mdm_api:enrolled_device_filevault_prk", args=(self.enrolled_device.pk,))) + self.assertEqual( + response.json(), + {"id": self.enrolled_device.pk, + "serial_number": self.enrolled_device.serial_number, + "filevault_prk": None} + ) + post_event.assert_not_called() + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_enrolled_device_filevault_prk(self, post_event): + self.enrolled_device.set_filevault_prk("123456") + self.enrolled_device.save() + self.set_permissions("mdm.view_filevault_prk") + response = self.get(reverse("mdm_api:enrolled_device_filevault_prk", args=(self.enrolled_device.pk,))) + self.assertEqual( + response.json(), + {"id": self.enrolled_device.pk, + "serial_number": self.enrolled_device.serial_number, + "filevault_prk": "123456"} + ) + self.assertEqual(len(post_event.call_args_list), 1) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, FileVaultPRKViewedEvent) + self.assertEqual(event.metadata.machine_serial_number, self.enrolled_device.serial_number) + + # enrolled_device_filevault_prk + + def test_enrolled_device_recovery_password_unauthorized(self): + response = self.get(reverse("mdm_api:enrolled_device_recovery_password", args=(self.enrolled_device.pk,)), + include_token=False) + self.assertEqual(response.status_code, 401) + + def test_enrolled_device_recovery_password_permission_denied(self): + response = self.get(reverse("mdm_api:enrolled_device_recovery_password", args=(self.enrolled_device.pk,))) + self.assertEqual(response.status_code, 403) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_enrolled_device_recovery_password_null(self, post_event): + self.set_permissions("mdm.view_recovery_password") + response = self.get(reverse("mdm_api:enrolled_device_recovery_password", args=(self.enrolled_device.pk,))) + self.assertEqual( + response.json(), + {"id": self.enrolled_device.pk, + "serial_number": self.enrolled_device.serial_number, + "recovery_password": None} + ) + post_event.assert_not_called() + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_enrolled_device_recovery_password(self, post_event): + self.enrolled_device.set_recovery_password("123456") + self.enrolled_device.save() + self.set_permissions("mdm.view_recovery_password") + response = self.get(reverse("mdm_api:enrolled_device_recovery_password", args=(self.enrolled_device.pk,))) + self.assertEqual( + response.json(), + {"id": self.enrolled_device.pk, + "serial_number": self.enrolled_device.serial_number, + "recovery_password": "123456"} + ) + self.assertEqual(len(post_event.call_args_list), 1) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, RecoveryPasswordViewedEvent) + self.assertEqual(event.metadata.machine_serial_number, self.enrolled_device.serial_number) diff --git a/tests/mdm/test_api_recovery_password_configs_views.py b/tests/mdm/test_api_recovery_password_configs_views.py new file mode 100644 index 0000000000..6735ac6504 --- /dev/null +++ b/tests/mdm/test_api_recovery_password_configs_views.py @@ -0,0 +1,349 @@ +from functools import reduce +import operator +from unittest.mock import patch +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 zentral.contrib.mdm.models import RecoveryPasswordConfig +from zentral.core.events.base import AuditEvent +from .utils import force_blueprint, force_recovery_password_config + + +@override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') +class MDMRecoveryPasswordConfigsAPIViewsTestCase(TestCase): + maxDiff = None + + @classmethod + def setUpTestData(cls): + cls.service_account = User.objects.create( + username=get_random_string(12), + email="{}@zentral.io".format(get_random_string(12)), + is_service_account=True + ) + cls.user = User.objects.create_user("godzilla", "godzilla@zentral.io", get_random_string(12)) + cls.group = Group.objects.create(name=get_random_string(12)) + 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) + + # utility methods + + def set_permissions(self, *permissions): + if permissions: + permission_filter = reduce(operator.or_, ( + Q(content_type__app_label=app_label, codename=codename) + for app_label, codename in ( + permission.split(".") + for permission in permissions + ) + )) + self.group.permissions.set(list(Permission.objects.filter(permission_filter))) + else: + self.group.permissions.clear() + + def login(self, *permissions): + self.set_permissions(*permissions) + self.client.force_login(self.user) + + def login_redirect(self, url): + response = self.client.get(url) + self.assertRedirects(response, "{u}?next={n}".format(u=reverse("login"), n=url)) + + def _make_request(self, method, 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 method(url, **kwargs) + + def delete(self, *args, **kwargs): + return self._make_request(self.client.delete, *args, **kwargs) + + def get(self, *args, **kwargs): + return self._make_request(self.client.get, *args, **kwargs) + + def post(self, *args, **kwargs): + return self._make_request(self.client.post, *args, **kwargs) + + def put(self, *args, **kwargs): + return self._make_request(self.client.put, *args, **kwargs) + + # list recovery password configs + + def test_list_recovery_password_configs_unauthorized(self): + response = self.get(reverse("mdm_api:recovery_password_configs"), include_token=False) + self.assertEqual(response.status_code, 401) + + def test_list_recovery_password_configs_permission_denied(self): + response = self.get(reverse("mdm_api:recovery_password_configs")) + self.assertEqual(response.status_code, 403) + + def test_list_recovery_password_configs(self): + rp_config = force_recovery_password_config() + self.set_permissions("mdm.view_recoverypasswordconfig") + response = self.get(reverse("mdm_api:recovery_password_configs")) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + [{'id': rp_config.pk, + 'name': rp_config.name, + 'dynamic_password': rp_config.dynamic_password, + 'static_password': rp_config.static_password, + 'rotation_interval_days': 0, + 'rotate_firmware_password': False, + 'created_at': rp_config.created_at.isoformat(), + 'updated_at': rp_config.updated_at.isoformat()}] + ) + + def test_list_recovery_password_configs_name_filter(self): + force_recovery_password_config() + static_password = get_random_string(12) + rp_config = force_recovery_password_config(static_password=static_password) + self.set_permissions("mdm.view_recoverypasswordconfig") + response = self.get(reverse("mdm_api:recovery_password_configs"), data={"name": rp_config.name}) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + [{'id': rp_config.pk, + 'name': rp_config.name, + 'dynamic_password': rp_config.dynamic_password, + 'static_password': static_password, + 'rotation_interval_days': 0, + 'rotate_firmware_password': False, + 'created_at': rp_config.created_at.isoformat(), + 'updated_at': rp_config.updated_at.isoformat()}] + ) + + # get recovery password config + + def test_get_recovery_password_config_unauthorized(self): + rp_config = force_recovery_password_config() + response = self.get(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), include_token=False) + self.assertEqual(response.status_code, 401) + + def test_get_recovery_password_config_permission_denied(self): + rp_config = force_recovery_password_config() + response = self.get(reverse("mdm_api:blueprint", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 403) + + def test_get_recovery_password_config(self): + force_recovery_password_config() + static_password = get_random_string(12) + rp_config = force_recovery_password_config(rotation_interval_days=17, static_password=static_password) + self.set_permissions("mdm.view_recoverypasswordconfig") + response = self.get(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + {'id': rp_config.pk, + 'name': rp_config.name, + 'dynamic_password': rp_config.dynamic_password, + 'static_password': static_password, + 'rotation_interval_days': 17, + 'rotate_firmware_password': False, + 'created_at': rp_config.created_at.isoformat(), + 'updated_at': rp_config.updated_at.isoformat()} + ) + + # create recovery password config + + def test_create_recovery_password_config_unauthorized(self): + response = self.post(reverse("mdm_api:recovery_password_configs"), + {"name": get_random_string(12)}, + include_token=False) + self.assertEqual(response.status_code, 401) + + def test_create_recovery_password_config_permission_denied(self): + response = self.post(reverse("mdm_api:recovery_password_configs"), + {"name": get_random_string(12)}) + self.assertEqual(response.status_code, 403) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_create_recovery_password_config(self, post_event): + self.set_permissions("mdm.add_recoverypasswordconfig") + name = get_random_string(12) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.post(reverse("mdm_api:recovery_password_configs"), + {"name": name}) + self.assertEqual(response.status_code, 201) + self.assertEqual(len(callbacks), 1) + rp_config = RecoveryPasswordConfig.objects.get(name=name) + self.assertEqual( + response.json(), + {'id': rp_config.pk, + 'name': rp_config.name, + 'dynamic_password': True, + 'static_password': None, + 'rotation_interval_days': 0, + 'rotate_firmware_password': False, + 'created_at': rp_config.created_at.isoformat(), + 'updated_at': rp_config.updated_at.isoformat()} + ) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, AuditEvent) + self.assertEqual( + event.payload, + {"action": "created", + "object": { + "model": "mdm.recoverypasswordconfig", + "pk": str(rp_config.pk), + "new_value": { + "pk": rp_config.pk, + "name": name, + "dynamic_password": True, + "rotation_interval_days": 0, + "rotate_firmware_password": False, + "created_at": rp_config.created_at, + "updated_at": rp_config.updated_at + } + }} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["objects"], {"mdm_recovery_password_config": [str(rp_config.pk)]}) + self.assertEqual(sorted(metadata["tags"]), ["mdm", "zentral"]) + + # update Recovery password config + + def test_update_recovery_password_config_unauthorized(self): + rp_config = force_recovery_password_config() + response = self.put(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), + {"name": get_random_string(12)}, + include_token=False) + self.assertEqual(response.status_code, 401) + + def test_update_recovery_password_config_permission_denied(self): + rp_config = force_recovery_password_config() + response = self.put(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), + {"name": get_random_string(12)}) + self.assertEqual(response.status_code, 403) + + def test_update_recovery_password_dynamic_and_static_password_error(self): + rp_config = force_recovery_password_config() + self.set_permissions("mdm.change_recoverypasswordconfig") + static_password = get_random_string(12) + response = self.put(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), + {"name": get_random_string(12), + "dynamic_password": True, + "static_password": static_password, + "rotation_interval_days": 17, + "rotate_firmware_password": True}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'static_password': ['Cannot be set when dynamic_password is true']}) + + def test_update_recovery_password_required_static_password_error(self): + rp_config = force_recovery_password_config() + self.set_permissions("mdm.change_recoverypasswordconfig") + response = self.put(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), + {"name": get_random_string(12), + "dynamic_password": False, + "rotation_interval_days": 17, + "rotate_firmware_password": True}) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {'static_password': ['Required when dynamic_password is false']}) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_update_recovery_password_config(self, post_event): + rp_config = force_recovery_password_config() + prev_value = rp_config.serialize_for_event() + self.set_permissions("mdm.change_recoverypasswordconfig") + new_name = get_random_string(12) + static_password = get_random_string(12) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.put(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), + {"name": new_name, + "dynamic_password": False, + "static_password": static_password, + "rotation_interval_days": 17, + "rotate_firmware_password": True}) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(callbacks), 1) + rp_config.refresh_from_db() + self.assertEqual(rp_config.name, new_name) + self.assertFalse(rp_config.dynamic_password) + self.assertEqual(rp_config.get_static_password(), static_password) + self.assertEqual(rp_config.rotation_interval_days, 17) + self.assertTrue(rp_config.rotate_firmware_password) + self.assertEqual( + response.json(), + {'id': rp_config.pk, + 'name': new_name, + 'dynamic_password': False, + 'static_password': static_password, + 'rotation_interval_days': 17, + 'rotate_firmware_password': True, + 'created_at': rp_config.created_at.isoformat(), + 'updated_at': rp_config.updated_at.isoformat()} + ) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, AuditEvent) + self.assertEqual( + event.payload, + {"action": "updated", + "object": { + "model": "mdm.recoverypasswordconfig", + "pk": str(rp_config.pk), + "new_value": { + "pk": rp_config.pk, + "name": new_name, + "dynamic_password": False, + "rotation_interval_days": 17, + "rotate_firmware_password": True, + "created_at": rp_config.created_at, + "updated_at": rp_config.updated_at + }, + "prev_value": prev_value + }} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["objects"], {"mdm_recovery_password_config": [str(rp_config.pk)]}) + self.assertEqual(sorted(metadata["tags"]), ["mdm", "zentral"]) + + # delete Recovery password config + + def test_delete_recovery_password_config_unauthorized(self): + rp_config = force_recovery_password_config() + response = self.delete(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,)), include_token=False) + self.assertEqual(response.status_code, 401) + + def test_delete_recovery_password_config_permission_denied(self): + rp_config = force_recovery_password_config() + response = self.delete(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 403) + + def test_delete_recovery_password_config_cannot_be_deleted(self): + rp_config = force_recovery_password_config() + force_blueprint(recovery_password_config=rp_config) + self.set_permissions("mdm.delete_recoverypasswordconfig") + response = self.delete(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), ["This recovery password configuration cannot be deleted"]) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_delete_recovery_password_config(self, post_event): + rp_config = force_recovery_password_config() + prev_value = rp_config.serialize_for_event() + self.set_permissions("mdm.delete_recoverypasswordconfig") + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.delete(reverse("mdm_api:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 204) + self.assertEqual(len(callbacks), 1) + self.assertEqual(RecoveryPasswordConfig.objects.filter(name=rp_config.name).count(), 0) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, AuditEvent) + self.assertEqual( + event.payload, + {"action": "deleted", + "object": { + "model": "mdm.recoverypasswordconfig", + "pk": str(rp_config.pk), + "prev_value": prev_value + }} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["objects"], {"mdm_recovery_password_config": [str(rp_config.pk)]}) + self.assertEqual(sorted(metadata["tags"]), ["mdm", "zentral"]) diff --git a/tests/mdm/test_management_enrolled_device.py b/tests/mdm/test_management_enrolled_device.py index 2948c29262..392556d8f8 100644 --- a/tests/mdm/test_management_enrolled_device.py +++ b/tests/mdm/test_management_enrolled_device.py @@ -676,6 +676,201 @@ def test_create_enrolled_device_security_info_command_ok(self): command = session.enrolled_device.commands.first() self.assertEqual(command.name, "SecurityInfo") + # create set recovery lock command + + def test_enrolled_device_no_perms_no_set_recovery_lock_command_link(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice") + response = self.client.get(reverse("mdm:enrolled_device", args=(session.enrolled_device.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_detail.html") + self.assertNotContains( + response, + reverse("mdm:create_enrolled_device_command", args=(session.enrolled_device.pk, "SetRecoveryLock")) + ) + + def test_enrolled_device_not_apple_silicon_no_set_recovery_lock_command_link(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + self.assertFalse(session.enrolled_device.apple_silicon) + self._login("mdm.view_enrolleddevice") + response = self.client.get(reverse("mdm:enrolled_device", args=(session.enrolled_device.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_detail.html") + self.assertNotContains( + response, + reverse("mdm:create_enrolled_device_command", args=(session.enrolled_device.pk, "SetRecoveryLock")) + ) + + def test_enrolled_device_set_recovery_lock_command_link(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.get(reverse("mdm:enrolled_device", args=(session.enrolled_device.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_detail.html") + self.assertContains( + response, + reverse("mdm:create_enrolled_device_command", args=(session.enrolled_device.pk, "SetRecoveryLock")) + ) + + def test_create_enrolled_device_set_recovery_lock_command_redirect(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login_redirect(reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock"))) + + def test_create_enrolled_device_set_recovery_lock_command_permission_denied(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice") + response = self.client.get( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")) + ) + self.assertEqual(response.status_code, 403) + + def test_create_enrolled_device_set_recovery_lock_command_get(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.add_devicecommand") + response = self.client.get( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")) + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_create_command.html") + + def test_create_enrolled_device_set_recovery_lock_command_pwd_too_short(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.post( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")), + {"new_password": "1234567"}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_create_command.html") + self.assertFormError( + response.context["form"], + "new_password", + "The password must be at least 8 characters long." + ) + + def test_create_enrolled_device_set_recovery_lock_command_pwd_too_long(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.post( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")), + {"new_password": 33 * "1"}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_create_command.html") + self.assertFormError( + response.context["form"], + "new_password", + "The password must be at most 32 characters long." + ) + + def test_create_enrolled_device_set_recovery_lock_command_pwd_non_ascii(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.post( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")), + {"new_password": 8 * "é"}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_create_command.html") + self.assertFormError( + response.context["form"], + "new_password", + "The characters in this value must consist of low-ASCII, printable characters (0x20 through 0x7E) " + "to ensure that all characters are enterable on the EFI login screen." + ) + + def test_create_enrolled_device_set_recovery_lock_command_pwd_clear_non_existing(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.post( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")), + {"new_password": ""}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_create_command.html") + self.assertFormError( + response.context["form"], + "new_password", + "No current recovery lock set: this field is required." + ) + + def test_create_enrolled_device_set_recovery_lock_command_ok(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.set_recovery_password("87654321") + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.post( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")), + {"new_password": "12345678"}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_detail.html") + self.assertContains(response, "Set recovery lock command successfully created") + db_cmd = session.enrolled_device.commands.first() + self.assertEqual(db_cmd.name, "SetRecoveryLock") + cmd = load_command(db_cmd) + self.assertEqual( + cmd.build_command(), + {"CurrentPassword": "87654321", + "NewPassword": "12345678"}, + ) + + def test_create_enrolled_device_set_recovery_lock_command_clear_ok(self): + session, _, _ = force_dep_enrollment_session(self.mbu, completed=True) + session.enrolled_device.set_recovery_password("87654321") + session.enrolled_device.apple_silicon = True + session.enrolled_device.save() + self._login("mdm.view_enrolleddevice", "mdm.add_devicecommand") + response = self.client.post( + reverse("mdm:create_enrolled_device_command", + args=(session.enrolled_device.pk, "SetRecoveryLock")), + {"new_password": ""}, + follow=True + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/enrolleddevice_detail.html") + self.assertContains(response, "Set recovery lock command successfully created") + db_cmd = session.enrolled_device.commands.first() + self.assertEqual(db_cmd.name, "SetRecoveryLock") + cmd = load_command(db_cmd) + self.assertEqual( + cmd.build_command(), + {"CurrentPassword": "87654321", + "NewPassword": ""}, + ) + # create rotate filevault key command def test_enrolled_device_no_rotate_filevault_key_command_link(self): diff --git a/tests/mdm/test_management_recovery_password_config.py b/tests/mdm/test_management_recovery_password_config.py new file mode 100644 index 0000000000..15b2bd8a8c --- /dev/null +++ b/tests/mdm/test_management_recovery_password_config.py @@ -0,0 +1,367 @@ +from functools import reduce +import operator +from unittest.mock import patch +from django.contrib.auth.models import Group, Permission +from django.db.models import Q +from django.test import TestCase, override_settings +from django.urls import reverse +from django.utils.crypto import get_random_string +from accounts.models import User +from zentral.core.events.base import AuditEvent +from .utils import force_blueprint, force_recovery_password_config + + +@override_settings(STATICFILES_STORAGE='django.contrib.staticfiles.storage.StaticFilesStorage') +class RecoveryPasswordConfigManagementViewsTestCase(TestCase): + maxDiff = None + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user("godzilla", "godzilla@zentral.io", get_random_string(12)) + cls.group = Group.objects.create(name=get_random_string(12)) + cls.user.groups.set([cls.group]) + + # utiliy methods + + def _login_redirect(self, url): + response = self.client.get(url) + self.assertRedirects(response, "{u}?next={n}".format(u=reverse("login"), n=url)) + + def _login(self, *permissions): + if permissions: + permission_filter = reduce(operator.or_, ( + Q(content_type__app_label=app_label, codename=codename) + for app_label, codename in ( + permission.split(".") + for permission in permissions + ) + )) + self.group.permissions.set(list(Permission.objects.filter(permission_filter))) + else: + self.group.permissions.clear() + self.client.force_login(self.user) + + # recovery password configurations + + def test_recovery_password_configurations_redirect(self): + self._login_redirect(reverse("mdm:recovery_password_configs")) + + def test_recovery_password_configurations_permission_denied(self): + self._login() + response = self.client.get(reverse("mdm:recovery_password_configs")) + self.assertEqual(response.status_code, 403) + + def test_recovery_password_configurations_no_links(self): + rp_config = force_recovery_password_config() + self._login("mdm.view_recoverypasswordconfig") + response = self.client.get(reverse("mdm:recovery_password_configs")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_list.html") + self.assertContains(response, rp_config.name) + self.assertNotContains(response, reverse("mdm:update_recovery_password_config", args=(rp_config.pk,))) + self.assertNotContains(response, reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + + def test_recovery_password_configurations_all_links(self): + rp_config1 = force_recovery_password_config() + force_blueprint(recovery_password_config=rp_config1) + rp_config2 = force_recovery_password_config() + self._login("mdm.view_recoverypasswordconfig", + "mdm.change_recoverypasswordconfig", + "mdm.delete_recoverypasswordconfig") + response = self.client.get(reverse("mdm:recovery_password_configs")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_list.html") + self.assertContains(response, rp_config1.name) + self.assertContains(response, rp_config2.name) + self.assertContains(response, reverse("mdm:update_recovery_password_config", args=(rp_config1.pk,))) + self.assertNotContains(response, reverse("mdm:delete_recovery_password_config", args=(rp_config1.pk,))) + self.assertContains(response, reverse("mdm:update_recovery_password_config", args=(rp_config2.pk,))) + self.assertContains(response, reverse("mdm:delete_recovery_password_config", args=(rp_config2.pk,))) + + # create recovery password configuration + + def test_create_recovery_password_configuration_redirect(self): + self._login_redirect(reverse("mdm:create_recovery_password_config")) + + def test_create_recovery_password_configuration_permission_denied(self): + self._login() + response = self.client.get(reverse("mdm:create_recovery_password_config")) + self.assertEqual(response.status_code, 403) + + def test_create_recovery_password_configuration_get(self): + self._login("mdm.add_recoverypasswordconfig") + response = self.client.get(reverse("mdm:create_recovery_password_config")) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_form.html") + self.assertContains(response, "Create recovery password configuration") + + def test_create_recovery_password_configuration_static_password_required_error(self): + self._login("mdm.add_recoverypasswordconfig") + response = self.client.post(reverse("mdm:create_recovery_password_config"), + {"name": get_random_string(12), + "dynamic_password": False, + "rotation_interval_days": 0, + "rotate_firmware_password": False}, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_form.html") + self.assertFormError(response.context["form"], "static_password", + 'This field is required when not using dynamic passwords.') + + def test_create_recovery_password_configuration_static_password_too_short_error(self): + self._login("mdm.add_recoverypasswordconfig") + response = self.client.post(reverse("mdm:create_recovery_password_config"), + {"name": get_random_string(12), + "dynamic_password": False, + "static_password": "1", + "rotation_interval_days": 0, + "rotate_firmware_password": False}, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_form.html") + self.assertFormError(response.context["form"], "static_password", + 'The password must be at least 8 characters long.') + + def test_create_recovery_password_configuration_static_password_too_long_error(self): + self._login("mdm.add_recoverypasswordconfig") + response = self.client.post(reverse("mdm:create_recovery_password_config"), + {"name": get_random_string(12), + "dynamic_password": False, + "static_password": 33 * "1", + "rotation_interval_days": 0, + "rotate_firmware_password": False}, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_form.html") + self.assertFormError(response.context["form"], "static_password", + 'The password must be at most 32 characters long.') + + def test_create_recovery_password_configuration_static_password_non_ascii(self): + self._login("mdm.add_recoverypasswordconfig") + response = self.client.post(reverse("mdm:create_recovery_password_config"), + {"name": get_random_string(12), + "dynamic_password": False, + "static_password": 8 * "é", + "rotation_interval_days": 0, + "rotate_firmware_password": False}, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_form.html") + self.assertFormError( + response.context["form"], + "static_password", + "The characters in this value must consist of low-ASCII, printable characters (0x20 through 0x7E) " + "to ensure that all characters are enterable on the EFI login screen." + ) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_create_recovery_password_configuration_post(self, post_event): + self._login("mdm.add_recoverypasswordconfig", "mdm.view_recoverypasswordconfig") + name = get_random_string(12) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(reverse("mdm:create_recovery_password_config"), + {"name": name, + "dynamic_password": False, + "static_password": "12345678", + "rotation_interval_days": 90, + "rotate_firmware_password": True}, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(callbacks), 1) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_detail.html") + rp_config = response.context["object"] + self.assertEqual(rp_config.name, name) + self.assertFalse(rp_config.dynamic_password) + self.assertEqual(rp_config.get_static_password(), "12345678") + self.assertEqual(rp_config.rotation_interval_days, 90) + self.assertTrue(rp_config.rotate_firmware_password) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, AuditEvent) + self.assertEqual( + event.payload, + {"action": "created", + "object": { + "model": "mdm.recoverypasswordconfig", + "pk": str(rp_config.pk), + "new_value": { + "pk": rp_config.pk, + "name": name, + "dynamic_password": False, + "rotation_interval_days": 90, + "rotate_firmware_password": True, + "created_at": rp_config.created_at, + "updated_at": rp_config.updated_at + } + }} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["objects"], {"mdm_recovery_password_config": [str(rp_config.pk)]}) + self.assertEqual(sorted(metadata["tags"]), ["mdm", "zentral"]) + + # recovery password configuration + + def test_recovery_password_configuration_redirect(self): + rp_config = force_recovery_password_config() + self._login_redirect(reverse("mdm:recovery_password_config", args=(rp_config.pk,))) + + def test_recovery_password_configuration_permission_denied(self): + rp_config = force_recovery_password_config() + self._login() + response = self.client.get(reverse("mdm:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 403) + + def test_recovery_password_configuration_get(self): + rp_config = force_recovery_password_config() + self._login("mdm.view_recoverypasswordconfig", "mdm.delete_recoverypasswordconfig") + response = self.client.get(reverse("mdm:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_detail.html") + self.assertContains(response, rp_config.name) + self.assertContains(response, reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + + def test_recovery_password_configuration_get_no_perm_no_delete_link(self): + rp_config = force_recovery_password_config() + self._login("mdm.view_recoverypasswordconfig") + response = self.client.get(reverse("mdm:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_detail.html") + self.assertContains(response, rp_config.name) + self.assertNotContains(response, reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + + def test_recovery_password_configuration_get_cannot_be_deleted_no_delete_link(self): + rp_config = force_recovery_password_config() + force_blueprint(recovery_password_config=rp_config) + self._login("mdm.view_recoverypasswordconfig", "mdm.delete_recoverypasswordconfig") + response = self.client.get(reverse("mdm:recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_detail.html") + self.assertContains(response, rp_config.name) + self.assertNotContains(response, reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + + # update recovery password configuration + + def test_update_recovery_password_configuration_redirect(self): + rp_config = force_recovery_password_config() + self._login_redirect(reverse("mdm:update_recovery_password_config", args=(rp_config.pk,))) + + def test_update_recovery_password_configuration_permission_denied(self): + rp_config = force_recovery_password_config() + self._login() + response = self.client.get(reverse("mdm:update_recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 403) + + def test_update_recovery_password_configuration_get(self): + rp_config = force_recovery_password_config() + self._login("mdm.change_recoverypasswordconfig") + response = self.client.get(reverse("mdm:update_recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_form.html") + self.assertContains(response, "Update recovery password configuration") + self.assertContains(response, rp_config.name) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_update_recovery_password_configuration_post(self, post_event): + rp_config = force_recovery_password_config() + prev_value = rp_config.serialize_for_event() + self.assertTrue(rp_config.dynamic_password) + self.assertIsNone(rp_config.static_password) + self.assertEqual(rp_config.rotation_interval_days, 0) + self.assertFalse(rp_config.rotate_firmware_password) + self._login("mdm.change_recoverypasswordconfig", "mdm.view_recoverypasswordconfig") + new_name = get_random_string(12) + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(reverse("mdm:update_recovery_password_config", args=(rp_config.pk,)), + {"name": new_name, + "dynamic_password": False, + "static_password": "12345678", + "rotation_interval_days": 90, + "rotate_firmware_password": True}, + follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(callbacks), 1) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_detail.html") + rp_config2 = response.context["object"] + self.assertEqual(rp_config2, rp_config) + self.assertEqual(rp_config2.name, new_name) + self.assertFalse(rp_config2.dynamic_password) + self.assertEqual(rp_config2.get_static_password(), "12345678") + self.assertEqual(rp_config2.rotation_interval_days, 90) + self.assertTrue(rp_config2.rotate_firmware_password) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, AuditEvent) + self.assertEqual( + event.payload, + {"action": "updated", + "object": { + "model": "mdm.recoverypasswordconfig", + "pk": str(rp_config2.pk), + "new_value": { + "pk": rp_config2.pk, + "name": new_name, + "dynamic_password": False, + "rotation_interval_days": 90, + "rotate_firmware_password": True, + "created_at": rp_config2.created_at, + "updated_at": rp_config2.updated_at + }, + "prev_value": prev_value, + }} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["objects"], {"mdm_recovery_password_config": [str(rp_config.pk)]}) + self.assertEqual(sorted(metadata["tags"]), ["mdm", "zentral"]) + + # delete recovery password configuration + + def test_delete_recovery_password_configuration_redirect(self): + rp_config = force_recovery_password_config() + self._login_redirect(reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + + def test_delete_recovery_password_configuration_permission_denied(self): + rp_config = force_recovery_password_config() + self._login() + response = self.client.get(reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 403) + + def test_delete_recovery_password_configuration_404(self): + rp_config = force_recovery_password_config() + force_blueprint(recovery_password_config=rp_config) + self._login("mdm.delete_recoverypasswordconfig") + response = self.client.get(reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 404) + + def test_delete_recovery_password_configuration_get(self): + rp_config = force_recovery_password_config() + self._login("mdm.delete_recoverypasswordconfig") + response = self.client.get(reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,))) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_confirm_delete.html") + self.assertContains(response, "Delete recovery password configuration") + self.assertContains(response, rp_config.name) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_delete_recovery_password_configuration_post(self, post_event): + rp_config = force_recovery_password_config() + prev_value = rp_config.serialize_for_event() + self._login("mdm.delete_recoverypasswordconfig", "mdm.view_recoverypasswordconfig") + with self.captureOnCommitCallbacks(execute=True) as callbacks: + response = self.client.post(reverse("mdm:delete_recovery_password_config", args=(rp_config.pk,)), + follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(len(callbacks), 1) + self.assertTemplateUsed(response, "mdm/recoverypasswordconfig_list.html") + self.assertNotContains(response, rp_config.name) + event = post_event.call_args_list[0].args[0] + self.assertIsInstance(event, AuditEvent) + self.assertEqual( + event.payload, + {"action": "deleted", + "object": { + "model": "mdm.recoverypasswordconfig", + "pk": str(rp_config.pk), + "prev_value": prev_value, + }} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["objects"], {"mdm_recovery_password_config": [str(rp_config.pk)]}) + self.assertEqual(sorted(metadata["tags"]), ["mdm", "zentral"]) diff --git a/tests/mdm/test_rotate_filevault_key_command.py b/tests/mdm/test_rotate_filevault_key_command.py index 9df7fb6851..eddfcd5559 100644 --- a/tests/mdm/test_rotate_filevault_key_command.py +++ b/tests/mdm/test_rotate_filevault_key_command.py @@ -11,7 +11,7 @@ from zentral.contrib.mdm.commands import RotateFileVaultKey from zentral.contrib.mdm.commands.scheduling import _rotate_filevault_key from zentral.contrib.mdm.crypto import encrypt_cms_payload -from zentral.contrib.mdm.events import FileVaultPRKUpdateEvent +from zentral.contrib.mdm.events import FileVaultPRKUpdatedEvent from zentral.contrib.mdm.models import Channel, Command, Platform, RequestStatus from .utils import force_blueprint, force_dep_enrollment_session, force_filevault_config @@ -126,7 +126,7 @@ def test_process_acknowledged_response(self, post_event): events = list(call_args.args[0] for call_args in post_event.call_args_list) self.assertEqual(len(events), 1) event = events[0] - self.assertIsInstance(event, FileVaultPRKUpdateEvent) + self.assertIsInstance(event, FileVaultPRKUpdatedEvent) self.assertEqual( event.payload, {'command': {'request_type': 'RotateFileVaultKey', diff --git a/tests/mdm/test_security_info_command.py b/tests/mdm/test_security_info_command.py index e0e793ed89..2483d541c0 100644 --- a/tests/mdm/test_security_info_command.py +++ b/tests/mdm/test_security_info_command.py @@ -13,7 +13,7 @@ from zentral.contrib.mdm.commands.scheduling import _update_inventory from zentral.contrib.mdm.commands.setup_filevault import get_escrow_key_certificate_der_bytes from zentral.contrib.mdm.crypto import encrypt_cms_payload -from zentral.contrib.mdm.events import FileVaultPRKUpdateEvent +from zentral.contrib.mdm.events import FileVaultPRKUpdatedEvent from zentral.contrib.mdm.models import Blueprint, Channel, Platform, RequestStatus from .utils import force_dep_enrollment_session @@ -156,7 +156,7 @@ def test_process_acknowledged_response_update_filevault_prk(self, post_event): events = list(call_args.args[0] for call_args in post_event.call_args_list) self.assertEqual(len(events), 1) event = events[0] - self.assertIsInstance(event, FileVaultPRKUpdateEvent) + self.assertIsInstance(event, FileVaultPRKUpdatedEvent) self.assertEqual( event.payload, {'command': {'request_type': 'SecurityInfo', diff --git a/tests/mdm/test_set_recovery_lock_command.py b/tests/mdm/test_set_recovery_lock_command.py new file mode 100644 index 0000000000..8ee702697d --- /dev/null +++ b/tests/mdm/test_set_recovery_lock_command.py @@ -0,0 +1,350 @@ +from datetime import datetime, timedelta +import plistlib +from unittest.mock import patch +from django.test import TestCase +from django.utils.crypto import get_random_string +from zentral.contrib.inventory.models import MetaBusinessUnit +from zentral.contrib.mdm.artifacts import Target +from zentral.contrib.mdm.commands import SetRecoveryLock +from zentral.contrib.mdm.commands.scheduling import _manage_recovery_password +from zentral.contrib.mdm.events import (RecoveryPasswordClearedEvent, + RecoveryPasswordSetEvent, + RecoveryPasswordUpdatedEvent) +from zentral.contrib.mdm.models import Channel, Command, Platform, RequestStatus +from .utils import force_blueprint, force_dep_enrollment_session, force_enrolled_user, force_recovery_password_config + + +class SetRecoveryLockCommandTestCase(TestCase): + @classmethod + def setUpTestData(cls): + cls.mbu = MetaBusinessUnit.objects.create(name=get_random_string(12)) + cls.mbu.create_enrollment_business_unit() + cls.dep_enrollment_session, cls.device_udid, cls.serial_number = force_dep_enrollment_session( + cls.mbu, + authenticated=True, + completed=True, + ) + cls.enrolled_device = cls.dep_enrollment_session.enrolled_device + cls.enrolled_device.apple_silicon = True + cls.enrolled_device.save() + + # verify_channel_and_device + + def test_verify_channel_and_device_ok(self): + self.assertEqual(self.enrolled_device.platform, Platform.MACOS) + self.assertTrue(self.enrolled_device.apple_silicon) + self.assertTrue(SetRecoveryLock.verify_channel_and_device( + Channel.DEVICE, + self.enrolled_device + )) + + def test_verify_channel_and_device_user_channel_not_ok(self): + self.assertEqual(self.enrolled_device.platform, Platform.MACOS) + self.assertTrue(self.enrolled_device.apple_silicon) + self.assertFalse(SetRecoveryLock.verify_channel_and_device( + Channel.USER, + self.enrolled_device + )) + + def test_verify_channel_and_device_intel_not_ok(self): + self.assertEqual(self.enrolled_device.platform, Platform.MACOS) + self.enrolled_device.apple_silicon = False + self.assertFalse(SetRecoveryLock.verify_channel_and_device( + Channel.DEVICE, + self.enrolled_device + )) + + # build_command + + def test_build_command_set_automatic_password(self): + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device)) + response = cmd.build_http_response(self.dep_enrollment_session) + cmd_plist = plistlib.loads(response.content)["Command"] + self.assertEqual(cmd_plist["RequestType"], "SetRecoveryLock") + self.assertEqual(cmd_plist["NewPassword"], cmd.load_new_password()) + self.assertEqual(len(cmd_plist["NewPassword"]), 12) + self.assertNotIn("CurrentPassword", cmd_plist) + + def test_build_command_set_static_password(self): + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device), "12345678") + response = cmd.build_http_response(self.dep_enrollment_session) + cmd_plist = plistlib.loads(response.content)["Command"] + self.assertEqual(cmd_plist["RequestType"], "SetRecoveryLock") + self.assertEqual(cmd_plist["NewPassword"], "12345678") + self.assertNotIn("CurrentPassword", cmd_plist) + + def test_build_command_rotate_automatic_password(self): + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device)) + response = cmd.build_http_response(self.dep_enrollment_session) + cmd_plist = plistlib.loads(response.content)["Command"] + self.assertEqual(cmd_plist["RequestType"], "SetRecoveryLock") + self.assertEqual(cmd_plist["CurrentPassword"], "12345678") + self.assertEqual(cmd_plist["NewPassword"], cmd.load_new_password()) + self.assertEqual(len(cmd_plist["NewPassword"]), 12) + + def test_build_command_clear_password(self): + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_target(Target(self.enrolled_device)) + response = cmd.build_http_response(self.dep_enrollment_session) + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device)) + cmd_plist = plistlib.loads(response.content)["Command"] + self.assertEqual(cmd_plist["RequestType"], "SetRecoveryLock") + self.assertEqual(cmd_plist["CurrentPassword"], "12345678") + self.assertEqual(cmd_plist["NewPassword"], "") + + # process_response + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_process_acknowledged_response_set_recovery_lock(self, post_event): + self.assertIsNone(self.enrolled_device.recovery_password) + self.assertIsNone(self.enrolled_device.recovery_password_updated_at) + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device), "12345678") + with self.captureOnCommitCallbacks(execute=True): + cmd.process_response( + {"UDID": self.enrolled_device.udid, + "Status": "Acknowledged", + "CommandUUID": str(cmd.uuid).upper()}, + self.dep_enrollment_session, + self.mbu + ) + cmd.db_command.refresh_from_db() + self.assertEqual(cmd.status, Command.Status.ACKNOWLEDGED) + self.assertEqual(cmd.db_command.status, Command.Status.ACKNOWLEDGED) + self.enrolled_device.refresh_from_db() + self.assertEqual(self.enrolled_device.get_recovery_password(), "12345678") + self.assertIsNotNone(self.enrolled_device.recovery_password_updated_at) + # event + events = list(call_args.args[0] for call_args in post_event.call_args_list) + self.assertEqual(len(events), 1) + event = events[0] + self.assertIsInstance(event, RecoveryPasswordSetEvent) + self.assertEqual( + event.payload, + {'command': {'request_type': 'SetRecoveryLock', + 'uuid': str(cmd.uuid)}, + 'password_type': 'recovery_lock'} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["machine_serial_number"], self.enrolled_device.serial_number) + self.assertEqual(metadata["objects"], {"mdm_command": [str(cmd.uuid)]}) + self.assertEqual(set(metadata["tags"]), {"mdm", "recovery_password"}) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_process_acknowledged_response_update_recovery_lock(self, post_event): + self.enrolled_device.set_recovery_password("87654321") + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device), "12345678") + with self.captureOnCommitCallbacks(execute=True): + cmd.process_response( + {"UDID": self.enrolled_device.udid, + "Status": "Acknowledged", + "CommandUUID": str(cmd.uuid).upper()}, + self.dep_enrollment_session, + self.mbu + ) + cmd.db_command.refresh_from_db() + self.assertEqual(cmd.status, Command.Status.ACKNOWLEDGED) + self.assertEqual(cmd.db_command.status, Command.Status.ACKNOWLEDGED) + self.enrolled_device.refresh_from_db() + self.assertEqual(self.enrolled_device.get_recovery_password(), "12345678") + # event + events = list(call_args.args[0] for call_args in post_event.call_args_list) + self.assertEqual(len(events), 1) + event = events[0] + self.assertIsInstance(event, RecoveryPasswordUpdatedEvent) + self.assertEqual( + event.payload, + {'command': {'request_type': 'SetRecoveryLock', + 'uuid': str(cmd.uuid)}, + 'password_type': 'recovery_lock'} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["machine_serial_number"], self.enrolled_device.serial_number) + self.assertEqual(metadata["objects"], {"mdm_command": [str(cmd.uuid)]}) + self.assertEqual(set(metadata["tags"]), {"mdm", "recovery_password"}) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_process_acknowledged_response_clear_recovery_lock(self, post_event): + self.enrolled_device.set_recovery_password("87654321") + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_target(Target(self.enrolled_device)) + with self.captureOnCommitCallbacks(execute=True): + cmd.process_response( + {"UDID": self.enrolled_device.udid, + "Status": "Acknowledged", + "CommandUUID": str(cmd.uuid).upper()}, + self.dep_enrollment_session, + self.mbu + ) + cmd.db_command.refresh_from_db() + self.assertEqual(cmd.status, Command.Status.ACKNOWLEDGED) + self.assertEqual(cmd.db_command.status, Command.Status.ACKNOWLEDGED) + self.enrolled_device.refresh_from_db() + self.assertIsNone(self.enrolled_device.recovery_password) + # event + events = list(call_args.args[0] for call_args in post_event.call_args_list) + self.assertEqual(len(events), 1) + event = events[0] + self.assertIsInstance(event, RecoveryPasswordClearedEvent) + self.assertEqual( + event.payload, + {'command': {'request_type': 'SetRecoveryLock', + 'uuid': str(cmd.uuid)}, + 'password_type': 'recovery_lock'} + ) + metadata = event.metadata.serialize() + self.assertEqual(metadata["machine_serial_number"], self.enrolled_device.serial_number) + self.assertEqual(metadata["objects"], {"mdm_command": [str(cmd.uuid)]}) + self.assertEqual(set(metadata["tags"]), {"mdm", "recovery_password"}) + + @patch("zentral.core.queues.backends.kombu.EventQueues.post_event") + def test_process_acknowledged_response_noop(self, post_event): + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_automatic_scheduling(Target(self.enrolled_device), "12345678") + with self.captureOnCommitCallbacks(execute=True): + cmd.process_response( + {"UDID": self.enrolled_device.udid, + "Status": "Acknowledged", + "CommandUUID": str(cmd.uuid).upper()}, + self.dep_enrollment_session, + self.mbu + ) + cmd.db_command.refresh_from_db() + self.assertEqual(cmd.status, Command.Status.ACKNOWLEDGED) + self.assertEqual(cmd.db_command.status, Command.Status.ACKNOWLEDGED) + self.enrolled_device.refresh_from_db() + self.assertEqual(self.enrolled_device.get_recovery_password(), "12345678") + post_event.assert_not_called() + + # _manage_recovery_password + + def test_manage_recovery_password_notnow_noop(self): + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.NOT_NOW, + )) + + def test_manage_recovery_password_ios_noop(self): + self.enrolled_device.platform = Platform.IOS + self.enrolled_device.save() + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_user_channel_noop(self): + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device, force_enrolled_user(self.enrolled_device)), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_no_blueprint_noop(self): + self.enrolled_device.blueprint = None + self.enrolled_device.save() + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_no_recovery_password_config_noop(self): + self.enrolled_device.blueprint = force_blueprint() + self.enrolled_device.save() + self.assertIsNone(self.enrolled_device.blueprint.recovery_password_config) + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_existing_password_no_auto_rotation_noop(self): + self.enrolled_device.blueprint = force_blueprint(recovery_password_config=force_recovery_password_config()) + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.save() + self.assertEqual(self.enrolled_device.blueprint.recovery_password_config.rotation_interval_days, 0) + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_existing_recent_password_auto_rotation_noop(self): + self.enrolled_device.blueprint = force_blueprint(recovery_password_config=force_recovery_password_config( + rotation_interval_days=90 + )) + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.save() + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_existing_old_password_auto_rotation_latest_cmd_error_noop(self): + self.enrolled_device.blueprint = force_blueprint(recovery_password_config=force_recovery_password_config( + rotation_interval_days=90 + )) + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.recovery_password_updated_at -= timedelta(days=91) + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_target(Target(self.enrolled_device)) + cmd.process_response( + {"UDID": self.enrolled_device.udid, + "Status": "Error", + "CommandUUID": str(cmd.uuid).upper()}, + self.dep_enrollment_session, + self.mbu + ) + self.assertIsNone(_manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + )) + + def test_manage_recovery_password_existing_old_password_auto_rotation_ok(self): + self.enrolled_device.blueprint = force_blueprint(recovery_password_config=force_recovery_password_config( + rotation_interval_days=90 + )) + self.enrolled_device.set_recovery_password("12345678") + self.enrolled_device.recovery_password_updated_at -= timedelta(days=91) + self.enrolled_device.save() + cmd = SetRecoveryLock.create_for_target(Target(self.enrolled_device)) + cmd.db_command.time = datetime.utcnow() - timedelta(hours=5) + cmd.db_command.save() + cmd.process_response( + {"UDID": self.enrolled_device.udid, + "Status": "Error", + "CommandUUID": str(cmd.uuid).upper()}, + self.dep_enrollment_session, + self.mbu + ) + cmd = _manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + ) + self.assertIsInstance(cmd, SetRecoveryLock) + self.assertEqual(cmd.build_command()["CurrentPassword"], "12345678") + + def test_manage_recovery_password_first_time_static_ok(self): + self.enrolled_device.blueprint = force_blueprint(recovery_password_config=force_recovery_password_config( + static_password="12345678" + )) + self.enrolled_device.save() + cmd = _manage_recovery_password( + Target(self.enrolled_device), + self.dep_enrollment_session, + RequestStatus.IDLE, + ) + self.assertIsInstance(cmd, SetRecoveryLock) + self.assertEqual( + cmd.build_command(), + {"NewPassword": "12345678"} + ) diff --git a/tests/mdm/utils.py b/tests/mdm/utils.py index 544aa0db48..abdb61bfa8 100644 --- a/tests/mdm/utils.py +++ b/tests/mdm/utils.py @@ -19,7 +19,7 @@ DEPVirtualServer, EnrolledDevice, EnrolledUser, EnterpriseApp, FileVaultConfig, Location, LocationAsset, OTAEnrollment, OTAEnrollmentSession, - Profile, PushCertificate, SCEPConfig, + Profile, PushCertificate, RecoveryPasswordConfig, SCEPConfig, StoreApp, UserEnrollment, UserEnrollmentSession) from zentral.contrib.mdm.skip_keys import skippable_setup_panes @@ -427,10 +427,23 @@ def force_filevault_config(prk_rotation_interval_days=0): ) -def force_blueprint(filevault_config=None): +def force_recovery_password_config(rotation_interval_days=0, static_password=None): + cfg = RecoveryPasswordConfig.objects.create( + name=get_random_string(12), + dynamic_password=static_password is None, + rotation_interval_days=rotation_interval_days, + ) + if static_password: + cfg.set_static_password(static_password) + cfg.save() + return cfg + + +def force_blueprint(filevault_config=None, recovery_password_config=None): return Blueprint.objects.create( name=get_random_string(12), filevault_config=filevault_config, + recovery_password_config=recovery_password_config, ) diff --git a/zentral/contrib/mdm/api_urls.py b/zentral/contrib/mdm/api_urls.py index b6b1c381d6..15d6887de0 100644 --- a/zentral/contrib/mdm/api_urls.py +++ b/zentral/contrib/mdm/api_urls.py @@ -5,8 +5,9 @@ BlueprintArtifactDetail, BlueprintArtifactList, FileVaultConfigDetail, FileVaultConfigList, ProfileDetail, ProfileList, + RecoveryPasswordConfigList, RecoveryPasswordConfigDetail, DEPVirtualServerSyncDevicesView, - EnrolledDeviceFileVaultPRK,) + EnrolledDeviceFileVaultPRK, EnrolledDeviceRecoveryPassword) app_name = "mdm_api" @@ -21,11 +22,16 @@ path('filevault_configs//', FileVaultConfigDetail.as_view(), name="filevault_config"), path('profiles/', ProfileList.as_view(), name="profiles"), path('profiles//', ProfileDetail.as_view(), name="profile"), + path('recovery_password_configs/', RecoveryPasswordConfigList.as_view(), name="recovery_password_configs"), + path('recovery_password_configs//', RecoveryPasswordConfigDetail.as_view(), + name="recovery_password_config"), path('dep/virtual_servers//sync_devices/', DEPVirtualServerSyncDevicesView.as_view(), name="dep_virtual_server_sync_devices"), path('enrolled_devices//filevault_prk/', EnrolledDeviceFileVaultPRK.as_view(), name="enrolled_device_filevault_prk"), + path('enrolled_devices//recovery_password/', EnrolledDeviceRecoveryPassword.as_view(), + name="enrolled_device_recovery_password"), ] diff --git a/zentral/contrib/mdm/api_views/__init__.py b/zentral/contrib/mdm/api_views/__init__.py index 496d3f66f6..091eeac9ab 100644 --- a/zentral/contrib/mdm/api_views/__init__.py +++ b/zentral/contrib/mdm/api_views/__init__.py @@ -3,3 +3,4 @@ from .dep import * # NOQA from .enrolled_devices import * # NOQA from .filevault_configs import * # NOQA +from .recovery_password_configs import * # NOQA diff --git a/zentral/contrib/mdm/api_views/enrolled_devices.py b/zentral/contrib/mdm/api_views/enrolled_devices.py index cd50d2cbdd..a51c9b006c 100644 --- a/zentral/contrib/mdm/api_views/enrolled_devices.py +++ b/zentral/contrib/mdm/api_views/enrolled_devices.py @@ -1,7 +1,7 @@ from django.shortcuts import get_object_or_404 from rest_framework.views import APIView from rest_framework.response import Response -from zentral.contrib.mdm.events import post_view_filevault_prk_event +from zentral.contrib.mdm.events import post_filevault_prk_viewed_event, post_recovery_password_viewed_event from zentral.contrib.mdm.models import EnrolledDevice from zentral.utils.drf import DjangoPermissionRequired @@ -12,9 +12,27 @@ class EnrolledDeviceFileVaultPRK(APIView): def get(self, request, *args, **kwargs): enrolled_device = get_object_or_404(EnrolledDevice, pk=kwargs["pk"]) - post_view_filevault_prk_event(request, enrolled_device) + filevault_prk = enrolled_device.get_filevault_prk() + if filevault_prk: + post_filevault_prk_viewed_event(request, enrolled_device) return Response({ - "pk": enrolled_device.pk, + "id": enrolled_device.pk, "serial_number": enrolled_device.serial_number, - "filevault_prk": enrolled_device.get_filevault_prk() + "filevault_prk": filevault_prk, + }) + + +class EnrolledDeviceRecoveryPassword(APIView): + permission_required = "mdm.view_recovery_password" + permission_classes = [DjangoPermissionRequired] + + def get(self, request, *args, **kwargs): + enrolled_device = get_object_or_404(EnrolledDevice, pk=kwargs["pk"]) + recovery_password = enrolled_device.get_recovery_password() + if recovery_password: + post_recovery_password_viewed_event(request, enrolled_device) + return Response({ + "id": enrolled_device.pk, + "serial_number": enrolled_device.serial_number, + "recovery_password": recovery_password, }) diff --git a/zentral/contrib/mdm/api_views/recovery_password_configs.py b/zentral/contrib/mdm/api_views/recovery_password_configs.py new file mode 100644 index 0000000000..f89591c2ee --- /dev/null +++ b/zentral/contrib/mdm/api_views/recovery_password_configs.py @@ -0,0 +1,27 @@ +from rest_framework.exceptions import ValidationError +from zentral.utils.drf import ListCreateAPIViewWithAudit, RetrieveUpdateDestroyAPIViewWithAudit +from zentral.contrib.mdm.models import RecoveryPasswordConfig +from zentral.contrib.mdm.serializers import RecoveryPasswordConfigSerializer + + +class RecoveryPasswordConfigList(ListCreateAPIViewWithAudit): + """ + List all RecoveryPasswordConfig, search RecoveryPasswordConfig by name, or create a new RecoveryPasswordConfig. + """ + queryset = RecoveryPasswordConfig.objects.all() + serializer_class = RecoveryPasswordConfigSerializer + filterset_fields = ('name',) + + +class RecoveryPasswordConfigDetail(RetrieveUpdateDestroyAPIViewWithAudit): + """ + Retrieve, update or delete a RecoveryPasswordConfig instance. + """ + queryset = RecoveryPasswordConfig.objects.all() + serializer_class = RecoveryPasswordConfigSerializer + + def perform_destroy(self, instance): + if not instance.can_be_deleted(): + raise ValidationError('This recovery password configuration cannot be deleted') + else: + return super().perform_destroy(instance) diff --git a/zentral/contrib/mdm/apps.py b/zentral/contrib/mdm/apps.py index 19b96dce50..c21504a93a 100644 --- a/zentral/contrib/mdm/apps.py +++ b/zentral/contrib/mdm/apps.py @@ -22,6 +22,7 @@ class ZentralMDMAppConfig(ZentralAppConfig): "profile", "pushcertificate", "otaenrollment", + "recoverypasswordconfig", "scepconfig", "userartifact", "userenrollment", diff --git a/zentral/contrib/mdm/commands/__init__.py b/zentral/contrib/mdm/commands/__init__.py index b4d3590ee8..21ad101096 100644 --- a/zentral/contrib/mdm/commands/__init__.py +++ b/zentral/contrib/mdm/commands/__init__.py @@ -16,4 +16,5 @@ from .remove_profile import RemoveProfile # NOQA from .rotate_filevault_key import RotateFileVaultKey # NOQA from .security_info import SecurityInfo # NOQA +from .set_recovery_lock import SetRecoveryLock # NOQA from .setup_filevault import SetupFileVault # NOQA diff --git a/zentral/contrib/mdm/commands/rotate_filevault_key.py b/zentral/contrib/mdm/commands/rotate_filevault_key.py index cb182ba999..f66364da07 100644 --- a/zentral/contrib/mdm/commands/rotate_filevault_key.py +++ b/zentral/contrib/mdm/commands/rotate_filevault_key.py @@ -8,7 +8,7 @@ from cryptography.x509.oid import NameOID from django.db import transaction from zentral.contrib.mdm.crypto import decrypt_cms_payload -from zentral.contrib.mdm.events import post_filevault_prk_update_event +from zentral.contrib.mdm.events import post_filevault_prk_updated_event from zentral.contrib.mdm.models import Channel, Platform from zentral.core.secret_engines import decrypt, encrypt from .base import register_command, Command, CommandBaseForm @@ -108,7 +108,7 @@ def command_acknowledged(self): if prk and prk != self.enrolled_device.get_filevault_prk(): self.enrolled_device.set_filevault_prk(prk) self.enrolled_device.save() - transaction.on_commit(lambda: post_filevault_prk_update_event(self)) + transaction.on_commit(lambda: post_filevault_prk_updated_event(self)) register_command(RotateFileVaultKey) diff --git a/zentral/contrib/mdm/commands/scheduling.py b/zentral/contrib/mdm/commands/scheduling.py index 78d744e56b..84093b076d 100644 --- a/zentral/contrib/mdm/commands/scheduling.py +++ b/zentral/contrib/mdm/commands/scheduling.py @@ -24,6 +24,7 @@ from .remove_profile import RemoveProfile from .rotate_filevault_key import RotateFileVaultKey from .security_info import SecurityInfo +from .set_recovery_lock import SetRecoveryLock from .setup_filevault import SetupFileVault @@ -257,6 +258,51 @@ def _rotate_filevault_key(target, enrollment_session, status): return RotateFileVaultKey.create_for_target(target) +def _manage_recovery_password(target, enrollment_session, status): + if status == RequestStatus.NOT_NOW: + return + if not target.platform == Platform.MACOS: + return + if not target.is_device: + return + try: + recovery_password_config = target.blueprint.recovery_password_config + except AttributeError: + return + if not recovery_password_config: + return + for cmd_class in (SetRecoveryLock,): + if cmd_class.verify_target(target): + break + else: + return + enrolled_device = target.enrolled_device + if ( + enrolled_device.recovery_password + and ( + not recovery_password_config.rotation_interval_days + or (enrolled_device.recovery_password_updated_at + and (datetime.utcnow() - enrolled_device.recovery_password_updated_at + < timedelta(days=recovery_password_config.rotation_interval_days)) + ) + ) + ): + # recovery password set and recent enough + return + latest_cmd = ( + DeviceCommand.objects.filter(name=cmd_class.get_db_name(), + enrolled_device=enrolled_device, + time__gte=datetime.utcnow() - timedelta(hours=4)) + .order_by("-time") + .first() + ) + if latest_cmd and latest_cmd.status != Command.Status.ACKNOWLEDGED: + # Backoff: the latest command sent in the last 4 hours has a bad status + # TODO: 4 hours hard-coded + return + return cmd_class.create_for_automatic_scheduling(target, password=recovery_password_config.get_static_password()) + + def _configure_dep_enrollment_accounts(target, enrollment_session, status): if status == RequestStatus.NOT_NOW: return @@ -303,6 +349,7 @@ def get_next_command_response(target, enrollment_session, status): _remove_artifacts, _setup_filevault, _rotate_filevault_key, + _manage_recovery_password, _configure_dep_enrollment_accounts, _finish_dep_enrollment_configuration ): diff --git a/zentral/contrib/mdm/commands/security_info.py b/zentral/contrib/mdm/commands/security_info.py index 9f7a438718..a1e940501e 100644 --- a/zentral/contrib/mdm/commands/security_info.py +++ b/zentral/contrib/mdm/commands/security_info.py @@ -3,7 +3,7 @@ from cryptography.hazmat.primitives.serialization import load_der_private_key from django.db import transaction from zentral.contrib.mdm.crypto import decrypt_cms_payload -from zentral.contrib.mdm.events import post_filevault_prk_update_event +from zentral.contrib.mdm.events import post_filevault_prk_updated_event from zentral.contrib.mdm.models import Channel, Platform from zentral.utils.json import prepare_loaded_plist from .base import register_command, Command, CommandBaseForm @@ -52,7 +52,7 @@ def command_acknowledged(self): else: if prk and prk != self.enrolled_device.get_filevault_prk(): self.enrolled_device.set_filevault_prk(prk) - transaction.on_commit(lambda: post_filevault_prk_update_event(self)) + transaction.on_commit(lambda: post_filevault_prk_updated_event(self)) self.enrolled_device.security_info = prepare_loaded_plist(security_info) self.enrolled_device.security_info_updated_at = datetime.utcnow() diff --git a/zentral/contrib/mdm/commands/set_recovery_lock.py b/zentral/contrib/mdm/commands/set_recovery_lock.py new file mode 100644 index 0000000000..5dca96f450 --- /dev/null +++ b/zentral/contrib/mdm/commands/set_recovery_lock.py @@ -0,0 +1,115 @@ +import logging +from uuid import uuid4 +from django import forms +from django.db import transaction +from django.utils.crypto import get_random_string +from zentral.contrib.mdm.events import post_recovery_password_event +from zentral.contrib.mdm.models import Channel, Platform +from zentral.core.secret_engines import decrypt_str, encrypt_str +from .base import register_command, Command, CommandBaseForm + + +logger = logging.getLogger("zentral.contrib.mdm.commands.set_recovery_lock") + + +def validate_recovery_password(password): + if len(password) < 8: + raise forms.ValidationError("The password must be at least 8 characters long.") + if len(password) > 32: + raise forms.ValidationError("The password must be at most 32 characters long.") + if not all(32 <= ord(c) < 127 for c in password): + raise forms.ValidationError( + "The characters in this value must consist of low-ASCII, printable characters (0x20 through 0x7E) " + "to ensure that all characters are enterable on the EFI login screen." + ) + + +def generate_password(length=12): + # TODO: increase the entropy by using the full ASCII range without risking having a password impossible to type? + return get_random_string(length) + + +def get_secret_engine_kwargs(uuid): + return {"model": "mdm.devicecommand", "field": "new_password", "uuid": str(uuid)} + + +class SetRecoveryLockForm(CommandBaseForm): + new_password = forms.CharField( + label="New password", required=False, strip=True, + validators=[validate_recovery_password] + ) + + def clean_new_password(self): + new_password = self.cleaned_data.get("new_password") + if not new_password and not self.enrolled_device.recovery_password: + raise forms.ValidationError("No current recovery lock set: this field is required.") + return new_password + + def get_command_kwargs(self, uuid): + kwargs = {} + new_password = self.cleaned_data.get("new_password") + if new_password: + kwargs["new_password"] = encrypt_str(self.cleaned_data["new_password"], **get_secret_engine_kwargs(uuid)) + return kwargs + + +class SetRecoveryLock(Command): + request_type = "SetRecoveryLock" + display_name = "Set recovery lock" + reschedule_notnow = True + form_class = SetRecoveryLockForm + + @classmethod + def create_for_automatic_scheduling(cls, target, password=None): + if not password: + password = generate_password() + uuid = uuid4() + return super().create_for_target( + target, + kwargs={"new_password": encrypt_str(password, **get_secret_engine_kwargs(uuid))}, + uuid=uuid, + ) + + @staticmethod + def verify_channel_and_device(channel, enrolled_device): + return ( + channel == Channel.DEVICE + and enrolled_device.platform == Platform.MACOS + and enrolled_device.apple_silicon + ) + + def load_new_password(self): + new_password = self.db_command.kwargs.get("new_password") + if new_password: + return decrypt_str(new_password, **get_secret_engine_kwargs(self.uuid)) + else: + return "" + + def build_command(self): + payload = {"NewPassword": self.load_new_password()} + current_password = self.enrolled_device.get_recovery_password() + if current_password: + payload["CurrentPassword"] = current_password + return payload + + def command_acknowledged(self): + current_password = self.enrolled_device.get_recovery_password() + new_password = self.load_new_password() + operation = None + if new_password: + if current_password: + if current_password != new_password: + operation = "update" + else: + operation = "set" + elif current_password: + operation = "clear" + if operation: + self.enrolled_device.set_recovery_password(new_password) + self.enrolled_device.save() + transaction.on_commit(lambda: post_recovery_password_event( + self, password_type="recovery_lock", operation=operation + )) + + +register_command(SetRecoveryLock) diff --git a/zentral/contrib/mdm/events/__init__.py b/zentral/contrib/mdm/events/__init__.py index 70de555ed4..f1b59b81bb 100644 --- a/zentral/contrib/mdm/events/__init__.py +++ b/zentral/contrib/mdm/events/__init__.py @@ -2,6 +2,7 @@ from .filevault import * # NOQA from .management import * # NOQA from .mdm import * # NOQA +from .recovery_password import * # NOQA ALL_EVENTS_SEARCH_DICT = {"tag": "mdm"} diff --git a/zentral/contrib/mdm/events/filevault.py b/zentral/contrib/mdm/events/filevault.py index 24acff49c6..b15070681b 100644 --- a/zentral/contrib/mdm/events/filevault.py +++ b/zentral/contrib/mdm/events/filevault.py @@ -6,8 +6,8 @@ logger = logging.getLogger('zentral.contrib.mdm.events.filevault') -class FileVaultPRKUpdateEvent(BaseEvent): - event_type = "filevault_prk_update" +class FileVaultPRKUpdatedEvent(BaseEvent): + event_type = "filevault_prk_updated" tags = ["mdm"] def get_linked_objects_keys(self): @@ -18,14 +18,14 @@ def get_linked_objects_keys(self): return keys -register_event_type(FileVaultPRKUpdateEvent) +register_event_type(FileVaultPRKUpdatedEvent) -def post_filevault_prk_update_event(mdm_command): +def post_filevault_prk_updated_event(mdm_command): event_metadata = EventMetadata( machine_serial_number=mdm_command.enrolled_device.serial_number, ) - event = FileVaultPRKUpdateEvent( + event = FileVaultPRKUpdatedEvent( event_metadata, {"command": {"request_type": mdm_command.request_type, "uuid": str(mdm_command.uuid)}}, diff --git a/zentral/contrib/mdm/events/management.py b/zentral/contrib/mdm/events/management.py index 9fbef988db..00039aedd7 100644 --- a/zentral/contrib/mdm/events/management.py +++ b/zentral/contrib/mdm/events/management.py @@ -6,18 +6,41 @@ logger = logging.getLogger('zentral.contrib.mdm.events.management') -class ViewFileVaultPRKEvent(BaseEvent): - event_type = "view_filevault_prk" +# FileVault PRK + + +class FileVaultPRKViewedEvent(BaseEvent): + event_type = "filevault_prk_viewed" + tags = ["mdm"] + + +register_event_type(FileVaultPRKViewedEvent) + + +def post_filevault_prk_viewed_event(request, enrolled_device): + event_metadata = EventMetadata( + machine_serial_number=enrolled_device.serial_number, + request=EventRequest.build_from_request(request), + ) + event = FileVaultPRKViewedEvent(event_metadata, {}) + event.post() + + +# Recovery password + + +class RecoveryPasswordViewedEvent(BaseEvent): + event_type = "recovery_password_viewed" tags = ["mdm"] -register_event_type(ViewFileVaultPRKEvent) +register_event_type(RecoveryPasswordViewedEvent) -def post_view_filevault_prk_event(request, enrolled_device): +def post_recovery_password_viewed_event(request, enrolled_device): event_metadata = EventMetadata( machine_serial_number=enrolled_device.serial_number, request=EventRequest.build_from_request(request), ) - event = ViewFileVaultPRKEvent(event_metadata, {}) + event = RecoveryPasswordViewedEvent(event_metadata, {}) event.post() diff --git a/zentral/contrib/mdm/events/recovery_password.py b/zentral/contrib/mdm/events/recovery_password.py new file mode 100644 index 0000000000..db05744a56 --- /dev/null +++ b/zentral/contrib/mdm/events/recovery_password.py @@ -0,0 +1,60 @@ +import logging +from zentral.core.events import register_event_type +from zentral.core.events.base import BaseEvent, EventMetadata + + +logger = logging.getLogger('zentral.contrib.mdm.events.recovery_password') + + +class BaseRecoveryPasswordEvent(BaseEvent): + namespace = "mdm_recovery_password" + tags = ["mdm", "recovery_password"] + + def get_linked_objects_keys(self): + keys = {} + cmd_uuid = self.payload.get("command", {}).get("uuid") + if cmd_uuid: + keys["mdm_command"] = [(cmd_uuid,)] + return keys + + +class RecoveryPasswordSetEvent(BaseRecoveryPasswordEvent): + event_type = "recovery_password_set" + + +register_event_type(RecoveryPasswordSetEvent) + + +class RecoveryPasswordUpdatedEvent(BaseRecoveryPasswordEvent): + event_type = "recovery_password_updated" + + +register_event_type(RecoveryPasswordUpdatedEvent) + + +class RecoveryPasswordClearedEvent(BaseRecoveryPasswordEvent): + event_type = "recovery_password_cleared" + + +register_event_type(RecoveryPasswordClearedEvent) + + +def post_recovery_password_event(mdm_command, password_type, operation): + event_metadata = EventMetadata( + machine_serial_number=mdm_command.enrolled_device.serial_number, + ) + if operation == "set": + event_class = RecoveryPasswordSetEvent + elif operation == "update": + event_class = RecoveryPasswordUpdatedEvent + elif operation == "clear": + event_class = RecoveryPasswordClearedEvent + else: + raise ValueError(f"Unknown recovery password operation: {operation}") + event = event_class( + event_metadata, + {"command": {"request_type": mdm_command.request_type, + "uuid": str(mdm_command.uuid)}, + "password_type": password_type}, + ) + event.post() diff --git a/zentral/contrib/mdm/forms.py b/zentral/contrib/mdm/forms.py index ae783bb2e8..d581537a0e 100644 --- a/zentral/contrib/mdm/forms.py +++ b/zentral/contrib/mdm/forms.py @@ -13,6 +13,7 @@ from .app_manifest import build_enterprise_app_manifest from .apps_books import AppsBooksClient from .artifacts import update_blueprint_serialized_artifacts +from .commands.set_recovery_lock import validate_recovery_password from .crypto import load_push_certificate_and_key from .dep import decrypt_dep_token from .dep_client import DEPClient @@ -21,7 +22,7 @@ Blueprint, BlueprintArtifact, BlueprintArtifactTag, Channel, DEPDevice, DEPOrganization, DEPEnrollment, DEPToken, DEPVirtualServer, EnrolledDevice, EnterpriseApp, Platform, - FileVaultConfig, SCEPConfig, + FileVaultConfig, RecoveryPasswordConfig, SCEPConfig, OTAEnrollment, UserEnrollment, PushCertificate, Profile, Location, LocationAsset, StoreApp) from .skip_keys import skippable_setup_panes @@ -875,6 +876,47 @@ def clean(self): self.add_error("bypass_attempts", "Must be at least 0 when enablement deferred at login") +class RecoveryPasswordConfigForm(forms.ModelForm): + static_password = forms.CharField( + widget=forms.PasswordInput(render_value=True), + validators=[validate_recovery_password], + required=False, strip=True + ) + field_order = [ + "name", + "dynamic_password", + "static_password", + "rotation_interval_days", + "rotate_firmware_password", + ] + + class Meta: + model = RecoveryPasswordConfig + fields = "__all__" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + self.fields["static_password"].initial = self.instance.get_static_password() or "" + + def clean(self): + super().clean() + dynamic_password = self.cleaned_data.get("dynamic_password") + if not dynamic_password: + static_password = self.cleaned_data.get("static_password") + if not static_password and "static_password" not in self.errors: + self.add_error("static_password", "This field is required when not using dynamic passwords.") + + def save(self): + if self.instance.pk and not self.cleaned_data.get("dynamic_password"): + self.instance.set_static_password(None) + obj = super().save() + if not obj.dynamic_password: + obj.set_static_password(self.cleaned_data["static_password"]) + obj.save() + return obj + + class SCEPConfigForm(forms.ModelForm): class Meta: model = SCEPConfig diff --git a/zentral/contrib/mdm/migrations/0067_recoverypasswordconfig_and_more.py b/zentral/contrib/mdm/migrations/0067_recoverypasswordconfig_and_more.py new file mode 100644 index 0000000000..c012a19162 --- /dev/null +++ b/zentral/contrib/mdm/migrations/0067_recoverypasswordconfig_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 4.1.9 on 2023-08-03 14:57 + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mdm', '0066_filevaultconfig_alter_blueprint_options_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='RecoveryPasswordConfig', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=256, unique=True)), + ('dynamic_password', models.BooleanField(default=True)), + ('static_password', models.TextField(editable=False, null=True)), + ('rotation_interval_days', models.IntegerField(default=0, help_text='Interval in days after which the recovery password will be automatically rotated.', validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(366)], verbose_name='Rotation interval (days)')), + ('rotate_firmware_password', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ('name',), + }, + ), + migrations.AddField( + model_name='enrolleddevice', + name='recovery_password', + field=models.TextField(null=True), + ), + migrations.AddField( + model_name='enrolleddevice', + name='recovery_password_updated_at', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='blueprint', + name='recovery_password_config', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='mdm.recoverypasswordconfig'), + ), + migrations.AlterModelOptions( + name='enrolleddevice', + options={'permissions': [('view_filevault_prk', 'Can view FileVault PRK'), ('view_recovery_password', 'Can view recovery password')]}, + ), + ] diff --git a/zentral/contrib/mdm/models.py b/zentral/contrib/mdm/models.py index a4bfff66ff..20d6d69cac 100644 --- a/zentral/contrib/mdm/models.py +++ b/zentral/contrib/mdm/models.py @@ -177,6 +177,77 @@ def serialize_for_event(self, keys_only=False): return d +# Recovery password + + +class RecoveryPasswordConfigManager(models.Manager): + def can_be_deleted(self): + return self.annotate(bp_count=Count("blueprint")).filter(bp_count=0) + + +class RecoveryPasswordConfig(models.Model): + name = models.CharField(max_length=256, unique=True) + dynamic_password = models.BooleanField(default=True) + static_password = models.TextField(null=True, editable=False) + rotation_interval_days = models.IntegerField( + verbose_name="Rotation interval (days)", + validators=[MinValueValidator(0), MaxValueValidator(366)], + default=0, + help_text="Interval in days after which the recovery password will be automatically rotated." + ) + rotate_firmware_password = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = FileVaultConfigManager() + + class Meta: + ordering = ("name",) + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse("mdm:recovery_password_config", args=(self.pk,)) + + def can_be_deleted(self): + return RecoveryPasswordConfig.objects.can_be_deleted().filter(pk=self.pk).exists() + + def _get_secret_engine_kwargs(self, field): + return { + "model": "mdm.recoverypasswordconfig", + "pk": self.pk, + "field": field + } + + def get_static_password(self): + if not self.static_password: + return + return decrypt_str(self.static_password, **self._get_secret_engine_kwargs("static_password")) + + def set_static_password(self, static_password): + if static_password is None: + self.static_password = None + return + self.static_password = encrypt_str(static_password, **self._get_secret_engine_kwargs("static_password")) + + def rewrap_secrets(self): + self.server_token = rewrap(self.static_password, **self._get_secret_engine_kwargs("static_password")) + + def serialize_for_event(self, keys_only=False): + d = {"pk": self.pk, "name": self.name} + if keys_only: + return d + d.update({ + "dynamic_password": self.dynamic_password, + "rotation_interval_days": self.rotation_interval_days, + "rotate_firmware_password": self.rotate_firmware_password, + "created_at": self.created_at, + "updated_at": self.updated_at, + }) + return d + + # Blueprint @@ -228,7 +299,11 @@ class InventoryItemCollectionOption(models.IntegerChoices): default=InventoryItemCollectionOption.NO ) # FileVault - filevault_config = models.ForeignKey(FileVaultConfig, null=True, blank=True, on_delete=models.SET_NULL) + filevault_config = models.ForeignKey(FileVaultConfig, null=True, blank=True, + on_delete=models.SET_NULL) + # Recovery password + recovery_password_config = models.ForeignKey(RecoveryPasswordConfig, null=True, blank=True, + on_delete=models.SET_NULL) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -274,6 +349,8 @@ def serialize_for_event(self, keys_only=False): }) if self.filevault_config: d["filevault_config"] = self.filevault_config.serialize_for_event(keys_only=True) + if self.recovery_password_config: + d["recovery_password_config"] = self.recovery_password_config.serialize_for_event(keys_only=True) return d def can_be_deleted(self): @@ -681,6 +758,10 @@ class EnrolledDevice(models.Model): filevault_prk = models.TextField(null=True) filevault_prk_updated_at = models.DateTimeField(null=True) + # Recovery password + recovery_password = models.TextField(null=True) + recovery_password_updated_at = models.DateTimeField(null=True) + # timestamps checkout_at = models.DateTimeField(blank=True, null=True) blocked_at = models.DateTimeField(blank=True, null=True) @@ -698,6 +779,7 @@ def get_absolute_url(self): class Meta: permissions = [ ("view_filevault_prk", "Can view FileVault PRK"), + ("view_recovery_password", "Can view recovery password"), ] # secrets @@ -753,6 +835,18 @@ def set_filevault_prk(self, filevault_prk): self.filevault_prk = encrypt_str(filevault_prk, **self._get_secret_engine_kwargs("filevault_prk")) self.filevault_prk_updated_at = datetime.utcnow() + def get_recovery_password(self): + if not self.recovery_password: + return + return decrypt_str(self.recovery_password, **self._get_secret_engine_kwargs("recovery_password")) + + def set_recovery_password(self, recovery_password): + if not recovery_password: + self.recovery_password = None + return + self.recovery_password = encrypt_str(recovery_password, **self._get_secret_engine_kwargs("recovery_password")) + self.recovery_password_updated_at = datetime.utcnow() + def rewrap_secrets(self): if self.bootstrap_token: self.bootstrap_token = rewrap(self.bootstrap_token, **self._get_secret_engine_kwargs("bootstrap_token")) @@ -763,6 +857,9 @@ def rewrap_secrets(self): **self._get_secret_engine_kwargs("filevault_escrow_key")) if self.filevault_prk: self.filevault_prk = rewrap(self.filevault_prk, **self._get_secret_engine_kwargs("filevault_prk")) + if self.recovery_password: + self.recovery_password = rewrap(self.recovery_password, + **self._get_secret_engine_kwargs("recovery_password")) def get_urlsafe_serial_number(self): if self.serial_number: diff --git a/zentral/contrib/mdm/serializers.py b/zentral/contrib/mdm/serializers.py index 8888c24372..b163abbd7b 100644 --- a/zentral/contrib/mdm/serializers.py +++ b/zentral/contrib/mdm/serializers.py @@ -6,7 +6,8 @@ from .models import (Artifact, ArtifactVersion, ArtifactVersionTag, Blueprint, BlueprintArtifact, BlueprintArtifactTag, FileVaultConfig, - Platform, Profile) + Platform, Profile, + RecoveryPasswordConfig) from .payloads import get_configuration_profile_info @@ -39,6 +40,43 @@ def validate(self, data): return data +class RecoveryPasswordConfigSerializer(serializers.ModelSerializer): + static_password = serializers.CharField(required=False, source="get_static_password") + + class Meta: + model = RecoveryPasswordConfig + fields = ("id", "name", + "dynamic_password", "static_password", + "rotation_interval_days", "rotate_firmware_password", + "created_at", "updated_at") + + def validate(self, data): + dynamic_password = data.get("dynamic_password", True) + static_password = data.get("get_static_password") + if dynamic_password: + if static_password: + raise serializers.ValidationError({"static_password": "Cannot be set when dynamic_password is true"}) + elif not static_password: + raise serializers.ValidationError({"static_password": "Required when dynamic_password is false"}) + return data + + def create(self, validated_data): + static_password = validated_data.pop("get_static_password", None) + instance = RecoveryPasswordConfig.objects.create(**validated_data) + if static_password: + instance.set_static_password(static_password) + instance.save() + return instance + + def update(self, instance, validated_data): + static_password = validated_data.pop("get_static_password", None) + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.set_static_password(static_password) + instance.save() + return instance + + class BlueprintSerializer(serializers.ModelSerializer): class Meta: model = Blueprint diff --git a/zentral/contrib/mdm/templates/mdm/blueprint_detail.html b/zentral/contrib/mdm/templates/mdm/blueprint_detail.html index 5c57df65aa..bbf1901c36 100644 --- a/zentral/contrib/mdm/templates/mdm/blueprint_detail.html +++ b/zentral/contrib/mdm/templates/mdm/blueprint_detail.html @@ -44,7 +44,7 @@

MDM blueprint {{ object }}

FileVault configuration {% if object.filevault_config %} - {% if perms.mdm.view_filevault_config %} + {% if perms.mdm.view_filevaultconfig %} {{ object.filevault_config }} @@ -54,6 +54,22 @@

MDM blueprint {{ object }}

{% else %} - {% endif %} + + + Recovery password configuration + + {% if object.recovery_password_config %} + {% if perms.mdm.view_recoverypasswordconfig %} + + {{ object.recovery_password_config }} + + {% else %} + {{ object.recovery_password_config }} + {% endif %} + {% else %} + - + {% endif %} + diff --git a/zentral/contrib/mdm/templates/mdm/enrolleddevice_detail.html b/zentral/contrib/mdm/templates/mdm/enrolleddevice_detail.html index 382806b5be..2b5e6c7fc5 100644 --- a/zentral/contrib/mdm/templates/mdm/enrolleddevice_detail.html +++ b/zentral/contrib/mdm/templates/mdm/enrolleddevice_detail.html @@ -237,8 +237,8 @@

Device {{ object }}

Personal recovery key {% if object.filevault_prk and perms.mdm.view_filevault_prk %} - - + + {% else %} {{ security_info.FDE_HasPersonalRecoveryKey|yesno }} {% endif %} @@ -250,6 +250,26 @@

Device {{ object }}

{% endif %} {% endwith %} + {% if object.platform == "macOS" %} + +

Recovery OS

+ + + {% if object.apple_silicon %}Recovery lock{% else %}Firmware password{% endif %} + + {% if object.recovery_password %} + {% if perms.mdm.view_recovery_password %} + + + {% else %} + Yes + {% endif %} + {% else %} + No + {% endif %} + + + {% endif %} {% with object.security_info.SecureBoot as secure_boot %} {% if secure_boot %} @@ -411,35 +431,36 @@

{{ enrollment_session_info_count }} Enrollment session{{ enrollment_session_ {% block extrajs %} diff --git a/zentral/contrib/mdm/templates/mdm/index.html b/zentral/contrib/mdm/templates/mdm/index.html index 6f837118ce..5d37fbe423 100644 --- a/zentral/contrib/mdm/templates/mdm/index.html +++ b/zentral/contrib/mdm/templates/mdm/index.html @@ -32,6 +32,9 @@

Manage devices

{% if perms.mdm.view_filevaultconfig %}

FileVault configurations

{% endif %} +{% if perms.mdm.view_recoverypasswordconfig %} +

Recovery password configurations

+{% endif %} {% if perms.mdm.view_scepconfig %}

SCEP configurations

{% endif %} diff --git a/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_confirm_delete.html b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_confirm_delete.html new file mode 100644 index 0000000000..9f2ed52f72 --- /dev/null +++ b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_confirm_delete.html @@ -0,0 +1,22 @@ +{% extends 'base.html' %} +{% load bootstrap %} + +{% block content %} + + +

Delete recovery password configuration {{ object }}

+ +
{% csrf_token %} +

Do you really want to delete this recovery password configuration?

+

+ Cancel + +

+
+{% endblock %} diff --git a/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_detail.html b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_detail.html new file mode 100644 index 0000000000..831e59d034 --- /dev/null +++ b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_detail.html @@ -0,0 +1,114 @@ +{% extends 'base.html' %} +{% load bootstrap %} + +{% block content %} + + +

{{ object }}

+ + + + + + + + + + + {% if not object.dynamic_password %} + + + + + {% endif %} + + + + + + + + + + + + +
Name{{ object.name }} +
Dynamic password? + {{ object.dynamic_password|yesno }} +
Static password + + +
Automatic rotation + {% if not object.rotation_interval_days %} + never + {% else %} + every {{ object.rotation_interval_days }} day{{ object.rotation_interval_days|pluralize }} + {% endif %} +
Rotate firmware password? + {{ object.rotate_firmware_password|yesno }} +
Blueprint{{ blueprint_count|pluralize }} ({{ blueprint_count }}) + {% if blueprint_count %} +
    + {% for blueprint in blueprints %} + {% if perms.mdm.view_blueprint %} +
  • {{ blueprint }}
  • + {% else %} +
  • {{ blueprint }}
  • + {% endif %} + {% endfor %} +
+ {% else %} + - + {% endif %} +
+ +
+
Created at
+
{{ object.created_at|date:'SHORT_DATETIME_FORMAT' }}
+
Updated at
+
{{ object.updated_at|date:'SHORT_DATETIME_FORMAT' }}
+
+ +

+ {% if perms.mdm.change_recoverypasswordconfig %} + + + Edit + + {% endif %} + {% if perms.mdm.delete_recoverypasswordconfig and object.can_be_deleted %} + + + Delete + + {% endif %} +

+{% endblock %} + +{% block extrajs %} + +{% endblock %} diff --git a/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_form.html b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_form.html new file mode 100644 index 0000000000..78a6e08fe9 --- /dev/null +++ b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_form.html @@ -0,0 +1,47 @@ +{% extends 'base.html' %} +{% load bootstrap %} + +{% block content %} + + +

{% if object %}Update recovery password configuration {{ object }}{% else %}Create recovery password configuration{% endif %}

+ +
{% csrf_token %} + {{ form|bootstrap }} +

+ {% if object %} + Cancel + {% else %} + Cancel + {% endif %} + +

+
+{% endblock %} + +{% block extrajs %} + +{% endblock %} diff --git a/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_list.html b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_list.html new file mode 100644 index 0000000000..ffa1f014dc --- /dev/null +++ b/zentral/contrib/mdm/templates/mdm/recoverypasswordconfig_list.html @@ -0,0 +1,83 @@ +{% extends 'base.html' %} +{% load bootstrap %} + +{% block content %} + + +

{{ page_obj.paginator.count }} Recovery password configuration{{ page_obj.paginator.count|pluralize }}

+ +{% if perms.mdm.add_recoverypasswordconfig %} +

+ + Create + +

+{% endif %} + +{% if next_url or previous_url %} + +{% endif %} + +{% if object_list %} + + + + + + + + + + + {% for recovery_password_config in object_list %} + + + + + + + {% endfor %} + +
Namecreated atupdated at
+ {{ recovery_password_config }} + {{ recovery_password_config.created_at|date:"SHORT_DATETIME_FORMAT" }}{{ recovery_password_config.updated_at|date:"SHORT_DATETIME_FORMAT" }} + {% if perms.mdm.change_recoverypasswordconfig %} + + + + {% endif %} + {% if perms.mdm.delete_recoverypasswordconfig and recovery_password_config.can_be_deleted %} + + + + {% endif %} +
+{% endif %} + +{% if next_url or previous_url %} + +{% endif %} +{% endblock %} diff --git a/zentral/contrib/mdm/urls.py b/zentral/contrib/mdm/urls.py index 310c982337..c8c859d67c 100644 --- a/zentral/contrib/mdm/urls.py +++ b/zentral/contrib/mdm/urls.py @@ -208,6 +208,23 @@ views.DeleteFileVaultConfigView.as_view(), name="delete_filevault_config"), + # Recovery password configurations + path('recovery_password_configurations/', + views.RecoveryPasswordConfigListView.as_view(), + name="recovery_password_configs"), + path('recovery_password_configurations/create/', + views.CreateRecoveryPasswordConfigView.as_view(), + name="create_recovery_password_config"), + path('recovery_password_configurations//', + views.RecoveryPasswordConfigView.as_view(), + name="recovery_password_config"), + path('recovery_password_configurations//update/', + views.UpdateRecoveryPasswordConfigView.as_view(), + name="update_recovery_password_config"), + path('recovery_password_configurations//delete/', + views.DeleteRecoveryPasswordConfigView.as_view(), + name="delete_recovery_password_config"), + # SCEP configurations path('scep_configurations/', views.SCEPConfigListView.as_view(), diff --git a/zentral/contrib/mdm/views/management.py b/zentral/contrib/mdm/views/management.py index 21c3a80f9b..7e183c5fc4 100644 --- a/zentral/contrib/mdm/views/management.py +++ b/zentral/contrib/mdm/views/management.py @@ -23,6 +23,7 @@ DEPDeviceSearchForm, EnrolledDeviceSearchForm, FileVaultConfigForm, OTAEnrollmentForm, + RecoveryPasswordConfigForm, SCEPConfigForm, UpdateArtifactForm, UserEnrollmentForm, @@ -36,6 +37,7 @@ EnrolledDevice, EnrolledUser, EnterpriseApp, FileVaultConfig, OTAEnrollment, + RecoveryPasswordConfig, SCEPConfig, UserEnrollment, Profile, StoreApp) @@ -932,7 +934,8 @@ class CreateBlueprintView(PermissionRequiredMixin, CreateViewWithAudit): "collect_apps", "collect_certificates", "collect_profiles", - "filevault_config",) + "filevault_config", + "recovery_password_config",) def form_valid(self, form): response = super().form_valid(form) @@ -965,7 +968,8 @@ class UpdateBlueprintView(PermissionRequiredMixin, UpdateViewWithAudit): "collect_apps", "collect_certificates", "collect_profiles", - "filevault_config",) + "filevault_config", + "recovery_password_config",) def form_valid(self, form): response = super().form_valid(form) @@ -1021,6 +1025,46 @@ def get_queryset(self): return FileVaultConfig.objects.can_be_deleted() +# Recovery password configurations + + +class RecoveryPasswordConfigListView(PermissionRequiredMixin, ListView): + permission_required = "mdm.view_recoverypasswordconfig" + model = RecoveryPasswordConfig + paginate_by = 20 + + +class CreateRecoveryPasswordConfigView(PermissionRequiredMixin, CreateViewWithAudit): + permission_required = "mdm.add_recoverypasswordconfig" + model = RecoveryPasswordConfig + form_class = RecoveryPasswordConfigForm + + +class RecoveryPasswordConfigView(PermissionRequiredMixin, DetailView): + permission_required = "mdm.view_recoverypasswordconfig" + model = RecoveryPasswordConfig + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["blueprints"] = self.object.blueprint_set.order_by("name") + ctx["blueprint_count"] = ctx["blueprints"].count() + return ctx + + +class UpdateRecoveryPasswordConfigView(PermissionRequiredMixin, UpdateViewWithAudit): + permission_required = "mdm.change_recoverypasswordconfig" + model = RecoveryPasswordConfig + form_class = RecoveryPasswordConfigForm + + +class DeleteRecoveryPasswordConfigView(PermissionRequiredMixin, DeleteViewWithAudit): + permission_required = "mdm.delete_recoverypasswordconfig" + success_url = reverse_lazy("mdm:recovery_password_configs") + + def get_queryset(self): + return RecoveryPasswordConfig.objects.can_be_deleted() + + # SCEP Configurations