Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add MDM Enterprise App API #808

Merged
merged 1 commit into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
700 changes: 700 additions & 0 deletions tests/mdm/test_api_enterprise_apps_views.py

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions tests/mdm/test_management_enterprise_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import operator
import plistlib
from django.contrib.auth.models import Group, Permission
from django.core.files import File
from django.core.files.uploadedfile import SimpleUploadedFile
from django.db.models import Q
from django.test import TestCase, override_settings
from django.urls import reverse
Expand Down Expand Up @@ -160,10 +160,10 @@ def test_upgrade_enterprise_app_get(self):
def test_upgrade_enterprise_app_post_same_package(self):
artifact, (enterprise_app_av,) = force_artifact(artifact_type=Artifact.Type.ENTERPRISE_APP)
package = self._build_package()
uploaded_package = SimpleUploadedFile(package.name, package.read())
_, product_id, product_version, manifest, bundles, _ = build_enterprise_app_manifest(uploaded_package)
enterprise_app = enterprise_app_av.enterprise_app
package_file = File(package, name=package.name)
_, product_id, product_version, manifest, bundles, _ = build_enterprise_app_manifest(package_file)
enterprise_app.package = package_file
enterprise_app.package = uploaded_package
enterprise_app.product_id = product_id
enterprise_app.product_version = product_version
enterprise_app.manifest = manifest
Expand Down
3 changes: 3 additions & 0 deletions zentral/contrib/mdm/api_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from .api_views import (ArtifactDetail, ArtifactList,
BlueprintDetail, BlueprintList,
BlueprintArtifactDetail, BlueprintArtifactList,
EnterpriseAppDetail, EnterpriseAppList,
FileVaultConfigDetail, FileVaultConfigList,
ProfileDetail, ProfileList,
RecoveryPasswordConfigList, RecoveryPasswordConfigDetail,
Expand All @@ -19,6 +20,8 @@
path('blueprints/<int:pk>/', BlueprintDetail.as_view(), name="blueprint"),
path('blueprint_artifacts/', BlueprintArtifactList.as_view(), name="blueprint_artifacts"),
path('blueprint_artifacts/<int:pk>/', BlueprintArtifactDetail.as_view(), name="blueprint_artifact"),
path('enterprise_apps/', EnterpriseAppList.as_view(), name="enterprise_apps"),
path('enterprise_apps/<uuid:artifact_version_pk>/', EnterpriseAppDetail.as_view(), name="enterprise_app"),
path('filevault_configs/', FileVaultConfigList.as_view(), name="filevault_configs"),
path('filevault_configs/<int:pk>/', FileVaultConfigDetail.as_view(), name="filevault_config"),
path('profiles/', ProfileList.as_view(), name="profiles"),
Expand Down
57 changes: 55 additions & 2 deletions zentral/contrib/mdm/api_views/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
from rest_framework.exceptions import ValidationError
from zentral.utils.drf import ListCreateAPIViewWithAudit, RetrieveUpdateDestroyAPIViewWithAudit
from zentral.contrib.mdm.artifacts import update_blueprint_serialized_artifacts
from zentral.contrib.mdm.models import Artifact, Profile
from zentral.contrib.mdm.serializers import ArtifactSerializer, ProfileSerializer
from zentral.contrib.mdm.models import Artifact, EnterpriseApp, Profile
from zentral.contrib.mdm.serializers import ArtifactSerializer, EnterpriseAppSerializer, ProfileSerializer


# artifacts


class ArtifactList(ListCreateAPIViewWithAudit):
Expand Down Expand Up @@ -38,6 +41,9 @@ def perform_destroy(self, instance):
return super().perform_destroy(instance)


# profiles


class ProfileList(ListCreateAPIViewWithAudit):
"""
List all Profiles or create a new Profile
Expand Down Expand Up @@ -80,3 +86,50 @@ def perform_destroy(self, instance):
for blueprint in instance.artifact_version.artifact.blueprints():
update_blueprint_serialized_artifacts(blueprint)
return response


# enterprise apps


class EnterpriseAppList(ListCreateAPIViewWithAudit):
"""
List all EnterpriseApps or create a new EnterpriseApp
"""
queryset = (EnterpriseApp.objects
.select_related("artifact_version__artifact")
.prefetch_related("artifact_version__excluded_tags",
"artifact_version__item_tags__tag__meta_business_unit",
"artifact_version__item_tags__tag__taxonomy"))
serializer_class = EnterpriseAppSerializer

@transaction.non_atomic_requests
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)


class EnterpriseAppDetail(RetrieveUpdateDestroyAPIViewWithAudit):
"""
Retrieve, update or delete a EnterpriseApp instance.
"""
queryset = (EnterpriseApp.objects
.select_related("artifact_version__artifact")
.prefetch_related("artifact_version__excluded_tags",
"artifact_version__item_tags__tag__meta_business_unit",
"artifact_version__item_tags__tag__taxonomy"))
serializer_class = EnterpriseAppSerializer
lookup_field = "artifact_version__pk"
lookup_url_kwarg = "artifact_version_pk"

@transaction.non_atomic_requests
def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)

def perform_destroy(self, instance):
with transaction.atomic(durable=True):
if not instance.artifact_version.can_be_deleted():
raise ValidationError('This enterprise app cannot be deleted')
response = super().perform_destroy(instance)
with transaction.atomic(durable=True):
for blueprint in instance.artifact_version.artifact.blueprints():
update_blueprint_serialized_artifacts(blueprint)
return response
90 changes: 79 additions & 11 deletions zentral/contrib/mdm/app_manifest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from hashlib import md5
from hashlib import md5, sha256
import logging
import os
import plistlib
import subprocess
import tempfile
from urllib.parse import urlparse
import zipfile
import boto3
from defusedxml.ElementTree import fromstring, ParseError
from django.core.files.uploadedfile import TemporaryUploadedFile, UploadedFile
from zentral.utils.aws import get_region as get_aws_region
from .models import Platform


Expand All @@ -15,15 +19,75 @@
MD5_SIZE = 10 * 2**20 # 10MB


def ensure_tmp_file(uploaded_file):
if hasattr(uploaded_file, "temporary_file_path"):
return uploaded_file.temporary_file_path(), False
tmp_fd, tmp_filepath = tempfile.mkstemp()
tmp_f = os.fdopen(tmp_fd, "wb")
for chunk in uploaded_file.chunks():
tmp_f.write(chunk)
tmp_f.close()
return tmp_filepath, True
def validate_configuration(configuration):
if configuration:
if configuration.startswith("<dict>"):
# to make it easier for the users
configuration = f'<plist version="1.0">{configuration}</plist>'
try:
loaded_configuration = plistlib.loads(configuration.encode("utf-8"))
except Exception:
raise ValueError("Invalid property list")
if not isinstance(loaded_configuration, dict):
raise ValueError("Not a dictionary")
return plistlib.dumps(loaded_configuration)
else:
return None


def download_s3_source(parse_source_uri, source_sha256):
bucket = parse_source_uri.netloc
key = parse_source_uri.path.lstrip("/")
_, ext = os.path.splitext(key)
if ext not in (".pkg", ".ipa"):
raise ValueError(f"Unsupported file extension: '{ext}'")
file = tempfile.NamedTemporaryFile(suffix=f".downloaded_s3_source{ext}", delete=False)
try:
s3_client = boto3.client('s3', region_name=get_aws_region())
s3_client.download_fileobj(bucket, key, file)
except Exception:
file.close()
os.unlink(file.name)
raise
return file


def download_source(source_uri, source_sha256):
parsed_source_uri = urlparse(source_uri)
if parsed_source_uri.scheme == "s3":
file = download_s3_source(parsed_source_uri, source_sha256)
else:
raise ValueError(f"Unknown source URI scheme: '{parsed_source_uri.scheme}'")
# verify hash
file.seek(0)
h = sha256()
while True:
chunk = file.read(2**10 * 64)
if not chunk:
break
h.update(chunk)
if h.hexdigest() != source_sha256:
raise ValueError("Hash mismatch")
file.seek(0)
return os.path.basename(parsed_source_uri.path), file


def ensure_tmp_file(file):
if isinstance(file, TemporaryUploadedFile):
return file.temporary_file_path(), False
elif isinstance(file, UploadedFile):
tmp_fd, tmp_filepath = tempfile.mkstemp()
tmp_f = os.fdopen(tmp_fd, "wb")
while True:
chunk = file.read(2**10 * 64)
if not chunk:
break
tmp_f.write(chunk)
tmp_f.close()
return tmp_filepath, True
elif hasattr(file, "name"):
return file.name, False
raise ValueError("Unsupported file type")


def read_distribution_info(tmp_filepath):
Expand Down Expand Up @@ -137,7 +201,11 @@ def get_md5s(package_file, md5_size=MD5_SIZE):
md5s = []
h = md5()
current_size = 0
for chunk in package_file.chunks(chunk_size=file_chunk_size):
package_file.seek(0)
while True:
chunk = package_file.read(2**10 * 64)
if not chunk:
break
h.update(chunk)
current_size += len(chunk)
if current_size == md5_size:
Expand Down
44 changes: 19 additions & 25 deletions zentral/contrib/mdm/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,14 @@
import hashlib
import json
import logging
import plistlib
from dateutil import parser
from django import forms
from django.db import IntegrityError, transaction
from django.db.models import Count, Q
from realms.utils import build_password_hash_dict
from zentral.contrib.inventory.models import Tag
from zentral.utils.os_version import make_comparable_os_version
from .app_manifest import build_enterprise_app_manifest
from .app_manifest import build_enterprise_app_manifest, validate_configuration
from .apps_books import AppsBooksClient
from .artifacts import update_blueprint_serialized_artifacts
from .commands.set_recovery_lock import validate_recovery_password
Expand Down Expand Up @@ -461,26 +460,16 @@ class AppConfigurationMixin(forms.Form):
)

def set_initial_config(self):
configuration = self.instance.get_configuration()
if configuration:
self.fields["configuration"].initial = plistlib.dumps(configuration).decode("utf-8")
configuration_plist = self.instance.get_configuration_plist()
if configuration_plist:
self.fields["configuration"].initial = configuration_plist

def clean_configuration(self):
configuration = self.cleaned_data.pop("configuration")
if configuration:
if configuration.startswith("<dict>"):
# to make it easier for the users
configuration = f'<plist version="1.0">{configuration}</plist>'
try:
loaded_configuration = plistlib.loads(configuration.encode("utf-8"))
except Exception:
raise forms.ValidationError("Invalid property list")
if not isinstance(loaded_configuration, dict):
raise forms.ValidationError("Not a dictionary")
configuration = plistlib.dumps(loaded_configuration)
else:
configuration = None
return configuration
try:
return validate_configuration(configuration)
except ValueError as e:
raise forms.ValidationError(str(e))


class BaseEnterpriseAppForm(forms.ModelForm, AppConfigurationMixin):
Expand All @@ -507,12 +496,12 @@ def clean(self):
title, product_id, product_version, manifest, bundles, platforms = build_enterprise_app_manifest(package)
except Exception as e:
raise forms.ValidationError(f"Invalid app: {e}")
self.instance.filename = package.name
self.instance.bundles = bundles
self.cleaned_data["name"] = title or product_id
self.cleaned_data["filename"] = package.name
self.cleaned_data["product_id"] = product_id
self.cleaned_data["product_version"] = product_version
self.cleaned_data["manifest"] = manifest
self.cleaned_data["bundles"] = bundles
self.cleaned_data["platforms"] = platforms
# management
install_as_managed = self.cleaned_data.get("install_as_managed")
Expand Down Expand Up @@ -568,6 +557,7 @@ def clean(self):
)
has_changed = False
for k in ("product_version",
"bundles",
"manifest",
"ios_app",
"configuration",
Expand All @@ -586,10 +576,14 @@ def clean(self):
def save(self, artifact_version):
self.instance.id = None # force insert
self.instance.artifact_version = artifact_version
self.instance.product_id = self.cleaned_data["product_id"]
self.instance.product_version = self.cleaned_data["product_version"]
self.instance.manifest = self.cleaned_data["manifest"]
self.instance.configuration = self.cleaned_data["configuration"]
# save non-field attributes (configuration is not editable, so not a standard "field")
for attr in ("configuration",
"filename",
"product_id",
"product_version",
"bundles",
"manifest"):
setattr(self.instance, attr, self.cleaned_data[attr])
return super().save()


Expand Down
31 changes: 31 additions & 0 deletions zentral/contrib/mdm/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2364,9 +2364,35 @@ def get_configuration(self):
if self.configuration:
return plistlib.loads(self.configuration)

def get_configuration_plist(self):
configuration = self.get_configuration()
if configuration:
return plistlib.dumps(configuration).decode("utf-8")

def has_configuration(self):
return self.configuration is not None

def serialize_for_event(self):
d = self.artifact_version.serialize_for_event()
d.update({
"filename": self.filename,
"product_id": self.product_id,
"product_version": self.product_version,
"bundles": self.bundles,
"manifest": self.manifest,
"ios_app": self.ios_app,
"install_as_managed": self.install_as_managed,
"remove_on_unenroll": self.remove_on_unenroll,
})
configuration_plist = self.get_configuration_plist()
if configuration_plist:
d["configuration"] = configuration_plist
return d

def delete(self, *args, **kwargs):
self.artifact_version.delete(*args, **kwargs)
super().delete(*args, **kwargs)


@receiver(post_delete, sender=EnterpriseApp)
def post_delete_enterprise_app(sender, instance, *args, **kwargs):
Expand Down Expand Up @@ -2407,6 +2433,11 @@ def get_configuration(self):
if self.configuration:
return plistlib.loads(self.configuration)

def get_configuration_plist(self):
configuration = self.get_configuration()
if configuration:
return plistlib.dumps(configuration).decode("utf-8")

def has_configuration(self):
return self.configuration is not None

Expand Down
Loading