Repository: cmdmnt/commandment Branch: master Commit: 17c1dbe3f530 Files: 573 Total size: 1.5 MB Directory structure: gitextract_jp6y57tz/ ├── .circleci/ │ └── config.yml ├── .docker/ │ ├── Dockerfile │ ├── entry.sh │ ├── nginx.conf │ ├── openssl.cnf │ ├── settings.cfg.docker │ ├── supervisord.conf │ ├── uwsgi-commandment.ini │ └── uwsgi.ini ├── .dockerignore ├── .gitignore ├── .gitlab-ci.yml ├── LICENSE.txt ├── Pipfile ├── README.rst ├── alembic.ini ├── assets/ │ └── logo.afdesign ├── commandment/ │ ├── __init__.py │ ├── ac2/ │ │ ├── __init__.py │ │ └── ac2_app.py │ ├── alembic/ │ │ ├── __init__.py │ │ ├── disabled_versions/ │ │ │ ├── 072fba4a2256_create_ad_payload_table.py │ │ │ ├── 18412434fb57_create_energy_saver_payload_table.py │ │ │ ├── 323a90039a6a_create_email_payload_table.py │ │ │ ├── 4eddbcb30464_create_mdm_payload_table.py │ │ │ ├── 8186b8ecf0fc_create_ad_cert_payload_table.py │ │ │ ├── 9dd4e48235e3_create_vpn_payload_table.py │ │ │ ├── d65049bf4b91_create_wifi_payload_table.py │ │ │ ├── da52b64b865f_create_apps_table.py │ │ │ ├── e47e29a9537c_create_certificate_payload_table.py │ │ │ └── fc0c134cbb2e_create_password_policy_payload_table.py │ │ ├── env.py │ │ ├── script.py.mako │ │ └── versions/ │ │ ├── 0201b96ab856_add_ios_available_os_updates_fields.py │ │ ├── 0ab46b2f6d8c_create_users_table.py │ │ ├── 0c4c448f4daf_create_device_users_table.py │ │ ├── 0e5babc5b9ee_create_vpp_licenses.py │ │ ├── 1005dc7dea01_os_update_settings.py │ │ ├── 13358fb3846b_create_subject_alternative_names_table.py │ │ ├── 1532dff16984_drop_device_groups.py │ │ ├── 2808deb9fc62_create_dep_configurations.py │ │ ├── 2f1507bf6dc1_create_application_manifests_table.py │ │ ├── 3061e56045eb_create_certificate_authority.py │ │ ├── 3dbf6db7f9eb_application_tags.py │ │ ├── 3fb4a904979c_general_cleanup.py │ │ ├── 50188ffaf0cd_create_devices_table.py │ │ ├── 5b98cc4af6c9_create_profiles_table.py │ │ ├── 6675e981817e_create_available_os_updates_table.py │ │ ├── 70ff84113e8f_create_tags.py │ │ ├── 71818e983100_create_application_sources_table.py │ │ ├── 71ecf957301a_create_commands_table.py │ │ ├── 7ab500f58a76_create_installed_payloads.py │ │ ├── 7cf5787a089e_add_dep_profile_relationships.py │ │ ├── 7d578eb75092_create_device_groups_table.py │ │ ├── 80fa1767c7e2_create_oauth_server_models.py │ │ ├── 875dcce0bf8b_create_vpp_users.py │ │ ├── 8c866896f76e_create_dep_join_tables.py │ │ ├── __init__.py │ │ ├── a1d5ffaa2092_create_installed_applications_table.py │ │ ├── a2e0af380181_create_dep_profiles.py │ │ ├── a35eeb5a216e_create_installed_profiles_table.py │ │ ├── a3ddaad5c358_add_dep_device_columns.py │ │ ├── af4ba256efde_create_certificates_table.py │ │ ├── b231394ab475_add_scep_config_source_types.py │ │ ├── b74ca08cfd9a_create_applications_tables.py │ │ ├── ba4849d8c8ad_create_device_group_devices_table.py │ │ ├── d5b32b5cc74e_add_dep_profile_id_to_device.py │ │ ├── dd74229d17b9_create_payload_dependencies_table.py │ │ ├── e16577adc4fd_create_installed_certificates_table.py │ │ ├── e5840df9a88a_create_scep_payload_table.py │ │ ├── e58afdc17baa_create_rsa_private_keys_table.py │ │ ├── e78274be170e_create_organizations_table.py │ │ ├── e947cdf82307_add_ios_installed_application_fields.py │ │ ├── e9b0a4f7b595_create_payloads_table.py │ │ ├── ea34ae3f1e7e_create_profile_payloads_table.py │ │ ├── f029ac1af3f0_create_vpp_accounts.py │ │ ├── f5237c7e2374_create_scep_config_table.py │ │ ├── f8eb70b3aa2b_create_application_manifests.py │ │ └── fa4d91c6aacf_create_managed_applications_table.py │ ├── api/ │ │ ├── __init__.py │ │ ├── app_json.py │ │ ├── app_jsonapi.py │ │ ├── configuration.py │ │ ├── resources.py │ │ └── schema.py │ ├── apns/ │ │ ├── __init__.py │ │ ├── app.py │ │ ├── mdmcert.py │ │ ├── push.py │ │ ├── schema.py │ │ └── threads.py │ ├── app.py │ ├── apps/ │ │ ├── __init__.py │ │ ├── app_jsonapi.py │ │ ├── models.py │ │ ├── resources.py │ │ └── schema.py │ ├── auth/ │ │ ├── __init__.py │ │ ├── app.py │ │ ├── models.py │ │ └── oauth2.py │ ├── cli.py │ ├── cms/ │ │ ├── __init__.py │ │ └── decorators.py │ ├── dbtypes.py │ ├── decorators.py │ ├── default_settings.py │ ├── dep/ │ │ ├── __init__.py │ │ ├── app.py │ │ ├── apple_schema.py │ │ ├── cli.py │ │ ├── dep.py │ │ ├── errors.py │ │ ├── models.py │ │ ├── resources.py │ │ ├── schema.py │ │ ├── smime.py │ │ └── threads.py │ ├── deprecated/ │ │ ├── models.py │ │ └── schema.py │ ├── enroll/ │ │ ├── __init__.py │ │ ├── app.py │ │ ├── profiles.py │ │ └── util.py │ ├── errors.py │ ├── inventory/ │ │ ├── __init__.py │ │ ├── api.py │ │ ├── models.py │ │ ├── resources.py │ │ └── schema.py │ ├── mdm/ │ │ ├── __init__.py │ │ ├── api.py │ │ ├── app.py │ │ ├── commands.py │ │ ├── decorators.py │ │ ├── handlers.py │ │ ├── models.py │ │ ├── resources.py │ │ ├── response_schema.py │ │ ├── routers.py │ │ ├── schema.py │ │ └── util.py │ ├── models.py │ ├── mutablelist.py │ ├── omdm/ │ │ ├── __init__.py │ │ └── models.py │ ├── pkg/ │ │ ├── __init__.py │ │ ├── appmanifest.py │ │ ├── manifest.py │ │ ├── old_app_manifest.py │ │ └── schema.py │ ├── pki/ │ │ ├── ca.py │ │ ├── models.py │ │ ├── openssl.py │ │ ├── ormutils.py │ │ ├── serialization.py │ │ └── ssl.py │ ├── plistutil/ │ │ ├── __init__.py │ │ └── nonewriter.py │ ├── profiles/ │ │ ├── __init__.py │ │ ├── ad.py │ │ ├── api.py │ │ ├── certificates.py │ │ ├── eap.py │ │ ├── email.py │ │ ├── energy.py │ │ ├── models.py │ │ ├── plist_schema.py │ │ ├── resources.py │ │ ├── schema.py │ │ ├── vpn.py │ │ └── wifi.py │ ├── signals.py │ ├── static/ │ │ ├── .gitignore │ │ ├── index.dev.html │ │ └── index.html │ ├── storage/ │ │ └── .gitignore │ ├── templates/ │ │ └── index.html │ ├── threads/ │ │ ├── __init__.py │ │ ├── startup_thread.py │ │ └── vpp_thread.py │ ├── utils.py │ └── vpp/ │ ├── __init__.py │ ├── app.py │ ├── cli.py │ ├── decorators.py │ ├── enum.py │ ├── errors.py │ ├── models.py │ ├── schema.py │ └── vpp.py ├── doc/ │ ├── .gitignore │ ├── Makefile │ ├── _static/ │ │ ├── config/ │ │ │ ├── nginx-commandment.conf │ │ │ └── uwsgi-commandment.ini │ │ └── uml/ │ │ ├── checkin.puml │ │ ├── commandqueue.puml │ │ └── models/ │ │ ├── Certificate.plantuml │ │ ├── Command.plantuml │ │ ├── InstalledApplication.plantuml │ │ ├── InstalledCertificate.plantuml │ │ └── InstalledProfile.plantuml │ ├── about-mdm.rst │ ├── api/ │ │ ├── certificates.rst │ │ ├── commands.rst │ │ ├── dep.rst │ │ ├── devices.rst │ │ ├── index.rst │ │ └── organization.rst │ ├── conf.py │ ├── dev/ │ │ └── MUSINGS.rst │ ├── developer/ │ │ ├── guide/ │ │ │ ├── architecture.rst │ │ │ ├── building.rst │ │ │ ├── index.rst │ │ │ └── running.rst │ │ ├── index.rst │ │ └── microservices.rst │ ├── guides/ │ │ ├── INSTALL.md │ │ ├── nginx.rst │ │ └── scep.rst │ ├── index.rst │ ├── installing/ │ │ ├── index.rst │ │ ├── install.rst │ │ ├── macos.rst │ │ └── ubuntu-server.rst │ ├── internal/ │ │ ├── api/ │ │ │ ├── api.rst │ │ │ ├── index.rst │ │ │ └── json-api.rst │ │ ├── cms/ │ │ │ ├── decorators.rst │ │ │ └── index.rst │ │ ├── core/ │ │ │ ├── index.rst │ │ │ ├── models/ │ │ │ │ ├── certificate.rst │ │ │ │ ├── certificate_request.rst │ │ │ │ ├── command.rst │ │ │ │ ├── device.rst │ │ │ │ ├── index.rst │ │ │ │ ├── installed_application.rst │ │ │ │ ├── installed_certificate.rst │ │ │ │ ├── installed_profile.rst │ │ │ │ ├── organization.rst │ │ │ │ ├── profile.rst │ │ │ │ └── rsa_private_key.rst │ │ │ └── signals.rst │ │ ├── decorators.rst │ │ ├── dep/ │ │ │ ├── dep.rst │ │ │ ├── index.rst │ │ │ ├── models.rst │ │ │ └── types.rst │ │ ├── enroll/ │ │ │ ├── app.rst │ │ │ └── index.rst │ │ ├── flask/ │ │ │ ├── configuration.rst │ │ │ └── index.rst │ │ ├── index.rst │ │ ├── mdm/ │ │ │ ├── app.rst │ │ │ ├── handlers.rst │ │ │ ├── index.rst │ │ │ └── types.rst │ │ ├── push.rst │ │ ├── vpp/ │ │ │ ├── decorators.rst │ │ │ ├── enum.rst │ │ │ ├── errors.rst │ │ │ ├── index.rst │ │ │ ├── operations.rst │ │ │ └── vpp.rst │ │ └── workers/ │ │ ├── index.rst │ │ └── runner.rst │ ├── make.bat │ ├── sadisplay/ │ │ └── models.py │ └── user/ │ ├── configuration.rst │ ├── dep.rst │ └── index.rst ├── docker-compose.yml ├── mypy.ini ├── pytest.ini ├── settings.cfg.example ├── setup.cfg ├── setup.py ├── testdata/ │ ├── Authenticate/ │ │ ├── 10.11.x.xml │ │ ├── 10.12.2.xml │ │ ├── IOS-11.3.1.xml │ │ ├── IOS-9.x.xml │ │ └── iOS-11.3.1-cell.xml │ ├── AvailableOSUpdates/ │ │ ├── 10.12.5.xml │ │ ├── iOS-11.3.1.xml │ │ └── macOS-10.13.1.xml │ ├── CertificateList/ │ │ ├── 10.11.x.xml │ │ └── iOS-11.3.1.xml │ ├── CheckOut/ │ │ ├── 10.11.x.xml │ │ └── iOS-11.3.1.xml │ ├── DeviceInformation/ │ │ ├── 10.11.x.xml │ │ ├── iOS-11.3.1.xml │ │ └── macOS-10.13.1.xml │ ├── DeviceLock/ │ │ └── iOS-11.3.1.xml │ ├── Errors/ │ │ ├── 10.12.5-invalid-command.xml │ │ ├── 10.13.6-invalid-command.xml │ │ ├── error_invalid_request_type.plist │ │ ├── iOS-11.3.1-AvailableOSUpdatesFailure.xml │ │ ├── iOS-11.3.1-CommandFormatError.xml │ │ └── iOS-11.3.1-RemoveProfile-Unmanaged.xml │ ├── InstallApplication/ │ │ ├── iOS-11.3.1-alreadyprompting.xml │ │ ├── iOS-12.1-prompting.xml │ │ └── manifests/ │ │ ├── Microsoft_AutoUpdate-3.11.17101000.plist │ │ ├── OneDrive-17.3.7078.1101.plist │ │ ├── SkypeForBusinessInstaller-16.12.0.77.plist │ │ ├── dotnet-sdk-2.0.2-osx-x64.plist │ │ └── munkitools-3.1.0.3430.plist │ ├── InstalledApplicationList/ │ │ ├── 10.11.x.xml │ │ ├── iOS-11.3.1.xml │ │ └── iOS-12.1.xml │ ├── ManagedApplicationList/ │ │ ├── iOS-11.3.1-Failed.xml │ │ ├── iOS-12.1-Failed.xml │ │ ├── iOS-12.1-Installing.xml │ │ ├── iOS-12.1-Managed.xml │ │ └── iOS-12.1-RejectedPrompting.xml │ ├── NotNow/ │ │ └── iOS-11.3.1.xml │ ├── ProfileList/ │ │ ├── 10.11.x.xml │ │ └── iOS-11.3.1.xml │ ├── README.rst │ ├── SecurityInfo/ │ │ ├── 10.11.x.xml │ │ ├── IOS-9.x.xml │ │ ├── iOS-11.3.1.xml │ │ └── macOS-10.13.1.xml │ ├── TokenUpdate/ │ │ ├── 10.11.x-user.plist │ │ ├── 10.11.x.plist │ │ ├── 10.12.2-user.xml │ │ ├── 10.12.2.xml │ │ └── iOS-11.3.1.xml │ ├── decrypt_dep_token.sh │ ├── dep/ │ │ └── profile.xml │ ├── itunes/ │ │ ├── ios-search-slack.json │ │ └── mas-search-slack.json │ └── mdmclient-PKIOperation.der ├── tests/ │ ├── __init__.py │ ├── alembic_test.ini │ ├── api/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ └── test_devices.py │ ├── client.py │ ├── conftest.py │ ├── dep/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_dep.py │ │ ├── test_dep_app.py │ │ ├── test_dep_failures.py │ │ ├── test_dep_live.py │ │ └── test_smime.py │ ├── mdm/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_available_os_updates.py │ │ ├── test_certificate_list.py │ │ ├── test_checkin.py │ │ ├── test_device_information.py │ │ ├── test_installed_application_list.py │ │ ├── test_profile_list.py │ │ └── test_security_info.py │ ├── pkg/ │ │ └── __init__.py │ ├── pki/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── test_ca.py │ │ ├── test_models.py │ │ ├── test_openssl.py │ │ └── test_ormutils.py │ ├── test_api_flat.py │ ├── test_mdmcert.py │ ├── threads/ │ │ ├── __init__.py │ │ └── test_startup_thread.py │ └── vpp/ │ ├── __init__.py │ ├── conftest.py │ └── vpp_test.py ├── travis-ci-settings.cfg └── ui/ ├── .eslintrc.js ├── .gitignore ├── .storybook/ │ ├── config.js │ ├── preview-head.html │ └── webpack.config.js ├── _deprecated/ │ ├── AssistantPage.tsx │ ├── DeviceGroupPage.tsx │ ├── DeviceGroupsPage.tsx │ ├── InternalCAPage.tsx │ ├── MDMPage.tsx │ ├── SCEPConfigurationForm.tsx │ ├── SSLPage.tsx │ └── assistant/ │ ├── APNSConfiguration.tsx │ ├── FinalStep.tsx │ ├── SCEPConfiguration.tsx │ └── SSLConfiguration.tsx ├── babel.config.js ├── package.json ├── sass/ │ ├── _dropzone.scss │ ├── _helper.scss │ ├── _nav.scss │ ├── _settings.scss │ ├── _upload.scss │ └── app.scss ├── src/ │ ├── @types/ │ │ ├── byte-size/ │ │ │ └── index.d.ts │ │ └── redux-api-middleware/ │ │ └── index.d.ts │ ├── components/ │ │ ├── ActionMenu.tsx │ │ ├── App.tsx │ │ ├── BareLayout.tsx │ │ ├── CertificateTypeIcon.tsx │ │ ├── CheckListItem.tsx │ │ ├── DeviceActions.tsx │ │ ├── Navigation.scss │ │ ├── Navigation.tsx │ │ ├── NavigationLayout.tsx │ │ ├── NavigationVertical.tsx │ │ ├── ProtectedRoute.tsx │ │ ├── RSAAApiErrorMessage.tsx │ │ ├── SearchInput.tsx │ │ ├── TagDropdown.tsx │ │ ├── devices/ │ │ │ ├── DEPDeviceDetail.tsx │ │ │ ├── IOSDeviceDetail.tsx │ │ │ ├── MacOSDeviceDetail.scss │ │ │ ├── MacOSDeviceDetail.tsx │ │ │ └── ModelIcon.tsx │ │ ├── errors/ │ │ │ └── ApiError.tsx │ │ ├── formik/ │ │ │ └── FormikCheckbox.tsx │ │ ├── forms/ │ │ │ ├── DEPAccountForm.tsx │ │ │ ├── DEPProfileForm.tsx │ │ │ ├── DeviceAuthForm.tsx │ │ │ └── OrganizationForm.tsx │ │ ├── itunes/ │ │ │ └── MASResult.tsx │ │ ├── modals/ │ │ │ ├── DeviceRenameModal.tsx │ │ │ └── ProfileUploadModal.tsx │ │ ├── react-table/ │ │ │ ├── AppName.tsx │ │ │ ├── ApplicationType.tsx │ │ │ ├── ByteSize.tsx │ │ │ ├── CommandStatus.tsx │ │ │ ├── DEPAccountServerName.tsx │ │ │ ├── DEPProfileName.tsx │ │ │ ├── DeviceName.tsx │ │ │ ├── ObjectLink.tsx │ │ │ ├── ProfileName.tsx │ │ │ ├── RelativeToNow.tsx │ │ │ └── SUISelectionTools.tsx │ │ ├── react-tables/ │ │ │ ├── AppDeployStatusTable.tsx │ │ │ ├── ApplicationsTable.tsx │ │ │ ├── DEPAccountsTable.tsx │ │ │ ├── DEPProfilesTable.tsx │ │ │ ├── DeviceApplicationsTable.tsx │ │ │ ├── DeviceCertificatesTable.tsx │ │ │ ├── DeviceCommandsTable.tsx │ │ │ ├── DeviceProfilesTable.tsx │ │ │ ├── DeviceUpdatesTable.tsx │ │ │ ├── DevicesTable.tsx │ │ │ └── ProfilesTable.tsx │ │ ├── semantic-ui/ │ │ │ ├── ButtonLink.tsx │ │ │ └── MenuItemLink.tsx │ │ └── vpp/ │ │ └── VPPAccountDetail.tsx │ ├── constants.ts │ ├── containers/ │ │ ├── AppStorePage.tsx │ │ ├── ApplicationPage.tsx │ │ ├── ApplicationsPage.tsx │ │ ├── DEPAccountPage.tsx │ │ ├── DEPProfilePage.tsx │ │ ├── DashboardPage.tsx │ │ ├── DevicePage.tsx │ │ ├── DeviceRename.tsx │ │ ├── DevicesPage.tsx │ │ ├── LoginPage.tsx │ │ ├── LogoutPage.tsx │ │ ├── ProfilePage.tsx │ │ ├── ProfileUpload.tsx │ │ ├── ProfilesPage.tsx │ │ ├── SettingsPage.tsx │ │ ├── applications/ │ │ │ ├── ApplicationDeviceStatus.tsx │ │ │ └── MacOSEntApplicationPage.tsx │ │ ├── config/ │ │ │ ├── DeviceAuthPage.tsx │ │ │ └── OrganizationPage.tsx │ │ ├── devices/ │ │ │ ├── DeviceApplications.tsx │ │ │ ├── DeviceCertificates.tsx │ │ │ ├── DeviceCommands.tsx │ │ │ ├── DeviceDetail.tsx │ │ │ ├── DeviceOSUpdates.tsx │ │ │ └── DeviceProfiles.tsx │ │ └── settings/ │ │ ├── APNSPage.tsx │ │ ├── DEPAccountSetupPage.tsx │ │ ├── DEPAccountsPage.tsx │ │ └── VPPAccountsPage.tsx │ ├── entry.tsx │ ├── flask-rest-jsonapi.ts │ ├── forms/ │ │ ├── ApplicationForm.tsx │ │ └── DeviceGroupForm.tsx │ ├── guards.ts │ ├── hooks/ │ │ └── useForm.ts │ ├── json-api-v1.ts │ ├── models.ts │ ├── reducers/ │ │ ├── index.ts │ │ └── interfaces.ts │ ├── selectors/ │ │ └── device.ts │ ├── store/ │ │ ├── applications/ │ │ │ ├── actions.ts │ │ │ ├── itunes.ts │ │ │ ├── list_reducer.ts │ │ │ ├── managed.ts │ │ │ ├── managed_reducer.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ ├── assistant/ │ │ │ ├── actions.ts │ │ │ └── reducer.ts │ │ ├── auth/ │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ ├── certificates/ │ │ │ ├── actions.ts │ │ │ ├── ca_actions.ts │ │ │ ├── ca_reducer.ts │ │ │ ├── push_actions.ts │ │ │ ├── push_reducer.ts │ │ │ ├── reducer.ts │ │ │ ├── ssl_actions.ts │ │ │ ├── ssl_reducer.ts │ │ │ └── types.ts │ │ ├── commands/ │ │ │ ├── actions.ts │ │ │ └── reducer.ts │ │ ├── configuration/ │ │ │ ├── apns_reducer.ts │ │ │ ├── mdmcert_actions.ts │ │ │ ├── reducer.ts │ │ │ ├── scep_actions.ts │ │ │ ├── scep_reducer.ts │ │ │ ├── types.ts │ │ │ ├── vpp.ts │ │ │ └── vpp_reducer.ts │ │ ├── configureStore.ts │ │ ├── constants.ts │ │ ├── dep/ │ │ │ ├── account_reducer.ts │ │ │ ├── accounts_reducer.ts │ │ │ ├── actions.ts │ │ │ ├── profile_reducer.ts │ │ │ ├── profiles_reducer.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ ├── device/ │ │ │ ├── actions.ts │ │ │ ├── applications.ts │ │ │ ├── available_os_updates_reducer.ts │ │ │ ├── certificates.ts │ │ │ ├── commands_reducer.ts │ │ │ ├── installed_applications_reducer.ts │ │ │ ├── installed_certificates_reducer.ts │ │ │ ├── installed_profiles_reducer.ts │ │ │ ├── profiles.ts │ │ │ ├── reducer.ts │ │ │ ├── types.ts │ │ │ └── updates.ts │ │ ├── device_groups/ │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ ├── devices/ │ │ │ ├── actions.ts │ │ │ └── devices.ts │ │ ├── json-api.ts │ │ ├── mdm.ts │ │ ├── organization/ │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ ├── pki/ │ │ │ ├── actions.ts │ │ │ └── types.ts │ │ ├── profile/ │ │ │ └── reducer.ts │ │ ├── profiles/ │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ ├── redux-api-middleware.ts │ │ ├── table/ │ │ │ ├── actions.ts │ │ │ ├── reducer.ts │ │ │ └── types.ts │ │ └── tags/ │ │ ├── actions.ts │ │ ├── reducer.ts │ │ └── types.ts │ └── stories/ │ ├── DEPProfileForm.tsx │ ├── index.ts │ └── redux.tsx ├── tsconfig.json ├── tslint.json ├── webpack.config.hmr.js ├── webpack.config.js └── webpack.config.prod.js ================================================ FILE CONTENTS ================================================ ================================================ FILE: .circleci/config.yml ================================================ version: 2 jobs: build: docker: - image: circleci/ruby:2.4.1 steps: - checkout - run: echo "A first hello" ================================================ FILE: .docker/Dockerfile ================================================ FROM python:3.6 # Adapted from tiangolo-uwsgi-flask (https://github.com/tiangolo/uwsgi-nginx-flask-docker) or # (https://github.com/tiangolo/uwsgi-nginx-docker/blob/master/python3.6/Dockerfile) ENV NGINX_VERSION 1.13.12-1~stretch ENV NJS_VERSION 1.13.12.0.2.0-1~stretch RUN set -x \ && apt-get update \ && apt-get install --no-install-recommends --no-install-suggests -y gnupg1 apt-transport-https ca-certificates \ && \ NGINX_GPGKEY=573BFD6B3D8FBC641079A6ABABF5BD827BD9BF62; \ found=''; \ for server in \ ha.pool.sks-keyservers.net \ hkp://keyserver.ubuntu.com:80 \ hkp://p80.pool.sks-keyservers.net:80 \ pgp.mit.edu \ ; do \ echo "Fetching GPG key $NGINX_GPGKEY from $server"; \ apt-key adv --keyserver "$server" --keyserver-options timeout=10 --recv-keys "$NGINX_GPGKEY" && found=yes && break; \ done; \ test -z "$found" && echo >&2 "error: failed to fetch GPG key $NGINX_GPGKEY" && exit 1; \ apt-get remove --purge --auto-remove -y gnupg1 && rm -rf /var/lib/apt/lists/* \ && dpkgArch="$(dpkg --print-architecture)" \ && nginxPackages=" \ nginx=${NGINX_VERSION} \ " \ && case "$dpkgArch" in \ amd64|i386) \ # arches officialy built by upstream echo "deb https://nginx.org/packages/mainline/debian/ stretch nginx" >> /etc/apt/sources.list.d/nginx.list \ && apt-get update \ ;; \ *) \ # we're on an architecture upstream doesn't officially build for # let's build binaries from the published source packages echo "deb-src https://nginx.org/packages/mainline/debian/ stretch nginx" >> /etc/apt/sources.list.d/nginx.list \ \ # new directory for storing sources and .deb files && tempDir="$(mktemp -d)" \ && chmod 777 "$tempDir" \ # (777 to ensure APT's "_apt" user can access it too) \ # save list of currently-installed packages so build dependencies can be cleanly removed later && savedAptMark="$(apt-mark showmanual)" \ \ # build .deb files from upstream's source packages (which are verified by apt-get) && apt-get update \ && apt-get build-dep -y $nginxPackages \ && ( \ cd "$tempDir" \ && DEB_BUILD_OPTIONS="nocheck parallel=$(nproc)" \ apt-get source --compile $nginxPackages \ ) \ # we don't remove APT lists here because they get re-downloaded and removed later \ # reset apt-mark's "manual" list so that "purge --auto-remove" will remove all build dependencies # (which is done after we install the built packages so we don't have to redownload any overlapping dependencies) && apt-mark showmanual | xargs apt-mark auto > /dev/null \ && { [ -z "$savedAptMark" ] || apt-mark manual $savedAptMark; } \ \ # create a temporary local APT repo to install from (so that dependency resolution can be handled by APT, as it should be) && ls -lAFh "$tempDir" \ && ( cd "$tempDir" && dpkg-scanpackages . > Packages ) \ && grep '^Package: ' "$tempDir/Packages" \ && echo "deb [ trusted=yes ] file://$tempDir ./" > /etc/apt/sources.list.d/temp.list \ # work around the following APT issue by using "Acquire::GzipIndexes=false" (overriding "/etc/apt/apt.conf.d/docker-gzip-indexes") # Could not open file /var/lib/apt/lists/partial/_tmp_tmp.ODWljpQfkE_._Packages - open (13: Permission denied) # ... # E: Failed to fetch store:/var/lib/apt/lists/partial/_tmp_tmp.ODWljpQfkE_._Packages Could not open file /var/lib/apt/lists/partial/_tmp_tmp.ODWljpQfkE_._Packages - open (13: Permission denied) && apt-get -o Acquire::GzipIndexes=false update \ ;; \ esac \ \ && apt-get install --no-install-recommends --no-install-suggests -y \ $nginxPackages \ gettext-base \ && rm -rf /var/lib/apt/lists/* /etc/apt/sources.list.d/nginx.list \ \ # if we have leftovers from building, let's purge them (including extra, unnecessary build deps) && if [ -n "$tempDir" ]; then \ apt-get purge -y --auto-remove \ && rm -rf "$tempDir" /etc/apt/sources.list.d/temp.list; \ fi # forward request and error logs to docker log collector RUN ln -sf /dev/stdout /var/log/nginx/access.log \ && ln -sf /dev/stderr /var/log/nginx/error.log EXPOSE 80 443 # Standard set up Nginx finished # Install uWSGI RUN pip install uwsgi # Make NGINX run on the foreground RUN echo "daemon off;" >> /etc/nginx/nginx.conf # Remove default configuration from Nginx RUN rm /etc/nginx/conf.d/default.conf # Copy the modified Nginx conf COPY .docker/nginx.conf /etc/nginx/conf.d/ # Copy the base uWSGI ini file to enable default dynamic uwsgi process number COPY .docker/uwsgi.ini /etc/uwsgi/ # Install Supervisord RUN apt-get update && apt-get install -y supervisor sqlite3 libsqlite3-dev uwsgi-plugin-python3 \ && rm -rf /var/lib/apt/lists/* # Custom Supervisord config COPY .docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf # Which uWSGI .ini file should be used, to make it customizable # ENV UWSGI_INI /commandment/uwsgi.ini # By default, run 2 processes ENV UWSGI_CHEAPER 2 # By default, when on demand, run up to 16 processes ENV UWSGI_PROCESSES 16 # By default, allow unlimited file sizes, modify it to limit the file sizes # To have a maximum of 1 MB (Nginx's default) change the line to: # ENV NGINX_MAX_UPLOAD 1m ENV NGINX_MAX_UPLOAD 0 # By default, Nginx will run a single worker process, setting it to auto # will create a worker for each CPU core ENV NGINX_WORKER_PROCESSES 1 # By default, Nginx listens on port 80. # To modify this, change LISTEN_PORT environment variable. # (in a Dockerfile or with an option for `docker run`) ENV LISTEN_PORT 80 COPY . /commandment WORKDIR /commandment RUN pip install pipenv RUN pipenv install --system COPY .docker/uwsgi-commandment.ini /etc/uwsgi/uwsgi-commandment.ini COPY .docker/entry.sh /entry.sh COPY .docker/settings.cfg.docker /settings.cfg CMD ["/entry.sh"] ================================================ FILE: .docker/entry.sh ================================================ #!/usr/bin/env bash echo "Starting commandment..." SSL_HOSTNAME=${SSL_HOSTNAME:-"commandment.test"} PYTHONPATH=/commandment export PYTHONPATH #echo "Initialising database..." #touch /commandment/commandment.db #/usr/local/bin/alembic --config /commandment/alembic.ini -x data=true upgrade head if [[ ! -f /etc/nginx/ssl/ssl.crt || ! -f /etc/nginx/ssl.key ]]; then echo "Did not find any SSL certificate to use. SSL is required for MDM." echo "Creating new certificate using environment with DNSName: ${SSL_HOSTNAME}" cat <<- EOF > /tmp/openssl.cnf [req] distinguished_name = req_distinguished_name req_extensions = v3_req prompt = no [req_distinguished_name] C = US ST = California L = Cupertino O = Commandment OU = MDM CN = ${SSL_HOSTNAME} [v3_req] # Extensions to add to a certificate request basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = ${SSL_HOSTNAME} DNS.2 = localhost EOF openssl req -x509 -nodes -days 730 -newkey rsa:2048 -keyout /etc/nginx/ssl.key -out /etc/nginx/ssl.crt -config /tmp/openssl.cnf -extensions 'v3_req' fi echo "Starting uWSGI and nginx" exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf ================================================ FILE: .docker/nginx.conf ================================================ server { listen 80; listen 443 ssl; ssl_certificate ssl.crt; ssl_certificate_key ssl.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; root /commandment/commandment/static; index index.html; location /api { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:///tmp/uwsgi.sock; } location /enroll { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:///tmp/uwsgi.sock; } location /checkin { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:///tmp/uwsgi.sock; } location /mdm { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:///tmp/uwsgi.sock; } location /scep { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:///tmp/uwsgi.sock; } location /dep { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:///tmp/uwsgi.sock; } location / { try_files $uri /index.html; } location /static { alias /commandment/commandment/static; } } ================================================ FILE: .docker/openssl.cnf ================================================ [req] distinguished_name = req_distinguished_name req_extensions = v3_req [req_distinguished_name] countryName = Country Name (2 letter code) countryName_default = AU stateOrProvinceName = State or Province Name (full name) stateOrProvinceName_default = New South Wales localityName = Locality Name (eg, city) localityName_default = Sydney organizationalUnitName = Organizational Unit Name (eg, section) organizationalUnitName_default = Domain Control Validated commonName = commandment.test commonName_max = 64 [ v3_req ] # Extensions to add to a certificate request basicConstraints = CA:FALSE keyUsage = nonRepudiation, digitalSignature, keyEncipherment subjectAltName = @alt_names [alt_names] DNS.1 = localhost DNS.2 = mac.local ================================================ FILE: .docker/settings.cfg.docker ================================================ from os import path dirname = path.dirname(__file__) # The public facing hostname of the MDM # This will also be used as the self signed certificate dnsname PUBLIC_HOSTNAME = 'commandment.dev' # Development mode listen port PORT = 5443 # Configure your Database URI. # All SQLAlchemy options are available here: # http://flask-sqlalchemy.pocoo.org/2.1/config/ SQLALCHEMY_DATABASE_URI = 'sqlite:////commandment/commandment.db' # SQLALCHEMY_DATABASE_ECHO = True # SQLALCHEMY_TRACK_MODIFICATIONS = False # --------------- # Certificates # --------------- # [APNS] # You may supply the certificate as a pair of PEM encoded files, or as a .p12 container. # If you supply .p12 it will be encoded as a PEM keypair # ----- # If commandment is running in development mode, specify the path to the certificate and private key. # These can also be generated at start up. # Normally SSL should be handled by Apache/Nginx/etc. # [SSL] # Specify the Enterprise CA here if Apple Devices won't natively trust your CA eg. If you are using a # self-signed CA or Enterprise CA Certificate. # ----- CA_CERTIFICATE = '/etc/nginx/ssl/ca.crt' # Specify the development web server SSL certificate. # This only applies if you are running via the CLI or flask run # ----- SSL_CERTIFICATE = '/etc/nginx/ssl/ssl.crt' SSL_RSA_KEY = '/etc/nginx/ssl/ssl.key' # If not using external storage, the path to the root directory for upload storage. # This should not be used in production. # ----- STORAGE_ROOT = path.join(dirname, 'storage') # ------------------------- # SCEP via SCEPy (optional) # ------------------------- # Directory where certs, revocation lists, serials etc will be kept # ----- SCEPY_CA_ROOT = "/path/to/ca" # X.509 Name Attributes used to generate the CA Certificate. # ----- SCEPY_CA_X509_CN = 'SCEPY-CA' SCEPY_CA_X509_O = 'SCEPy' SCEPY_CA_X509_C = 'AU' # SubjectAltName extension is always on and will use this DNSName SAN_DNSNAME = 'scepy.dev' # (Optional) SCEP static challenge. This will have to be part of your SCEP profile # ----- SCEPY_CHALLENGE = 'sekret' # Raw data will be dumped to this directory for inspection with tools such as OpenSSL (openssl asn1parse) # ----- SCEPY_DUMP_DIR = '/tmp/scepy_dump' # If the GetCACert would return a single cert, force it to use a CMS degenerate case? # ----- SCEPY_FORCE_DEGENERATE_FOR_SINGLE_CERT = False ================================================ FILE: .docker/supervisord.conf ================================================ [supervisord] nodaemon=true [program:uwsgi] command=/usr/local/bin/uwsgi --ini /etc/uwsgi/uwsgi.ini --ini /etc/uwsgi/uwsgi-commandment.ini --die-on-term --need-app stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 [program:nginx] command=/usr/sbin/nginx stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 # Graceful stop, see http://nginx.org/en/docs/control.html stopsignal=QUIT ================================================ FILE: .docker/uwsgi-commandment.ini ================================================ [uwsgi] base = /commandment pythonpath = %(base) module = commandment:create_app() plugins = python3 env = COMMANDMENT_SETTINGS=/settings.cfg master = true processes = 4 enable-threads = true die-on-term = true ================================================ FILE: .docker/uwsgi.ini ================================================ [uwsgi] socket = /tmp/uwsgi.sock chown-socket = nginx:nginx chmod-socket = 664 # Graceful shutdown on SIGTERM, see https://github.com/unbit/uwsgi/issues/849#issuecomment-118869386 hook-master-start = unix_signal:15 gracefully_kill_them_all ================================================ FILE: .dockerignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff: .idea/**/workspace.xml .idea/**/tasks.xml .idea/dictionaries # Sensitive or high-churn files: .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.xml .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml # Gradle: .idea/**/gradle.xml .idea/**/libraries # Mongo Explorer plugin: .idea/**/mongoSettings.xml ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### Node template # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # Not relevant to running environment assets doc tests testdata # ui output should already be in commandment/static ui # dont use dev local settings settings.cfg # dont copy dev db into context *.db # no certificate(s) *.cer *.crt *.p12 *.key *.pem ssl # no simulator(s) simulators .git ================================================ FILE: .gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation doc/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # User-specific stuff: .idea/**/workspace.xml .idea/**/tasks.xml .idea/dictionaries # Sensitive or high-churn files: .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.xml .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml # Gradle: .idea/**/gradle.xml .idea/**/libraries # Mongo Explorer plugin: .idea/**/mongoSettings.xml ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties # IDEA *.iml .idea # SQLite3 *.db *.db-journal # Flask settings.cfg settings.cfg # dont ever commit certs *.pem *.p12 *.cer *.crt *.key *.csr *.p7m # dont ever commit vpp tokens if they are downloaded into the root *.vpptoken deptoken.json # mypy .mypy_cache # npm lock package-lock.json ================================================ FILE: .gitlab-ci.yml ================================================ image: python:3.6.5-stretch variables: PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache" #cache: # key: ${CI_COMMIT_REF_SLUG} # paths: # - node_modules/ # build_python: stage: build # tags: # - python before_script: - python -V # - pip install virtualenv # - virtualenv venv # - source venv/bin/activate script: # - apt-get update -qy # - apt-get install -y python-dev python-pip - pip install pipenv - pipenv install --system --dev cache: key: backend paths: - .cache/ - venv/ - /root/.local/share/virtualenvs/ build_js: stage: build # tags: # - js script: - apt-get update -qy - apt-get install -y apt-transport-https - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - - echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list - curl -sL https://deb.nodesource.com/setup_8.x | bash - - apt-get update -qy - apt-get install -y yarn - cd ui && yarn install - NODE_ENV=production ./node_modules/.bin/webpack artifacts: paths: - commandment/static/ cache: key: frontend paths: - node_modules/ test_python: stage: test # tags: # - python before_script: - pip install pipenv - pipenv install --system --dev script: - pytest -v -m "not depsim and not dep and not vppsim and not vpp" tests cache: key: backend paths: - .cache/ - venv/ - /root/.local/share/virtualenvs/ ================================================ FILE: LICENSE.txt ================================================ Copyright (c) 2015 Jesse Peterson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Pipfile ================================================ [[source]] url = "https://pypi.python.org/simple" verify_ssl = true name = "pypi" [requires] python_version = "3.6" [packages] acme = "*" aiohttp = "*" alembic = "*" "aniso8601" = "*" apns = "*" "apns2-client" = "*" babel = "*" biplist = "*" bixar = {git = "https://github.com/cmdmnt/bixar.git",editable = true} blinker = "*" decorator = "*" docutils = "*" flask = "*" flask-alembic = "*" flask-cors = "*" flask-jwt = "*" flask-marshmallow = "*" flask-rest-jsonapi = "*" flask-restful = "*" flask-sqlalchemy = "*" future = "*" imagesize = "*" marshmallow-enum = "*" marshmallow-sqlalchemy = "*" "oauth2" = "*" "oauth2client" = "*" oscrypto = "*" passlib = "*" paste = "*" py = "*" "pyasn1" = "*" "pyasn1-modules" = "*" pycparser = "*" pycryptodomex = "*" pygments = "*" pyparsing = "*" "repoze.who" = "*" requests = "*" rsa = "*" SCEPy = {git = "https://github.com/cmdmnt/SCEPy.git",editable = true} semver = "*" signxml = "*" ukpostcodeparser = "*" sqlalchemy = "*" cryptography = "*" python-dateutil = "*" requests-oauthlib = "*" authlib = "*" commandment = {editable = true,path = "."} [dev-packages] alembic-viz = "*" factory-boy = "*" mock = "*" mypy = "*" pytest = "*" pytest-runner = "*" sadisplay = "*" sphinx = "*" sphinxcontrib-httpdomain = "*" sphinxcontrib-napoleon = "*" sphinxcontrib-plantuml = "*" sphinxcontrib-websupport = "*" guzzle-sphinx-theme = "*" typing = "*" sphinx-rtd-theme = "*" ================================================ FILE: README.rst ================================================ =========================== Commandment Open Source MDM =========================== .. image:: https://travis-ci.org/cmdmnt/commandment.svg?branch=master :target: https://travis-ci.org/cmdmnt/commandment Commandment is an Open Source Apple MDM with support for managing iOS and macOS devices. The source code is available under an `MIT license `_. ------------ Requirements ------------ * Apple MDM Push Certificate and private key (in PEM format) * Obtain a free Push Certificate from `mdmcert.download `_. * Alternatively requires an Apple Enterprise Developer account (US$300/year) with the MDM vendor option enabled. * A trusted TLS certificate for the MDM. * `Python 3.6+ `_ ------------- Documentation ------------- The user, developer and API documentation is available at: http://cmdmnt.github.io/commandment/ ------------------ Bugs, issues, etc. ------------------ Please report any issues, bugs, suggestions, feedback, etc. to the `issue tracker `_ of this project. Also for discussion, and support, join us in the #commandment channel in the `MacAdmins Slack `_ ! ================================================ FILE: alembic.ini ================================================ [alembic] # path to migration scripts script_location = %(here)s/commandment/alembic sqlalchemy.url = sqlite:///commandment.db # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = DEBUG handlers = console qualname = [logger_sqlalchemy] level = INFO handlers = qualname = sqlalchemy.engine [logger_alembic] level = DEBUG handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: commandment/__init__.py ================================================ """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ from typing import Union, Optional from pathlib import PurePath from flask import Flask, render_template from commandment.mdm.app import mdm_app from .ac2.ac2_app import ac2_app from .api.app_jsonapi import api_app, api from .api.app_json import flat_api from .apns.app import api_push_app from .auth.app import oauth_app from .auth import oauth2 from .api.configuration import configuration_app from .enroll.app import enroll_app from .models import db from .omdm import omdm_app from .dep.app import dep_app from .vpp.app import vpp_app from .profiles.api import profiles_api_app from .inventory.api import api_app as inventory_api from .mdm.api import api_app as mdm_api from .apps.app_jsonapi import api_app as applications_api from .threads import startup_thread from .dep import threads as dep_threads from .apns import threads as push_threads def create_app(config_file: Optional[Union[str, PurePath]] = None) -> Flask: """Create the Flask Application. Configuration is looked up the following order: - default_settings.py in the commandment package. - config_file parameter passed to this factory method. - environment variable ``COMMANDMENT_SETTINGS`` pointing to a .cfg file. Args: config_file (Union[str, PurePath]): Path to configuration file. Returns: Instance of the flask application """ app = Flask(__name__) app.config.from_object('commandment.default_settings') if config_file is not None: app.config.from_pyfile(config_file) else: app.config.from_envvar('COMMANDMENT_SETTINGS') db.init_app(app) oauth2.init_app(app) api.init_app(app) api.oauth_manager(oauth2.require_oauth) app.register_blueprint(oauth_app, url_prefix='/oauth') app.register_blueprint(enroll_app, url_prefix='/enroll') app.register_blueprint(mdm_app) app.register_blueprint(configuration_app, url_prefix='/api/v1/configuration') app.register_blueprint(api_app, url_prefix='/api') app.register_blueprint(api_push_app, url_prefix='/api') app.register_blueprint(flat_api, url_prefix='/api') app.register_blueprint(profiles_api_app, url_prefix='/api') app.register_blueprint(applications_api, url_prefix='/api') app.register_blueprint(omdm_app, url_prefix='/omdm') app.register_blueprint(ac2_app) app.register_blueprint(dep_app) app.register_blueprint(vpp_app) try: from scepy.blueprint import scep_app app.register_blueprint(scep_app, url_prefix='/scep') app.logger.info('Registered SCEPy service at /scep') except ImportError: app.logger.warning("SCEP will not be available, cannot load SCEPy") # Threads startup_thread.start(app) # dep_threads.start(app) # push_threads.start(app) # SPA Entry Point (when not behind nginx or apache) @app.route('/') def index(): """Main entry point for the administrator web application.""" return render_template('index.html') # SPA history fallback handler @app.errorhandler(404) def send_index(path: str): """Fallback route for HTML5 History.""" return render_template('index.html') return app ================================================ FILE: commandment/ac2/__init__.py ================================================ ================================================ FILE: commandment/ac2/ac2_app.py ================================================ from flask import Blueprint, jsonify, current_app ac2_app = Blueprint('ac2_app', __name__) @ac2_app.route('/MDMServiceConfig') def mdm_service_config(): """Apple Configurator 2 checks this route to figure out which enrollment profile it should use.""" public_hostname = current_app.config.get('PUBLIC_HOSTNAME', 'localhost') port = current_app.config.get('PORT', 443) return jsonify({ 'dep_enrollment_url': 'https://{}:{}/dep/profile'.format(public_hostname, port), 'dep_anchor_certs_url': 'https://{}:{}/dep/anchor_certs'.format(public_hostname, port), 'trust_profile_url': 'https://{}:{}/enroll/trust.mobileconfig'.format(public_hostname, port) }) ================================================ FILE: commandment/alembic/__init__.py ================================================ ================================================ FILE: commandment/alembic/disabled_versions/072fba4a2256_create_ad_payload_table.py ================================================ """Create ad_payload table Revision ID: 072fba4a2256 Revises: 8186b8ecf0fc Create Date: 2017-05-19 19:50:25.537513 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '072fba4a2256' down_revision = '8186b8ecf0fc' branch_labels = None depends_on = None def upgrade(): op.create_table('ad_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('host_name', sa.String(), nullable=False), sa.Column('user_name', sa.String(), nullable=False), sa.Column('password', sa.String(), nullable=False), sa.Column('ad_organizational_unit', sa.String(), nullable=False), sa.Column('ad_mount_style', sa.Enum('AFP', 'SMB', name='admountstyle'), nullable=False), sa.Column('ad_default_user_shell', sa.String(), nullable=True), sa.Column('ad_map_uid_attribute', sa.String(), nullable=True), sa.Column('ad_map_gid_attribute', sa.String(), nullable=True), sa.Column('ad_map_ggid_attribute', sa.String(), nullable=True), sa.Column('ad_preferred_dc_server', sa.String(), nullable=True), sa.Column('ad_domain_admin_group_list', sa.String(), nullable=True), sa.Column('ad_namespace', sa.Enum('Domain', 'Forest', name='adnamespace'), nullable=True), sa.Column('ad_packet_sign', sa.Enum('Allow', 'Disable', 'Require', name='adpacketsignpolicy'), nullable=True), sa.Column('ad_packet_encrypt', sa.Enum('Allow', 'Disable', 'Require', 'SSL', name='adpacketencryptpolicy'), nullable=True), sa.Column('ad_restrict_ddns', sa.String(), nullable=True), sa.Column('ad_trust_change_pass_interval', sa.Integer(), nullable=True), sa.Column('ad_create_mobile_account_at_login', sa.Boolean(), nullable=True), sa.Column('ad_warn_user_before_creating_ma', sa.Boolean(), nullable=True), sa.Column('ad_force_home_local', sa.Boolean(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('ad_payload') ================================================ FILE: commandment/alembic/disabled_versions/18412434fb57_create_energy_saver_payload_table.py ================================================ """Create energy_saver_payload table Revision ID: 18412434fb57 Revises: 323a90039a6a Create Date: 2017-05-19 19:53:03.142964 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = '18412434fb57' down_revision = '323a90039a6a' branch_labels = None depends_on = None def upgrade(): op.create_table('energy_saver_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('destroy_fv_key_on_standby', sa.Boolean(), nullable=True), sa.Column('sleep_disabled', sa.Boolean(), nullable=True), sa.Column('desktop_acpower_profilenumber', sa.Integer(), nullable=True), sa.Column('portable_acpower_profilenumber', sa.Integer(), nullable=True), sa.Column('portable_battery_profilenumber', sa.Integer(), nullable=True), sa.Column('desktop_acpower', commandment.dbtypes.JSONEncodedDict(), nullable=True), sa.Column('portable_acpower', commandment.dbtypes.JSONEncodedDict(), nullable=True), sa.Column('portable_battery', commandment.dbtypes.JSONEncodedDict(), nullable=True), sa.Column('desktop_schedule', commandment.dbtypes.JSONEncodedDict(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('energy_saver_payload') ================================================ FILE: commandment/alembic/disabled_versions/323a90039a6a_create_email_payload_table.py ================================================ """Create email_payload table Revision ID: 323a90039a6a Revises: e47e29a9537c Create Date: 2017-05-19 19:52:05.726744 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '323a90039a6a' down_revision = 'e47e29a9537c' branch_labels = None depends_on = None def upgrade(): op.create_table('email_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('email_account_description', sa.String(), nullable=True), sa.Column('email_account_name', sa.String(), nullable=True), sa.Column('email_account_type', sa.Enum('POP', 'IMAP', name='emailaccounttype'), nullable=False), sa.Column('email_address', sa.String(), nullable=True), sa.Column('incoming_auth', sa.Enum('Password', 'CRAM_MD5', 'NTLM', 'HTTP_MD5', 'ENone', name='emailauthenticationtype'), nullable=False), sa.Column('incoming_host', sa.String(), nullable=False), sa.Column('incoming_port', sa.Integer(), nullable=True), sa.Column('incoming_use_ssl', sa.Boolean(), nullable=True), sa.Column('incoming_username', sa.String(), nullable=False), sa.Column('incoming_password', sa.String(), nullable=True), sa.Column('outgoing_password', sa.String(), nullable=True), sa.Column('outgoing_incoming_same', sa.Boolean(), nullable=True), sa.Column('outgoing_auth', sa.Enum('Password', 'CRAM_MD5', 'NTLM', 'HTTP_MD5', 'ENone', name='emailauthenticationtype'), nullable=False), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('email_payload') ================================================ FILE: commandment/alembic/disabled_versions/4eddbcb30464_create_mdm_payload_table.py ================================================ """Create mdm_payload table Revision ID: 4eddbcb30464 Revises: 18412434fb57 Create Date: 2017-05-19 19:54:24.264198 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = '4eddbcb30464' down_revision = '18412434fb57' branch_labels = None depends_on = None def upgrade(): op.create_table('mdm_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('identity_certificate_uuid', commandment.dbtypes.GUID(), nullable=False), sa.Column('topic', sa.String(), nullable=False), sa.Column('server_url', sa.String(), nullable=False), sa.Column('server_capabilities', sa.String(), nullable=True), sa.Column('sign_message', sa.Boolean(), nullable=True), sa.Column('check_in_url', sa.String(), nullable=True), sa.Column('check_out_when_removed', sa.Boolean(), nullable=True), sa.Column('access_rights', sa.Integer(), nullable=True), sa.Column('use_development_apns', sa.Boolean(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('mdm_payload') ================================================ FILE: commandment/alembic/disabled_versions/8186b8ecf0fc_create_ad_cert_payload_table.py ================================================ """Create ad_cert_payload table Revision ID: 8186b8ecf0fc Revises: 13358fb3846b Create Date: 2017-05-19 19:49:07.136996 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '8186b8ecf0fc' down_revision = '13358fb3846b' branch_labels = None depends_on = None def upgrade(): op.create_table('ad_cert_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('certificate_description', sa.String(), nullable=True), sa.Column('allow_all_apps_access', sa.Boolean(), nullable=True), sa.Column('cert_server', sa.String(), nullable=False), sa.Column('cert_template', sa.String(), nullable=False), sa.Column('acquisition_mechanism', sa.Enum('RPC', 'HTTP', name='adcertificateacquisitionmechanism'), nullable=True), sa.Column('certificate_authority', sa.String(), nullable=False), sa.Column('renewal_time_interval', sa.Integer(), nullable=True), sa.Column('identity_description', sa.String(), nullable=True), sa.Column('key_is_extractable', sa.Boolean(), nullable=True), sa.Column('prompt_for_credentials', sa.Boolean(), nullable=True), sa.Column('keysize', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('ad_cert_payload') ================================================ FILE: commandment/alembic/disabled_versions/9dd4e48235e3_create_vpn_payload_table.py ================================================ """Create vpn_payload table Revision ID: 9dd4e48235e3 Revises: e5840df9a88a Create Date: 2017-05-19 19:59:55.582629 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '9dd4e48235e3' down_revision = 'e5840df9a88a' branch_labels = None depends_on = None def upgrade(): op.create_table('vpn_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_defined_name', sa.String(), nullable=True), sa.Column('override_primary', sa.Boolean(), nullable=True), sa.Column('vpn_type', sa.Enum('L2TP', 'PPTP', 'IPSec', 'IKEv2', 'AlwaysOn', 'VPN', name='vpntype'), nullable=False), sa.Column('vpn_sub_type', sa.String(), nullable=True), sa.Column('provider_bundle_identifier', sa.String(), nullable=True), sa.Column('on_demand_enabled', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('vpn_payload') ================================================ FILE: commandment/alembic/disabled_versions/d65049bf4b91_create_wifi_payload_table.py ================================================ """Create wifi_payload table Revision ID: d65049bf4b91 Revises: 9dd4e48235e3 Create Date: 2017-05-19 20:00:36.548840 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'd65049bf4b91' down_revision = '9dd4e48235e3' branch_labels = None depends_on = None def upgrade(): op.create_table('wifi_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('ssid_str', sa.String(), nullable=False), sa.Column('hidden_network', sa.Boolean(), nullable=True), sa.Column('auto_join', sa.Boolean(), nullable=True), sa.Column('encryption_type', sa.Enum('ENone', 'Any', 'WPA2', 'WPA', 'WEP', name='wifiencryptiontype'), nullable=True), sa.Column('is_hotspot', sa.Boolean(), nullable=True), sa.Column('domain_name', sa.String(), nullable=True), sa.Column('service_provider_roaming_enabled', sa.Boolean(), nullable=True), sa.Column('roaming_consortium_ois', sa.String(), nullable=True), sa.Column('nai_realm_names', sa.String(), nullable=True), sa.Column('mccs_and_mncs', sa.String(), nullable=True), sa.Column('displayed_operator_name', sa.String(), nullable=True), sa.Column('captive_bypass', sa.Boolean(), nullable=True), sa.Column('password', sa.String(), nullable=True), sa.Column('tls_certificate_required', sa.Boolean(), nullable=True), sa.Column('payload_certificate_uuid', commandment.dbtypes.GUID(), nullable=True), sa.Column('proxy_type', sa.String(), nullable=True), sa.Column('proxy_server', sa.String(), nullable=True), sa.Column('proxy_server_port', sa.Integer(), nullable=True), sa.Column('proxy_username', sa.String(), nullable=True), sa.Column('proxy_password', sa.String(), nullable=True), sa.Column('proxy_pac_url', sa.String(), nullable=True), sa.Column('proxy_pac_fallback_allowed', sa.Boolean(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('wifi_payload') ================================================ FILE: commandment/alembic/disabled_versions/da52b64b865f_create_apps_table.py ================================================ """Create apps table Revision ID: da52b64b865f Revises: Create Date: 2017-05-18 22:27:44.830159 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'da52b64b865f' down_revision = None branch_labels = None depends_on = None def upgrade(): op.create_table('apps', sa.Column('id', sa.Integer(), nullable=False), sa.Column('filename', sa.String(), nullable=False), sa.Column('filesize', sa.Integer(), nullable=False), sa.Column('md5_hash', sa.String(length=32), nullable=False), sa.Column('md5_chunk_size', sa.Integer(), nullable=False), sa.Column('md5_chunk_hashes', sa.Text(), nullable=True), sa.Column('bundle_ids_json', sa.Text(), nullable=True), sa.Column('pkg_ids_json', sa.Text(), nullable=True), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('filename') ) def downgrade(): op.drop_table('app') ================================================ FILE: commandment/alembic/disabled_versions/e47e29a9537c_create_certificate_payload_table.py ================================================ """Create certificate_payload table Revision ID: e47e29a9537c Revises: 072fba4a2256 Create Date: 2017-05-19 19:51:20.672688 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'e47e29a9537c' down_revision = '072fba4a2256' branch_labels = None depends_on = None def upgrade(): op.create_table('certificate_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('certificate_file_name', sa.String(), nullable=True), sa.Column('payload_content', sa.LargeBinary(), nullable=True), sa.Column('password', sa.String(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('certificate_payload') ================================================ FILE: commandment/alembic/disabled_versions/fc0c134cbb2e_create_password_policy_payload_table.py ================================================ """Create password_policy_payload table Revision ID: fc0c134cbb2e Revises: 4eddbcb30464 Create Date: 2017-05-19 19:56:45.009648 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'fc0c134cbb2e' down_revision = '4eddbcb30464' branch_labels = None depends_on = None def upgrade(): op.create_table('password_policy_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('allow_simple', sa.Boolean(), nullable=True), sa.Column('force_pin', sa.Boolean(), nullable=True), sa.Column('max_failed_attempts', sa.Integer(), nullable=True), sa.Column('max_inactivity', sa.Integer(), nullable=True), sa.Column('max_pin_age_in_days', sa.Integer(), nullable=True), sa.Column('min_complex_chars', sa.Integer(), nullable=True), sa.Column('min_length', sa.Integer(), nullable=True), sa.Column('require_alphanumeric', sa.Boolean(), nullable=True), sa.Column('pin_history', sa.Integer(), nullable=True), sa.Column('max_grace_period', sa.Integer(), nullable=True), sa.Column('allow_fingerprint_modification', sa.Boolean(), nullable=True), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('password_policy_payload') ================================================ FILE: commandment/alembic/env.py ================================================ from __future__ import with_statement from alembic import context from sqlalchemy import engine_from_config, pool from logging.config import fileConfig # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) # add your model's MetaData object here # for 'autogenerate' support from commandment.models import db # import commandment.vpp.models #import commandment.dep.models #import commandment.apps.models #import commandment.pki.models import commandment.auth.models target_metadata = db.metadata # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ connectable = config.attributes.get('connection', None) if connectable is None: # only create Engine if we don't have a Connection # from the outside connectable = engine_from_config( config.get_section(config.config_ini_section), prefix='sqlalchemy.', poolclass=pool.NullPool) # when connectable is already a Connection object, calling # connect() gives us a *branched connection*. with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=True, ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: commandment/alembic/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes ${imports if imports else ""} from alembic import context # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): schema_upgrades() if context.get_x_argument(as_dictionary=True).get('data', None): data_upgrades() def downgrade(): if context.get_x_argument(as_dictionary=True).get('data', None): data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" ${upgrades if upgrades else "pass"} def schema_downgrades(): """schema downgrade migrations go here.""" ${downgrades if downgrades else "pass"} def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/0201b96ab856_add_ios_available_os_updates_fields.py ================================================ """add ios available os updates fields Revision ID: 0201b96ab856 Revises: e947cdf82307 Create Date: 2018-07-01 21:37:27.355712 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '0201b96ab856' down_revision = 'e947cdf82307' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.add_column('available_os_updates', sa.Column('build', sa.String(), nullable=True)) op.add_column('available_os_updates', sa.Column('download_size', sa.BigInteger(), nullable=True)) op.add_column('available_os_updates', sa.Column('install_size', sa.BigInteger(), nullable=True)) op.add_column('available_os_updates', sa.Column('product_name', sa.String(), nullable=True)) def schema_downgrades(): op.drop_column('available_os_updates', 'product_name') op.drop_column('available_os_updates', 'install_size') op.drop_column('available_os_updates', 'download_size') op.drop_column('available_os_updates', 'build') # def data_upgrades(): # """Add any optional data upgrade migrations here!""" # pass # # # def data_downgrades(): # """Add any optional data downgrade migrations here!""" # pass ================================================ FILE: commandment/alembic/versions/0ab46b2f6d8c_create_users_table.py ================================================ """Create users table Revision ID: 0ab46b2f6d8c Revises: f5237c7e2374 Create Date: 2017-05-19 19:35:12.126022 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '0ab46b2f6d8c' down_revision = 'f5237c7e2374' branch_labels = None depends_on = None def upgrade(): op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(), nullable=True), sa.Column('fullname', sa.String(), nullable=True), sa.Column('password', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('users') ================================================ FILE: commandment/alembic/versions/0c4c448f4daf_create_device_users_table.py ================================================ """Create device_users table Revision ID: 0c4c448f4daf Revises: 7d578eb75092 Create Date: 2017-05-18 22:32:52.087025 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = '0c4c448f4daf' down_revision = '7d578eb75092' branch_labels = None depends_on = None def upgrade(): op.create_table('device_users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('udid', commandment.dbtypes.GUID(), nullable=False), sa.Column('user_id', commandment.dbtypes.GUID(), nullable=False), sa.Column('long_name', sa.String(), nullable=True), sa.Column('short_name', sa.String(), nullable=True), sa.Column('need_sync_response', sa.Boolean(), nullable=True), sa.Column('user_configuration', sa.Boolean(), nullable=True), sa.Column('digest_challenge', sa.String(), nullable=True), sa.Column('auth_token', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('device_users') ================================================ FILE: commandment/alembic/versions/0e5babc5b9ee_create_vpp_licenses.py ================================================ """Create vpp_licenses table Revision ID: 0e5babc5b9ee Revises: 875dcce0bf8b Create Date: 2017-07-19 12:56:55.273155 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '0e5babc5b9ee' down_revision = '875dcce0bf8b' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('vpp_licenses', sa.Column('license_id', sa.Integer(), nullable=False), sa.Column('adam_id', sa.String(), nullable=True), sa.Column('product_type', sa.Enum('Software', 'Application', 'Publication', name='vppproducttype'), nullable=True), sa.Column('product_type_name', sa.String(), nullable=True), sa.Column('pricing_param', sa.Enum('StandardQuality', 'HighQuality', name='vpppricingparam'), nullable=True), sa.Column('is_irrevocable', sa.Boolean(), nullable=True), sa.Column('user_id', sa.Integer(), nullable=True), sa.Column('client_user_id', commandment.dbtypes.GUID(), nullable=True), sa.Column('its_id_hash', sa.String(), nullable=True), sa.ForeignKeyConstraint(['client_user_id'], ['vpp_users.client_user_id'], ), sa.ForeignKeyConstraint(['user_id'], ['vpp_users.user_id'], ), sa.PrimaryKeyConstraint('license_id') ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('vpp_licenses') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/1005dc7dea01_os_update_settings.py ================================================ """os_update_settings Revision ID: 1005dc7dea01 Revises: b74ca08cfd9a Create Date: 2018-02-02 15:49:22.170956 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '1005dc7dea01' down_revision = 'b74ca08cfd9a' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.add_column('devices', sa.Column('osu_automatic_app_installation_enabled', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_automatic_check_enabled', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_automatic_os_installation_enabled', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_automatic_security_updates_enabled', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_background_download_enabled', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_catalog_url', sa.String(), nullable=True)) op.add_column('devices', sa.Column('osu_is_default_catalog', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_perform_periodic_check', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('osu_previous_scan_date', sa.DateTime(), nullable=True)) op.add_column('devices', sa.Column('osu_previous_scan_result', sa.String(), nullable=True)) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_column('devices', 'osu_previous_scan_result') op.drop_column('devices', 'osu_previous_scan_date') op.drop_column('devices', 'osu_perform_periodic_check') op.drop_column('devices', 'osu_is_default_catalog') op.drop_column('devices', 'osu_catalog_url') op.drop_column('devices', 'osu_background_download_enabled') op.drop_column('devices', 'osu_automatic_security_updates_enabled') op.drop_column('devices', 'osu_automatic_os_installation_enabled') op.drop_column('devices', 'osu_automatic_check_enabled') op.drop_column('devices', 'osu_automatic_app_installation_enabled') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/13358fb3846b_create_subject_alternative_names_table.py ================================================ """Create subject_alternative_names table Revision ID: 13358fb3846b Revises: ea34ae3f1e7e Create Date: 2017-05-19 19:48:09.977131 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '13358fb3846b' down_revision = 'ea34ae3f1e7e' branch_labels = None depends_on = None def upgrade(): op.create_table('subject_alternative_names', sa.Column('id', sa.Integer(), nullable=False), sa.Column('discriminator', sa.Enum('RFC822Name', 'DNSName', 'UniformResourceIdentifier', 'DirectoryName', 'RegisteredID', 'IPAddress', 'OtherName', name='subjectalternativenametype'), nullable=False), sa.Column('str_value', sa.String(), nullable=True), sa.Column('octet_value', sa.LargeBinary(), nullable=True), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('subject_alternative_names') ================================================ FILE: commandment/alembic/versions/1532dff16984_drop_device_groups.py ================================================ """drop device groups Revision ID: 1532dff16984 Revises: f8eb70b3aa2b Create Date: 2018-03-13 21:26:13.058020 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '1532dff16984' down_revision = 'f8eb70b3aa2b' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.drop_table('device_groups') op.drop_table('device_group_devices') def schema_downgrades(): """schema downgrade migrations go here.""" op.create_table('device_group_devices', sa.Column('device_group_id', sa.INTEGER(), nullable=False), sa.Column('device_id', sa.INTEGER(), nullable=False), sa.ForeignKeyConstraint(['device_group_id'], ['device_groups.id'], ), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('device_group_id', 'device_id') ) op.create_table('device_groups', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('name', sa.VARCHAR(), nullable=False), sa.PrimaryKeyConstraint('id') ) def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/2808deb9fc62_create_dep_configurations.py ================================================ """Create DEP configurations Revision ID: 2808deb9fc62 Revises: 0201b96ab856 Create Date: 2018-07-04 16:57:16.899029 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '2808deb9fc62' down_revision = '0201b96ab856' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.create_table('dep_accounts', sa.Column('id', sa.Integer(), nullable=False), sa.Column('certificate_id', sa.Integer(), nullable=True), sa.Column('consumer_key', sa.String(), nullable=True), sa.Column('consumer_secret', sa.String(), nullable=True), sa.Column('access_token', sa.String(), nullable=True), sa.Column('access_secret', sa.String(), nullable=True), sa.Column('access_token_expiry', sa.DateTime(), nullable=True), sa.Column('token_updated_at', sa.DateTime(), nullable=True), sa.Column('auth_session_token', sa.String(), nullable=True), sa.Column('server_name', sa.String(), nullable=True), sa.Column('server_uuid', commandment.dbtypes.GUID(), nullable=True), sa.Column('admin_id', sa.String(), nullable=True), sa.Column('facilitator_id', sa.String(), nullable=True), sa.Column('org_name', sa.String(), nullable=True), sa.Column('org_email', sa.String(), nullable=True), sa.Column('org_phone', sa.String(), nullable=True), sa.Column('org_address', sa.String(), nullable=True), sa.Column('org_type', sa.Enum('Education', 'Organization', name='deporgtype'), nullable=True), sa.Column('org_version', sa.Enum('v1', 'v2', name='deporgversion'), nullable=True), sa.Column('org_id', sa.String(), nullable=True), sa.Column('org_id_hash', sa.String(), nullable=True), sa.Column('url', sa.String(), nullable=True), sa.Column('cursor', sa.String(), nullable=True), sa.Column('more_to_follow', sa.Boolean(), nullable=True), sa.Column('fetched_until', sa.DateTime(), nullable=True), sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), sa.PrimaryKeyConstraint('id') ) def schema_downgrades(): op.drop_table('dep_accounts') # def data_upgrades(): # """Add any optional data upgrade migrations here!""" # pass # # # def data_downgrades(): # """Add any optional data downgrade migrations here!""" # pass ================================================ FILE: commandment/alembic/versions/2f1507bf6dc1_create_application_manifests_table.py ================================================ """create application_manifests table Revision ID: 2f1507bf6dc1 Revises: 7ab500f58a76 Create Date: 2017-10-15 17:37:04.645717 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '2f1507bf6dc1' down_revision = '7ab500f58a76' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.create_table( 'application_manifests', sa.Column('id', sa.Integer(), primary_key=True), sa.Column('bundle_id', sa.String(), nullable=False), sa.Column('bundle_version', sa.String()), sa.Column('kind', sa.String()), sa.Column('size_in_bytes', sa.BigInteger()), sa.Column('subtitle', sa.String()), sa.Column('title', sa.String()), sa.Column('display_image_url', sa.String()), sa.Column('display_image_needs_shine', sa.Boolean()), sa.Column('full_size_image_url', sa.String()), sa.Column('full_size_image_needs_shine', sa.Boolean()), #op.add_column('application_manifests', sa.Column('full_size_image_needs_shine', sa.Boolean(), nullable=True)) #op.add_column('application_manifests', sa.Column('full_size_image_url', sa.String(), nullable=True)) # sa.UniqueConstraint('bundle_id', 'bundle_version', name='uq_application_bundle_version') ) op.create_table( 'application_manifest_checksums', sa.Column('id', sa.Integer(), primary_key=True), sa.Column('application_manifest_id', sa.Integer(), nullable=True), sa.Column('checksum_index', sa.Integer(), nullable=False), sa.Column('checksum_value', sa.String(), nullable=False), sa.ForeignKeyConstraint(['application_manifest_id'], ['application_manifests.id']), # sa.ForeignKeyConstraint(['application_manifest_id'], ['application_manifests.id'], ondelete="CASCADE"), # sa.UniqueConstraint('application_manifest_id', 'checksum_index', name='uq_application_checksum_index') ) # Commented items from an earlier migration: # op.create_table('applications_manifests', # sa.Column('id', sa.Integer(), nullable=False), # sa.Column('bundle_id', sa.String(), nullable=False), # sa.Column('bundle_version', sa.String(), nullable=True), # sa.Column('kind', sa.String(), nullable=True), # sa.Column('size_in_bytes', sa.BigInteger(), nullable=True), # sa.Column('subtitle', sa.String(), nullable=True), # sa.Column('title', sa.String(), nullable=True), # sa.PrimaryKeyConstraint('id') # ) # op.create_index(op.f('ix_applications_manifests_bundle_id'), 'applications_manifests', ['bundle_id'], unique=False) # op.create_index(op.f('ix_applications_manifests_bundle_version'), 'applications_manifests', ['bundle_version'], # unique=False) # op.create_table('application_manifest_checksums', # sa.Column('id', sa.Integer(), nullable=False), # sa.Column('application_manifest_id', sa.Integer(), nullable=True), # sa.Column('checksum_index', sa.Integer(), nullable=False), # sa.Column('checksum_value', sa.String(), nullable=False), # sa.ForeignKeyConstraint(['application_manifest_id'], ['applications_manifests.id'], ), # sa.PrimaryKeyConstraint('id') # ) # op.create_unique_constraint( # op.f('uq_application_manifest_checksum_manifest_index'), # 'application_manifest_checksums', ['application_manifest_id', 'checksum_index']) def schema_downgrades(): """schema downgrade migrations go here.""" # Commented items from an earlier migration: # op.drop_constraint(op.f('uq_application_manifest_checksum_manifest_index'), # table_name='application_manifest_checksums') # op.drop_table('application_manifest_checksums') # op.drop_index(op.f('ix_applications_manifests_bundle_version'), table_name='applications_manifests') # op.drop_index(op.f('ix_applications_manifests_bundle_id'), table_name='applications_manifests') # op.drop_table('applications_manifests') op.drop_table('application_manifest_checksums') op.drop_table('application_manifests') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/3061e56045eb_create_certificate_authority.py ================================================ """create certificate authority Revision ID: 3061e56045eb Revises: 3fb4a904979c Create Date: 2018-06-30 20:53:58.016051 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '3061e56045eb' down_revision = '3fb4a904979c' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.create_table('certificate_authority', sa.Column('id', sa.Integer(), nullable=False), sa.Column('common_name', sa.String(), nullable=True), # NOTE: certificate serials are still string but this remains as BigInteger because the counter is incremented # manually. sa.Column('serial', sa.BigInteger(), nullable=True), sa.Column('validity_period', sa.Integer(), nullable=True), sa.Column('certificate_id', sa.Integer(), nullable=True), sa.Column('rsa_private_key_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), sa.ForeignKeyConstraint(['rsa_private_key_id'], ['rsa_private_keys.id'], ), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('common_name') ) def schema_downgrades(): op.drop_table('certificate_authority') # def data_upgrades(): # """Add any optional data upgrade migrations here!""" # pass # # # def data_downgrades(): # """Add any optional data downgrade migrations here!""" # pass ================================================ FILE: commandment/alembic/versions/3dbf6db7f9eb_application_tags.py ================================================ """application_tags Revision ID: 3dbf6db7f9eb Revises: 7cf5787a089e Create Date: 2019-01-08 20:51:11.845673 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '3dbf6db7f9eb' down_revision = '7cf5787a089e' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" # ### commands auto generated by Alembic - please adjust! ### op.create_table('application_tags', sa.Column('application_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['application_id'], ['applications.id'], ), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ) ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('application_tags') ================================================ FILE: commandment/alembic/versions/3fb4a904979c_general_cleanup.py ================================================ """general cleanup Revision ID: 3fb4a904979c Revises: 1532dff16984 Create Date: 2018-03-13 21:27:55.983564 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '3fb4a904979c' down_revision = '1532dff16984' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.drop_table('users') op.drop_table('payload_dependencies') def schema_downgrades(): """schema downgrade migrations go here.""" op.create_table('payload_dependencies', sa.Column('payload_uuid', sa.CHAR(length=32), nullable=True), sa.Column('depends_on_payload_uuid', sa.CHAR(length=32), nullable=True), sa.ForeignKeyConstraint(['depends_on_payload_uuid'], ['payloads.uuid'], ), sa.ForeignKeyConstraint(['payload_uuid'], ['payloads.uuid'], ) ) op.create_table('users', sa.Column('id', sa.INTEGER(), nullable=False), sa.Column('name', sa.VARCHAR(), nullable=True), sa.Column('fullname', sa.VARCHAR(), nullable=True), sa.Column('password', sa.VARCHAR(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.drop_table('dep_configurations') op.drop_table('mdm_payload') op.drop_table('certificate_payload') op.drop_table('command_sequences') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/50188ffaf0cd_create_devices_table.py ================================================ """Create devices table Revision ID: 50188ffaf0cd Revises: 71ecf957301a Create Date: 2017-05-19 19:39:22.021264 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '50188ffaf0cd' down_revision = '71ecf957301a' branch_labels = None depends_on = None def upgrade(): op.create_table('devices', sa.Column('id', sa.Integer(), nullable=False), sa.Column('udid', sa.String(), nullable=True), sa.Column('topic', sa.String(), nullable=True), sa.Column('last_seen', sa.DateTime(), nullable=True), sa.Column('is_enrolled', sa.Boolean(), nullable=True), sa.Column('build_version', sa.String(), nullable=True), sa.Column('device_name', sa.String(), nullable=True), sa.Column('model', sa.String(), nullable=True), sa.Column('model_name', sa.String(), nullable=True), sa.Column('os_version', sa.String(), nullable=True), sa.Column('product_name', sa.String(), nullable=True), sa.Column('serial_number', sa.String(length=64), nullable=True), sa.Column('hostname', sa.String(), nullable=True), sa.Column('local_hostname', sa.String(), nullable=True), sa.Column('available_device_capacity', sa.Float(), nullable=True), sa.Column('device_capacity', sa.Float(), nullable=True), sa.Column('wifi_mac', sa.String(), nullable=True), sa.Column('bluetooth_mac', sa.String(), nullable=True), sa.Column('awaiting_configuration', sa.Boolean(), nullable=True), sa.Column('push_magic', sa.String(), nullable=True), sa.Column('_token', sa.String(), nullable=True), sa.Column('tokenupdate_at', sa.DateTime(), nullable=True), sa.Column('last_push_at', sa.DateTime(), nullable=True), sa.Column('last_apns_id', sa.Integer(), nullable=True), sa.Column('failed_push_count', sa.Integer(), nullable=False), sa.Column('unlock_token', sa.String(), nullable=True), sa.Column('passcode_present', sa.Boolean(), nullable=True), sa.Column('passcode_compliant', sa.Boolean(), nullable=True), sa.Column('passcode_compliant_with_profiles', sa.Boolean(), nullable=True), sa.Column('fde_enabled', sa.Boolean(), nullable=True), sa.Column('fde_has_prk', sa.Boolean(), nullable=True), sa.Column('fde_has_irk', sa.Boolean(), nullable=True), sa.Column('fde_personal_recovery_key_cms', sa.LargeBinary(), nullable=True), sa.Column('fde_personal_recovery_key_device_key', sa.String(), nullable=True), sa.Column('firewall_enabled', sa.Boolean(), nullable=True), sa.Column('block_all_incoming', sa.Boolean(), nullable=True), sa.Column('stealth_mode_enabled', sa.Boolean(), nullable=True), sa.Column('sip_enabled', sa.Boolean(), nullable=True), sa.Column('certificate_id', sa.Integer(), nullable=True), sa.Column('battery_level', sa.Float(), nullable=True), sa.Column('carrier_settings_version', sa.String(), nullable=True), sa.Column('cellular_technology', sa.Enum('Nothing', 'GSM', 'CDMA', 'Both', name='cellulartechnology'), nullable=True), sa.Column('current_carrier_network', sa.String(), nullable=True), sa.Column('current_mcc', sa.String(), nullable=True), sa.Column('current_mnc', sa.String(), nullable=True), sa.Column('data_roaming_enabled', sa.Boolean(), nullable=True), sa.Column('device_id', sa.String(), nullable=True), sa.Column('eas_device_identifier', sa.String(), nullable=True), sa.Column('iccid', sa.String(), nullable=True), sa.Column('imei', sa.String(), nullable=True), sa.Column('is_activation_lock_enabled', sa.Boolean(), nullable=True), sa.Column('is_cloud_backup_enabled', sa.Boolean(), nullable=True), sa.Column('is_device_locator_service_enabled', sa.Boolean(), nullable=True), sa.Column('is_do_not_disturb_in_effect', sa.Boolean(), nullable=True), sa.Column('is_mdm_lost_mode_enabled', sa.Boolean(), nullable=True), sa.Column('is_roaming', sa.Boolean(), nullable=True), sa.Column('is_supervised', sa.Boolean(), nullable=True), sa.Column('itunes_store_account_hash', sa.String(), nullable=True), sa.Column('itunes_store_account_is_active', sa.Boolean(), nullable=True), sa.Column('last_cloud_backup_date', sa.DateTime(), nullable=True), sa.Column('maximum_resident_users', sa.Integer(), nullable=True), sa.Column('meid', sa.String(), nullable=True), sa.Column('modem_firmware_version', sa.String(), nullable=True), sa.Column('passcode_lock_grace_period_enforced', sa.Integer(), nullable=True), sa.Column('personal_hotspot_enabled', sa.Boolean(), nullable=True), sa.Column('phone_number', sa.String(), nullable=True), sa.Column('sim_carrier_network', sa.String(), nullable=True), sa.Column('subscriber_carrier_network', sa.String(), nullable=True), sa.Column('subscriber_mcc', sa.String(), nullable=True), sa.Column('subscriber_mnc', sa.String(), nullable=True), sa.Column('voice_roaming_enabled', sa.Boolean(), nullable=True), sa.Column('activation_lock_escrow_key', sa.String(), nullable=True), sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_devices_serial_number'), 'devices', ['serial_number'], unique=False) op.create_index(op.f('ix_devices_udid'), 'devices', ['udid'], unique=False) def downgrade(): op.drop_index(op.f('ix_devices_udid'), table_name='devices') op.drop_index(op.f('ix_devices_serial_number'), table_name='devices') op.drop_table('devices') ================================================ FILE: commandment/alembic/versions/5b98cc4af6c9_create_profiles_table.py ================================================ """Create profiles table Revision ID: 5b98cc4af6c9 Revises: e78274be170e Create Date: 2017-05-19 19:30:47.058720 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = '5b98cc4af6c9' down_revision = 'e78274be170e' branch_labels = None depends_on = None def upgrade(): op.create_table('profiles', sa.Column('id', sa.Integer(), nullable=False), sa.Column('data', sa.LargeBinary(), nullable=True), sa.Column('payload_type', sa.String(), nullable=True), sa.Column('description', sa.Text(), nullable=True), sa.Column('display_name', sa.String(), nullable=True), sa.Column('expiration_date', sa.DateTime(), nullable=True), sa.Column('identifier', sa.String(), nullable=False), sa.Column('organization', sa.String(), nullable=True), sa.Column('uuid', commandment.dbtypes.GUID(), nullable=True), sa.Column('removal_disallowed', sa.Boolean(), nullable=True), sa.Column('version', sa.Integer(), nullable=True), sa.Column('scope', sa.Enum('User', 'System', name='payloadscope'), nullable=True), sa.Column('removal_date', sa.DateTime(), nullable=True), sa.Column('duration_until_removal', sa.BigInteger(), nullable=True), sa.Column('consent_en', sa.Text(), nullable=True), sa.Column('is_encrypted', sa.Boolean(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_profiles_uuid'), 'profiles', ['uuid'], unique=True) def downgrade(): op.drop_index(op.f('ix_profiles_uuid'), table_name='profiles') op.drop_table('profiles') ================================================ FILE: commandment/alembic/versions/6675e981817e_create_available_os_updates_table.py ================================================ """create available_os_updates table Revision ID: 6675e981817e Revises: 70ff84113e8f Create Date: 2017-06-23 17:40:11.879267 """ from alembic import op, context import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = '6675e981817e' down_revision = '70ff84113e8f' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" # ### commands auto generated by Alembic - please adjust! ### op.create_table('available_os_updates', sa.Column('id', sa.Integer(), nullable=False), sa.Column('device_id', sa.Integer(), nullable=True), sa.Column('allows_install_later', sa.Boolean(), nullable=True), sa.Column('app_identifiers_to_close', commandment.dbtypes.JSONEncodedDict(), nullable=True), sa.Column('human_readable_name', sa.String(), nullable=True), sa.Column('human_readable_name_locale', sa.String(), nullable=True), sa.Column('is_config_data_update', sa.Boolean(), nullable=True), sa.Column('is_critical', sa.Boolean(), nullable=True), sa.Column('is_firmware_update', sa.Boolean(), nullable=True), sa.Column('metadata_url', sa.String(), nullable=True), sa.Column('product_key', sa.String(), nullable=True), sa.Column('restart_required', sa.Boolean(), nullable=True), sa.Column('version', sa.String(), nullable=True), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('id') ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('available_os_updates') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/70ff84113e8f_create_tags.py ================================================ """Create tags table and join tables Revision ID: 70ff84113e8f Revises: 7ae48ae412d7 Create Date: 2017-06-20 17:13:11.572353 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '70ff84113e8f' down_revision = 'dd74229d17b9' branch_labels = None depends_on = None def upgrade(): op.create_table('tags', sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), sa.Column('name', sa.String(), nullable=False), sa.Column('color', sa.String(length=6), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_table('profile_tags', sa.Column('profile_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], ondelete="CASCADE"), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete="CASCADE") ) op.create_index(op.f('ix_profile_tags'), 'profile_tags', ['profile_id', 'tag_id'], unique=True) op.create_table('device_tags', sa.Column('device_id', sa.Integer(), nullable=True), sa.Column('tag_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete="CASCADE"), sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ondelete="CASCADE") ) op.create_index(op.f('ix_device_tags'), 'device_tags', ['device_id', 'tag_id'], unique=True) def downgrade(): op.drop_index(op.f('ix_device_tags'), table_name='device_tags') op.drop_table('device_tags') op.drop_index(op.f('ix_profile_tags'), table_name='profile_tags') op.drop_table('profile_tags') op.drop_table('tags') ================================================ FILE: commandment/alembic/versions/71818e983100_create_application_sources_table.py ================================================ """Create application_sources table Revision ID: 71818e983100 Revises: da52b64b865f Create Date: 2017-05-18 22:29:40.036227 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '71818e983100' down_revision = None branch_labels = None depends_on = None def upgrade(): op.create_table('application_sources', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(), nullable=True), sa.Column('source_type', sa.Enum('S3', 'Munki', name='appsourcetype'), nullable=True), sa.Column('endpoint', sa.String(), nullable=True), sa.Column('mount_uri', sa.String(), nullable=True), sa.Column('use_ssl', sa.Boolean(), nullable=True), sa.Column('access_key', sa.String(), nullable=True), sa.Column('secret_key', sa.String(), nullable=True), sa.Column('bucket', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('application_sources') ================================================ FILE: commandment/alembic/versions/71ecf957301a_create_commands_table.py ================================================ """Create commands table Revision ID: 71ecf957301a Revises: af4ba256efde Create Date: 2017-05-19 19:38:21.450906 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = '71ecf957301a' down_revision = 'af4ba256efde' branch_labels = None depends_on = None def upgrade(): op.create_table('commands', sa.Column('id', sa.Integer(), nullable=False), sa.Column('request_type', sa.String(), nullable=False), sa.Column('uuid', commandment.dbtypes.GUID(), nullable=False), sa.Column('parameters', commandment.dbtypes.JSONEncodedDict(), nullable=True), sa.Column('status', sa.String(length=40), nullable=False), sa.Column('queued_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=True), sa.Column('sent_at', sa.DateTime(), nullable=True), sa.Column('acknowledged_at', sa.DateTime(), nullable=True), sa.Column('after', sa.DateTime(), nullable=True), sa.Column('ttl', sa.Integer(), nullable=False), sa.Column('device_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_commands_status'), 'commands', ['status'], unique=False) op.create_index(op.f('ix_commands_uuid'), 'commands', ['uuid'], unique=True) def downgrade(): op.drop_index(op.f('ix_commands_uuid'), table_name='commands') op.drop_index(op.f('ix_commands_status'), table_name='commands') op.drop_table('commands') ================================================ FILE: commandment/alembic/versions/7ab500f58a76_create_installed_payloads.py ================================================ """create installed_payloads Revision ID: 7ab500f58a76 Revises: f029ac1af3f0 Create Date: 2017-07-19 14:17:49.094292 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '7ab500f58a76' down_revision = 'f029ac1af3f0' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('installed_payloads', sa.Column('id', sa.Integer(), nullable=False), sa.Column('profile_id', sa.Integer(), nullable=False), sa.Column('device_id', sa.Integer(), nullable=False), sa.Column('description', sa.String(), nullable=True), sa.Column('display_name', sa.String(), nullable=True), sa.Column('identifier', sa.String(), nullable=True), sa.Column('organization', sa.String(), nullable=True), sa.Column('payload_type', sa.String(), nullable=True), sa.Column('uuid', commandment.dbtypes.GUID(), nullable=True), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete="CASCADE"), sa.ForeignKeyConstraint(['profile_id'], ['installed_profiles.id'], ondelete="CASCADE"), sa.PrimaryKeyConstraint('id') ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('installed_payloads') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/7cf5787a089e_add_dep_profile_relationships.py ================================================ """add dep profile relationships Revision ID: 7cf5787a089e Revises: b231394ab475 Create Date: 2018-11-06 21:11:54.606189 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '7cf5787a089e' down_revision = 'b231394ab475' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): with op.batch_alter_table('dep_accounts', schema=None) as batch_op: batch_op.add_column(sa.Column('default_dep_profile_id', sa.Integer(), nullable=True)) batch_op.create_foreign_key('fk_dep_accounts_default_dep_profile_id', 'dep_profiles', ['default_dep_profile_id'], ['id']) with op.batch_alter_table('dep_profiles', schema=None) as batch_op: batch_op.add_column(sa.Column('dep_account_id', sa.Integer(), nullable=True)) batch_op.add_column(sa.Column('skip_setup_items', commandment.dbtypes.JSONEncodedDict(), nullable=True)) batch_op.create_foreign_key('fk_dep_profiles_dep_account_id', 'dep_accounts', ['dep_account_id'], ['id']) def schema_downgrades(): with op.batch_alter_table('dep_profiles', schema=None) as batch_op: batch_op.drop_constraint('fk_dep_profiles_dep_account_id', 'dep_profiles', type_='foreignkey') batch_op.drop_column('skip_setup_items') batch_op.drop_column('dep_account_id') with op.batch_alter_table('dep_accounts', schema=None) as batch_op: batch_op.drop_constraint('fk_dep_accounts_default_dep_profile_id', 'dep_accounts', type_='foreignkey') batch_op.drop_column('default_dep_profile_id') ================================================ FILE: commandment/alembic/versions/7d578eb75092_create_device_groups_table.py ================================================ """Create device_groups table Revision ID: 7d578eb75092 Revises: 71818e983100 Create Date: 2017-05-18 22:31:16.686848 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = '7d578eb75092' down_revision = '71818e983100' branch_labels = None depends_on = None def upgrade(): op.create_table('device_groups', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(), nullable=False), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('device_groups') ================================================ FILE: commandment/alembic/versions/80fa1767c7e2_create_oauth_server_models.py ================================================ """Create OAuth Server Models Revision ID: 80fa1767c7e2 Revises: fa4d91c6aacf Create Date: 2019-05-20 20:47:04.928849 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '80fa1767c7e2' down_revision = 'fa4d91c6aacf' branch_labels = None depends_on = None def upgrade(): schema_upgrades() def downgrade(): schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('oauth2_clients', sa.Column('client_id', sa.String(length=48), nullable=True), sa.Column('client_secret', sa.String(length=120), nullable=True), sa.Column('issued_at', sa.Integer(), nullable=False), sa.Column('expires_at', sa.Integer(), nullable=False), sa.Column('redirect_uri', sa.Text(), nullable=True), sa.Column('token_endpoint_auth_method', sa.String(length=48), nullable=True), sa.Column('grant_type', sa.Text(), nullable=False), sa.Column('response_type', sa.Text(), nullable=False), sa.Column('scope', sa.Text(), nullable=False), sa.Column('client_name', sa.String(length=100), nullable=True), sa.Column('client_uri', sa.Text(), nullable=True), sa.Column('logo_uri', sa.Text(), nullable=True), sa.Column('contact', sa.Text(), nullable=True), sa.Column('tos_uri', sa.Text(), nullable=True), sa.Column('policy_uri', sa.Text(), nullable=True), sa.Column('jwks_uri', sa.Text(), nullable=True), sa.Column('jwks_text', sa.Text(), nullable=True), sa.Column('i18n_metadata', sa.Text(), nullable=True), sa.Column('software_id', sa.String(length=36), nullable=True), sa.Column('software_version', sa.String(length=48), nullable=True), sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id') ) with op.batch_alter_table('oauth2_clients', schema=None) as batch_op: batch_op.create_index(batch_op.f('ix_oauth2_clients_client_id'), ['client_id'], unique=False) op.create_table('oauth2_tokens', sa.Column('client_id', sa.String(length=48), nullable=True), sa.Column('token_type', sa.String(length=40), nullable=True), sa.Column('access_token', sa.String(length=255), nullable=False), sa.Column('refresh_token', sa.String(length=255), nullable=True), sa.Column('scope', sa.Text(), nullable=True), sa.Column('revoked', sa.Boolean(), nullable=True), sa.Column('issued_at', sa.Integer(), nullable=False), sa.Column('expires_in', sa.Integer(), nullable=False), sa.Column('id', sa.Integer(), nullable=False), sa.Column('user_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), sa.PrimaryKeyConstraint('id'), sa.UniqueConstraint('access_token') ) with op.batch_alter_table('oauth2_tokens', schema=None) as batch_op: batch_op.create_index(batch_op.f('ix_oauth2_tokens_refresh_token'), ['refresh_token'], unique=False) op.create_table('users', sa.Column('id', sa.Integer(), nullable=False), sa.Column('name', sa.String(), nullable=True), sa.Column('fullname', sa.String(), nullable=True), sa.Column('password', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) def schema_downgrades(): op.drop_table('users') with op.batch_alter_table('oauth2_tokens', schema=None) as batch_op: batch_op.drop_index(batch_op.f('ix_oauth2_tokens_refresh_token')) op.drop_table('oauth2_tokens') with op.batch_alter_table('oauth2_clients', schema=None) as batch_op: batch_op.drop_index(batch_op.f('ix_oauth2_clients_client_id')) op.drop_table('oauth2_clients') ================================================ FILE: commandment/alembic/versions/875dcce0bf8b_create_vpp_users.py ================================================ """Create vpp_users table Revision ID: 875dcce0bf8b Revises: a2e0af380181 Create Date: 2017-07-19 12:56:02.203987 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '875dcce0bf8b' down_revision = 'a2e0af380181' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('vpp_users', sa.Column('user_id', sa.Integer(), nullable=False), sa.Column('client_user_id', commandment.dbtypes.GUID(), nullable=False), sa.Column('email', sa.String(), nullable=True), sa.Column('status', sa.Enum('Registered', 'Associated', 'Retired', 'Deleted', name='vppuserstatus'), nullable=True), sa.Column('invite_url', sa.String(), nullable=True), sa.Column('invite_code', sa.String(), nullable=True), sa.PrimaryKeyConstraint('user_id') ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('vpp_users') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/8c866896f76e_create_dep_join_tables.py ================================================ """empty message Revision ID: 8c866896f76e Revises: 0e5babc5b9ee Create Date: 2017-07-19 12:57:58.086196 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = '8c866896f76e' down_revision = '0e5babc5b9ee' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('dep_profile_anchor_certificates', sa.Column('dep_profile_id', sa.Integer(), nullable=True), sa.Column('certificate_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), sa.ForeignKeyConstraint(['dep_profile_id'], ['dep_profiles.id'], ) ) op.create_table('dep_profile_supervision_certificates', sa.Column('dep_profile_id', sa.Integer(), nullable=True), sa.Column('certificate_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['certificate_id'], ['certificates.id'], ), sa.ForeignKeyConstraint(['dep_profile_id'], ['dep_profiles.id'], ) ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('dep_profile_supervision_certificates') op.drop_table('dep_profile_anchor_certificates') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/__init__.py ================================================ ================================================ FILE: commandment/alembic/versions/a1d5ffaa2092_create_installed_applications_table.py ================================================ """Create installed_applications table Revision ID: a1d5ffaa2092 Revises: a35eeb5a216e Create Date: 2017-05-19 19:43:10.092363 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'a1d5ffaa2092' down_revision = 'a35eeb5a216e' branch_labels = None depends_on = None def upgrade(): op.create_table('installed_applications', sa.Column('id', sa.Integer(), nullable=False), sa.Column('device_udid', sa.String(40), nullable=False), sa.Column('device_id', sa.Integer(), nullable=True), sa.Column('bundle_identifier', sa.String(), nullable=True), sa.Column('version', sa.String(), nullable=True), sa.Column('short_version', sa.String(), nullable=True), sa.Column('name', sa.String(), nullable=True), sa.Column('bundle_size', sa.BigInteger(), nullable=True), sa.Column('dynamic_size', sa.BigInteger(), nullable=True), sa.Column('is_validated', sa.Boolean(), nullable=True), sa.Column('external_version_identifier', sa.BigInteger(), nullable=True), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_installed_applications_bundle_identifier'), 'installed_applications', ['bundle_identifier'], unique=False) op.create_index(op.f('ix_installed_applications_device_udid'), 'installed_applications', ['device_udid'], unique=False) op.create_index(op.f('ix_installed_applications_version'), 'installed_applications', ['version'], unique=False) def downgrade(): op.drop_index(op.f('ix_installed_applications_version'), table_name='installed_applications') op.drop_index(op.f('ix_installed_applications_device_udid'), table_name='installed_applications') op.drop_index(op.f('ix_installed_applications_bundle_identifier'), table_name='installed_applications') op.drop_table('installed_applications') ================================================ FILE: commandment/alembic/versions/a2e0af380181_create_dep_profiles.py ================================================ """Create dep_profiles table Revision ID: a2e0af380181 Revises: 6675e981817e Create Date: 2017-07-19 12:50:41.318647 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'a2e0af380181' down_revision = '6675e981817e' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('dep_profiles', sa.Column('id', sa.Integer(), nullable=False), sa.Column('uuid', commandment.dbtypes.GUID(), nullable=True), sa.Column('profile_name', sa.String(), nullable=False), sa.Column('url', sa.String(), nullable=False), sa.Column('allow_pairing', sa.Boolean(), nullable=True), sa.Column('is_supervised', sa.Boolean(), nullable=True), sa.Column('is_multi_user', sa.Boolean(), nullable=True), sa.Column('is_mandatory', sa.Boolean(), nullable=True), sa.Column('await_device_configured', sa.Boolean(), nullable=True), sa.Column('is_mdm_removable', sa.Boolean(), nullable=True), sa.Column('support_phone_number', sa.String(), nullable=True), sa.Column('auto_advance_setup', sa.Boolean(), nullable=True), sa.Column('support_email_address', sa.String(), nullable=True), sa.Column('org_magic', sa.String(), nullable=True), sa.Column('department', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_dep_profiles_uuid'), 'dep_profiles', ['uuid'], unique=False) # op.create_foreign_key(None, 'devices', 'dep_profiles', ['dep_profile_id'], ['id']) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_index(op.f('ix_dep_profiles_uuid'), table_name='dep_profiles') op.drop_table('dep_profiles') # op.drop_constraint(None, 'devices', type_='foreignkey') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/a35eeb5a216e_create_installed_profiles_table.py ================================================ """Create installed_profiles table Revision ID: a35eeb5a216e Revises: e16577adc4fd Create Date: 2017-05-19 19:41:46.995463 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'a35eeb5a216e' down_revision = 'e16577adc4fd' branch_labels = None depends_on = None def upgrade(): op.create_table('installed_profiles', sa.Column('id', sa.Integer(), nullable=False), sa.Column('device_udid', sa.String(40), nullable=False), sa.Column('device_id', sa.Integer(), nullable=True), sa.Column('has_removal_password', sa.Boolean(), nullable=True), sa.Column('is_encrypted', sa.Boolean(), nullable=True), sa.Column('is_managed', sa.Boolean(), nullable=True), sa.Column('payload_description', sa.String(), nullable=True), sa.Column('payload_display_name', sa.String(), nullable=True), sa.Column('payload_identifier', sa.String(), nullable=True), sa.Column('payload_organization', sa.String(), nullable=True), sa.Column('payload_removal_disallowed', sa.Boolean(), nullable=True), sa.Column('payload_uuid', commandment.dbtypes.GUID(), nullable=True), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_installed_profiles_device_udid'), 'installed_profiles', ['device_udid'], unique=False) op.create_index(op.f('ix_installed_profiles_payload_uuid'), 'installed_profiles', ['payload_uuid'], unique=False) def downgrade(): op.drop_index(op.f('ix_installed_profiles_payload_uuid'), table_name='installed_profiles') op.drop_index(op.f('ix_installed_profiles_device_udid'), table_name='installed_profiles') op.drop_table('installed_profiles') ================================================ FILE: commandment/alembic/versions/a3ddaad5c358_add_dep_device_columns.py ================================================ """Add DEP device columns Revision ID: a3ddaad5c358 Revises: 2808deb9fc62 Create Date: 2018-07-04 21:44:41.549806 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'a3ddaad5c358' down_revision = '2808deb9fc62' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.add_column('devices', sa.Column('description', sa.String(), nullable=True)) op.add_column('devices', sa.Column('asset_tag', sa.String(), nullable=True)) op.add_column('devices', sa.Column('color', sa.String(), nullable=True)) op.add_column('devices', sa.Column('device_assigned_by', sa.String(), nullable=True)) op.add_column('devices', sa.Column('device_assigned_date', sa.DateTime(), nullable=True)) op.add_column('devices', sa.Column('device_family', sa.String(), nullable=True)) op.add_column('devices', sa.Column('is_dep', sa.Boolean(), nullable=True)) op.add_column('devices', sa.Column('os', sa.String(), nullable=True)) op.add_column('devices', sa.Column('profile_assign_time', sa.DateTime(), nullable=True)) op.add_column('devices', sa.Column('profile_push_time', sa.DateTime(), nullable=True)) op.add_column('devices', sa.Column('profile_status', sa.String(), nullable=True)) op.add_column('devices', sa.Column('profile_uuid', sa.String(), nullable=True)) def schema_downgrades(): op.drop_column('devices', 'profile_uuid') op.drop_column('devices', 'profile_status') op.drop_column('devices', 'profile_push_time') op.drop_column('devices', 'profile_assign_time') op.drop_column('devices', 'os') op.drop_column('devices', 'is_dep') op.drop_column('devices', 'device_family') op.drop_column('devices', 'device_assigned_date') op.drop_column('devices', 'device_assigned_by') op.drop_column('devices', 'color') op.drop_column('devices', 'asset_tag') op.drop_column('devices', 'description') # def data_upgrades(): # """Add any optional data upgrade migrations here!""" # pass # # # def data_downgrades(): # """Add any optional data downgrade migrations here!""" # pass ================================================ FILE: commandment/alembic/versions/af4ba256efde_create_certificates_table.py ================================================ """Create certificates table Revision ID: af4ba256efde Revises: 0ab46b2f6d8c Create Date: 2017-05-19 19:36:12.171390 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'af4ba256efde' down_revision = '0ab46b2f6d8c' branch_labels = None depends_on = None def upgrade(): op.create_table('certificates', sa.Column('id', sa.Integer(), nullable=False), sa.Column('pem_data', sa.Text(), nullable=False), sa.Column('rsa_private_key_id', sa.Integer(), nullable=True), sa.Column('x509_cn', sa.String(length=64), nullable=True), sa.Column('x509_ou', sa.String(length=32), nullable=True), sa.Column('x509_o', sa.String(length=64), nullable=True), sa.Column('x509_c', sa.String(length=2), nullable=True), sa.Column('x509_st', sa.String(length=128), nullable=True), sa.Column('not_before', sa.DateTime(), nullable=False), sa.Column('not_after', sa.DateTime(), nullable=False), # NOTE: serial was changed from BigInteger because cryptography could generate a serial number at # random that could produce an integer overflow. sa.Column('serial', sa.String(), nullable=True), sa.Column('fingerprint', sa.String(length=64), nullable=False), sa.Column('push_topic', sa.String(), nullable=True), sa.Column('discriminator', sa.String(length=20), nullable=True), sa.ForeignKeyConstraint(['rsa_private_key_id'], ['rsa_private_keys.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_certificates_fingerprint'), 'certificates', ['fingerprint'], unique=False) def downgrade(): op.drop_index(op.f('ix_certificates_fingerprint'), table_name='certificates') op.drop_table('certificates') ================================================ FILE: commandment/alembic/versions/b231394ab475_add_scep_config_source_types.py ================================================ """add scep_config source types Revision ID: b231394ab475 Revises: a3ddaad5c358 Create Date: 2018-09-07 07:50:10.467330 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'b231394ab475' down_revision = 'a3ddaad5c358' branch_labels = None depends_on = None def upgrade(): schema_upgrades() def downgrade(): schema_downgrades() def schema_upgrades(): op.add_column('scep_config', sa.Column('source_type', sa.Enum('InternalPKCS12', 'InternalSCEP', 'ExternalSCEP', name='deviceidentitysources'), nullable=True)) def schema_downgrades(): op.drop_column('scep_config', 'source_type') ================================================ FILE: commandment/alembic/versions/b74ca08cfd9a_create_applications_tables.py ================================================ """create applications tables Revision ID: b74ca08cfd9a Revises: 2f1507bf6dc1 Create Date: 2017-10-19 21:26:19.927682 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'b74ca08cfd9a' down_revision = '2f1507bf6dc1' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('applications', sa.Column('id', sa.Integer(), nullable=False), sa.Column('display_name', sa.String(), nullable=False), sa.Column('description', sa.String(), nullable=True), sa.Column('version', sa.String(), nullable=True), sa.Column('itunes_store_id', sa.Integer(), nullable=True), sa.Column('bundle_id', sa.String(), nullable=False), sa.Column('purchase_method', sa.Integer(), nullable=True), sa.Column('manifest_url', sa.String(), nullable=True), sa.Column('management_flags', sa.Integer(), nullable=True), sa.Column('change_management_state', sa.String(), nullable=True), sa.Column('discriminator', sa.String(length=20), nullable=True), sa.Column('country', sa.String(length=2), nullable=True), sa.Column('artist_id', sa.Integer(), nullable=True), sa.Column('artist_name', sa.String(), nullable=True), sa.Column('artist_view_url', sa.String(), nullable=True), sa.Column('artwork_url60', sa.String(), nullable=True), sa.Column('artwork_url100', sa.String(), nullable=True), sa.Column('artwork_url512', sa.String(), nullable=True), sa.Column('release_notes', sa.Text(), nullable=True), sa.Column('release_date', sa.DateTime(), nullable=True), sa.Column('minimum_os_version', sa.String(), nullable=True), sa.Column('file_size_bytes', sa.BigInteger(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_applications_bundle_id'), 'applications', ['bundle_id'], unique=False) op.create_index(op.f('ix_applications_discriminator'), 'applications', ['discriminator'], unique=False) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_index(op.f('ix_applications_discriminator'), table_name='applications') op.drop_index(op.f('ix_applications_bundle_id'), table_name='applications') op.drop_table('applications') # ### end Alembic commands ### def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/ba4849d8c8ad_create_device_group_devices_table.py ================================================ """Create device_group_devices table Revision ID: ba4849d8c8ad Revises: a1d5ffaa2092 Create Date: 2017-05-19 19:44:37.403554 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'ba4849d8c8ad' down_revision = 'a1d5ffaa2092' branch_labels = None depends_on = None def upgrade(): op.create_table('device_group_devices', sa.Column('device_group_id', sa.Integer(), nullable=False), sa.Column('device_id', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['device_group_id'], ['device_groups.id'], ), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('device_group_id', 'device_id') ) def downgrade(): op.drop_table('device_group_devices') ================================================ FILE: commandment/alembic/versions/d5b32b5cc74e_add_dep_profile_id_to_device.py ================================================ """add dep profile id to device Revision ID: d5b32b5cc74e Revises: 1005dc7dea01 Create Date: 2018-03-13 21:16:23.964086 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'd5b32b5cc74e' down_revision = '1005dc7dea01' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.add_column('devices', sa.Column('dep_profile_id', sa.Integer(), nullable=True)) # Unsupported on SQLite3 # op.create_foreign_key(None, 'devices', 'dep_profiles', ['dep_profile_id'], ['id']) def schema_downgrades(): """schema downgrade migrations go here.""" # Unsupported on SQLite3 # op.drop_constraint(None, 'devices', type_='foreignkey') op.drop_column('devices', 'dep_profile_id') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/dd74229d17b9_create_payload_dependencies_table.py ================================================ """Create payload_dependencies table Revision ID: dd74229d17b9 Revises: d65049bf4b91 Create Date: 2017-05-19 20:02:17.116286 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'dd74229d17b9' down_revision = 'e5840df9a88a' branch_labels = None depends_on = None def upgrade(): op.create_table('payload_dependencies', sa.Column('payload_uuid', commandment.dbtypes.GUID(), nullable=True), sa.Column('depends_on_payload_uuid', commandment.dbtypes.GUID(), nullable=True), sa.ForeignKeyConstraint(['depends_on_payload_uuid'], ['payloads.uuid'], ), sa.ForeignKeyConstraint(['payload_uuid'], ['payloads.uuid'], ) ) def downgrade(): op.drop_table('payload_dependencies') ================================================ FILE: commandment/alembic/versions/e16577adc4fd_create_installed_certificates_table.py ================================================ """Create installed_certificates table Revision ID: e16577adc4fd Revises: 50188ffaf0cd Create Date: 2017-05-19 19:40:56.436486 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'e16577adc4fd' down_revision = '50188ffaf0cd' branch_labels = None depends_on = None def upgrade(): op.create_table('installed_certificates', sa.Column('id', sa.Integer(), nullable=False), sa.Column('device_udid', sa.String(40), nullable=False), sa.Column('device_id', sa.Integer(), nullable=True), sa.Column('x509_cn', sa.String(), nullable=True), sa.Column('is_identity', sa.Boolean(), nullable=True), sa.Column('der_data', sa.LargeBinary(), nullable=False), sa.Column('fingerprint_sha256', sa.String(length=64), nullable=False), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_installed_certificates_device_udid'), 'installed_certificates', ['device_udid'], unique=False) op.create_index(op.f('ix_installed_certificates_fingerprint_sha256'), 'installed_certificates', ['fingerprint_sha256'], unique=False) def downgrade(): op.drop_index(op.f('ix_installed_certificates_fingerprint_sha256'), table_name='installed_certificates') op.drop_index(op.f('ix_installed_certificates_device_udid'), table_name='installed_certificates') op.drop_table('installed_certificates') ================================================ FILE: commandment/alembic/versions/e5840df9a88a_create_scep_payload_table.py ================================================ """Create scep_payload table Revision ID: e5840df9a88a Revises: fc0c134cbb2e Create Date: 2017-05-19 19:58:54.048729 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'e5840df9a88a' down_revision = '13358fb3846b' branch_labels = None depends_on = None def upgrade(): op.create_table('scep_payload', sa.Column('id', sa.Integer(), nullable=False), sa.Column('url', sa.String(), nullable=False), sa.Column('name', sa.String(), nullable=True), sa.Column('subject', commandment.dbtypes.JSONEncodedDict(), nullable=False), sa.Column('challenge', sa.String(), nullable=True), sa.Column('key_size', sa.Integer(), nullable=False), sa.Column('ca_fingerprint', sa.LargeBinary(), nullable=True), sa.Column('key_type', sa.String(), nullable=False), sa.Column('key_usage', sa.Enum('Signing', 'Encryption', 'All', name='keyusage'), nullable=True), sa.Column('subject_alt_name', sa.String(), nullable=True), sa.Column('retries', sa.Integer(), nullable=False), sa.Column('retry_delay', sa.Integer(), nullable=False), sa.Column('certificate_renewal_time_interval', sa.Integer(), nullable=False), sa.ForeignKeyConstraint(['id'], ['payloads.id'], ), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('scep_payload') ================================================ FILE: commandment/alembic/versions/e58afdc17baa_create_rsa_private_keys_table.py ================================================ """Create rsa_private_keys table Revision ID: e58afdc17baa Revises: 5b98cc4af6c9 Create Date: 2017-05-19 19:32:28.454940 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'e58afdc17baa' down_revision = '5b98cc4af6c9' branch_labels = None depends_on = None def upgrade(): op.create_table('rsa_private_keys', sa.Column('id', sa.Integer(), nullable=False), sa.Column('pem_data', sa.Text(), nullable=False), sa.PrimaryKeyConstraint('id') ) def downgrade(): op.drop_table('rsa_private_keys') ================================================ FILE: commandment/alembic/versions/e78274be170e_create_organizations_table.py ================================================ """Create organizations table Revision ID: e78274be170e Revises: e9b0a4f7b595 Create Date: 2017-05-19 19:28:42.596244 """ from alembic import op import sqlalchemy as sa from sqlalchemy.sql import table from alembic import context # revision identifiers, used by Alembic. revision = 'e78274be170e' down_revision = 'e9b0a4f7b595' branch_labels = None depends_on = None TABLE = ( 'organizations', sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), sa.Column('name', sa.String(), nullable=True), sa.Column('payload_prefix', sa.String(), nullable=True), sa.Column('x509_ou', sa.String(length=32), nullable=True), sa.Column('x509_o', sa.String(length=64), nullable=True), sa.Column('x509_st', sa.String(length=128), nullable=True), sa.Column('x509_c', sa.String(length=2), nullable=True), sa.PrimaryKeyConstraint('id') ) DEMO_ORGANIZATION = { 'name': 'Commandment Inc', 'payload_prefix': 'dev.commandment', 'x509_c': 'US', 'x509_o': 'Commandment', 'x509_ou': 'MDM' } def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.create_table(*TABLE) def schema_downgrades(): op.drop_table('organizations') def data_upgrades(): tbl = table(*TABLE[:-1]) op.bulk_insert(tbl, [ DEMO_ORGANIZATION ]) def data_downgrades(): pass ================================================ FILE: commandment/alembic/versions/e947cdf82307_add_ios_installed_application_fields.py ================================================ """add ios installed application fields Revision ID: e947cdf82307 Revises: 3061e56045eb Create Date: 2018-07-01 20:30:53.621855 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'e947cdf82307' down_revision = '3061e56045eb' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.add_column('installed_applications', sa.Column('adhoc_codesigned', sa.Boolean(), nullable=True)) op.add_column('installed_applications', sa.Column('appstore_vendable', sa.Boolean(), nullable=True)) op.add_column('installed_applications', sa.Column('beta_app', sa.Boolean(), nullable=True)) op.add_column('installed_applications', sa.Column('device_based_vpp', sa.Boolean(), nullable=True)) op.add_column('installed_applications', sa.Column('has_update_available', sa.Boolean(), nullable=True)) op.add_column('installed_applications', sa.Column('installing', sa.Boolean(), nullable=True)) op.create_index(op.f('ix_installed_applications_external_version_identifier'), 'installed_applications', ['external_version_identifier'], unique=False) def schema_downgrades(): op.drop_index(op.f('ix_installed_applications_external_version_identifier'), table_name='installed_applications') op.drop_column('installed_applications', 'installing') op.drop_column('installed_applications', 'has_update_available') op.drop_column('installed_applications', 'device_based_vpp') op.drop_column('installed_applications', 'beta_app') op.drop_column('installed_applications', 'appstore_vendable') op.drop_column('installed_applications', 'adhoc_codesigned') # def data_upgrades(): # """Add any optional data upgrade migrations here!""" # pass # # # def data_downgrades(): # """Add any optional data downgrade migrations here!""" # pass ================================================ FILE: commandment/alembic/versions/e9b0a4f7b595_create_payloads_table.py ================================================ """Create payloads table Revision ID: e9b0a4f7b595 Revises: 0c4c448f4daf Create Date: 2017-05-18 22:34:37.838655 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes # revision identifiers, used by Alembic. revision = 'e9b0a4f7b595' down_revision = '0c4c448f4daf' branch_labels = None depends_on = None def upgrade(): op.create_table('payloads', sa.Column('id', sa.Integer(), nullable=False), sa.Column('type', sa.String(), nullable=False), sa.Column('version', sa.Integer(), nullable=True), sa.Column('identifier', sa.String(), nullable=True), sa.Column('uuid', commandment.dbtypes.GUID(), nullable=False), sa.Column('display_name', sa.String(), nullable=True), sa.Column('description', sa.Text(), nullable=True), sa.Column('organization', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) op.create_index(op.f('ix_payloads_type'), 'payloads', ['type'], unique=False) op.create_index(op.f('ix_payloads_uuid'), 'payloads', ['uuid'], unique=False) def downgrade(): op.drop_index(op.f('ix_payloads_uuid'), table_name='payloads') op.drop_index(op.f('ix_payloads_type'), table_name='payloads') op.drop_table('payloads') ================================================ FILE: commandment/alembic/versions/ea34ae3f1e7e_create_profile_payloads_table.py ================================================ """Create profile_payloads table Revision ID: ea34ae3f1e7e Revises: ba4849d8c8ad Create Date: 2017-05-19 19:45:34.375475 """ from alembic import op import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'ea34ae3f1e7e' down_revision = 'ba4849d8c8ad' branch_labels = None depends_on = None def upgrade(): op.create_table('profile_payloads', sa.Column('profile_id', sa.Integer(), nullable=True), sa.Column('payload_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['payload_id'], ['payloads.id'], ), sa.ForeignKeyConstraint(['profile_id'], ['profiles.id'], ) ) def downgrade(): op.drop_table('profile_payloads') ================================================ FILE: commandment/alembic/versions/f029ac1af3f0_create_vpp_accounts.py ================================================ """Create vpp_accounts table Revision ID: f029ac1af3f0 Revises: 8c866896f76e Create Date: 2017-07-19 13:02:13.563903 """ from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'f029ac1af3f0' down_revision = '8c866896f76e' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" op.create_table('vpp_accounts', sa.Column('id', sa.Integer(), nullable=False), sa.Column('stoken', sa.String(), nullable=False), sa.Column('licenses_since_modified_token', sa.String(), nullable=True), sa.Column('licenses_batch_token', sa.String(), nullable=True), sa.Column('users_since_modified_token', sa.String(), nullable=True), sa.Column('users_batch_token', sa.String(), nullable=True), sa.PrimaryKeyConstraint('id') ) def schema_downgrades(): """schema downgrade migrations go here.""" op.drop_table('vpp_accounts') def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/f5237c7e2374_create_scep_config_table.py ================================================ """Create scep_config table Revision ID: f5237c7e2374 Revises: e58afdc17baa Create Date: 2017-05-19 19:34:00.120370 """ from alembic import op, context import sqlalchemy as sa # revision identifiers, used by Alembic. revision = 'f5237c7e2374' down_revision = 'e58afdc17baa' branch_labels = None depends_on = None TABLE = ('scep_config', sa.Column('id', sa.Integer(), nullable=False, autoincrement=True), sa.Column('url', sa.String(), nullable=False), sa.Column('challenge_enabled', sa.Boolean(), nullable=True), sa.Column('challenge', sa.String(), nullable=True), sa.Column('ca_fingerprint', sa.String(), nullable=True), sa.Column('subject', sa.String(), nullable=False), sa.Column('key_size', sa.Integer(), nullable=False), sa.Column('key_type', sa.String(), nullable=False, server_default='RSA'), sa.Column('key_usage', sa.Enum('Signing', 'Encryption', 'All', name='keyusage'), nullable=True), sa.Column('retries', sa.Integer(), nullable=False), sa.Column('retry_delay', sa.Integer(), nullable=False), sa.Column('certificate_renewal_time_interval', sa.Integer(), nullable=False), sa.PrimaryKeyConstraint('id') ) DEMO_SCEP_CONFIG = { 'url': 'http://localhost:5000', 'challenge_enabled': True, 'challenge': 'sekret', 'subject': 'CN=%HardwareUUID%', 'key_size': 2048, 'key_type': 'RSA', 'key_usage': 'All', 'retries': 3, 'retry_delay': 10, 'certificate_renewal_time_interval': 24 } def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): op.create_table(*TABLE) def schema_downgrades(): op.drop_table('scep_config') def data_upgrades(): tbl = sa.table(*TABLE[:-1]) op.bulk_insert(tbl, [ DEMO_SCEP_CONFIG ]) def data_downgrades(): pass ================================================ FILE: commandment/alembic/versions/f8eb70b3aa2b_create_application_manifests.py ================================================ """create application manifests Revision ID: f8eb70b3aa2b Revises: d5b32b5cc74e Create Date: 2018-03-13 21:21:31.277764 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'f8eb70b3aa2b' down_revision = 'd5b32b5cc74e' branch_labels = None depends_on = None def upgrade(): schema_upgrades() # if context.get_x_argument(as_dictionary=True).get('data', None): # data_upgrades() def downgrade(): # if context.get_x_argument(as_dictionary=True).get('data', None): # data_downgrades() schema_downgrades() def schema_upgrades(): """schema upgrade migrations go here.""" # op.alter_column('application_manifest_checksums', 'application_manifest_id', # existing_type=sa.INTEGER(), # nullable=True) #op.drop_constraint('uq_application_checksum_index', 'application_manifest_checksums', type_='unique') #op.drop_constraint(None, 'application_manifest_checksums', type_='foreignkey') #op.create_foreign_key(None, 'application_manifest_checksums', 'application_manifests', ['application_manifest_id'], ['id']) # op.add_column('application_manifests', sa.Column('full_size_image_needs_shine', sa.Boolean(), nullable=True)) # op.add_column('application_manifests', sa.Column('full_size_image_url', sa.String(), nullable=True)) # op.alter_column('application_manifests', 'bundle_id', # existing_type=sa.VARCHAR(), # nullable=False) op.create_index(op.f('ix_application_manifests_bundle_id'), 'application_manifests', ['bundle_id'], unique=False) op.create_index(op.f('ix_application_manifests_bundle_version'), 'application_manifests', ['bundle_version'], unique=False) # op.drop_constraint('uq_application_bundle_version', 'application_manifests', type_='unique') #op.drop_column('application_manifests', 'full_image_url') #op.drop_column('application_manifests', 'full_image_needs_shine') def schema_downgrades(): """schema downgrade migrations go here.""" #op.add_column('application_manifests', sa.Column('full_image_needs_shine', sa.BOOLEAN(), nullable=True)) #op.add_column('application_manifests', sa.Column('full_image_url', sa.VARCHAR(), nullable=True)) # op.create_unique_constraint('uq_application_bundle_version', 'application_manifests', ['bundle_id', 'bundle_version']) op.drop_index(op.f('ix_application_manifests_bundle_version'), table_name='application_manifests') op.drop_index(op.f('ix_application_manifests_bundle_id'), table_name='application_manifests') # op.alter_column('application_manifests', 'bundle_id', # existing_type=sa.VARCHAR(), # nullable=True) # op.drop_column('application_manifests', 'full_size_image_url') # op.drop_column('application_manifests', 'full_size_image_needs_shine') #op.drop_constraint(None, 'application_manifest_checksums', type_='foreignkey') #op.create_foreign_key(None, 'application_manifest_checksums', 'application_manifests', ['application_manifest_id'], ['id'], ondelete='CASCADE') #op.create_unique_constraint('uq_application_checksum_index', 'application_manifest_checksums', ['application_manifest_id', 'checksum_index']) # op.alter_column('application_manifest_checksums', 'application_manifest_id', # existing_type=sa.INTEGER(), # nullable=False) def data_upgrades(): """Add any optional data upgrade migrations here!""" pass def data_downgrades(): """Add any optional data downgrade migrations here!""" pass ================================================ FILE: commandment/alembic/versions/fa4d91c6aacf_create_managed_applications_table.py ================================================ """create_managed_applications_table Revision ID: fa4d91c6aacf Revises: 3dbf6db7f9eb Create Date: 2019-01-10 10:01:10.750225 """ # From: http://alembic.zzzcomputing.com/en/latest/cookbook.html#conditional-migration-elements from alembic import op import sqlalchemy as sa import commandment.dbtypes from alembic import context # revision identifiers, used by Alembic. revision = 'fa4d91c6aacf' down_revision = '3dbf6db7f9eb' branch_labels = None depends_on = None def upgrade(): schema_upgrades() def downgrade(): schema_downgrades() def schema_upgrades(): op.create_table('managed_applications', sa.Column('id', sa.Integer(), nullable=False), sa.Column('device_id', sa.Integer(), nullable=True), sa.Column('bundle_id', sa.String(), nullable=True), sa.Column('external_version_id', sa.Integer(), nullable=True), sa.Column('has_configuration', sa.Boolean(), nullable=True), sa.Column('has_feedback', sa.Boolean(), nullable=True), sa.Column('is_validated', sa.Boolean(), nullable=True), sa.Column('management_flags', sa.Integer(), nullable=True), sa.Column('status', sa.String(), nullable=True), sa.Column('application_id', sa.Integer(), nullable=True), sa.Column('ia_command_id', sa.Integer(), nullable=True), sa.ForeignKeyConstraint(['application_id'], ['applications.id'], ), sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ), sa.ForeignKeyConstraint(['ia_command_id'], ['commands.id'], ), sa.PrimaryKeyConstraint('id') ) def schema_downgrades(): op.drop_table('managed_applications') ================================================ FILE: commandment/api/__init__.py ================================================ ================================================ FILE: commandment/api/app_json.py ================================================ """ This module contains API endpoints which do not fit with the JSON-API specification. """ import io from flask import Blueprint, send_file, abort, current_app, jsonify, request, make_response from sqlalchemy.orm.exc import NoResultFound import plistlib import string from commandment.plistutil.nonewriter import dumps as dumps_none from base64 import urlsafe_b64encode from commandment.models import db, Organization, Device, Command from commandment.pki.models import Certificate, RSAPrivateKey from commandment.profiles.models import Profile from commandment.mdm import commands, Platform from .schema import OrganizationFlatSchema from commandment.profiles.schema import ProfileSchema from commandment.profiles.plist_schema import ProfileSchema as ProfilePlistSchema flat_api = Blueprint('flat_api', __name__) # @flat_api.errorhandler(400) # def bad_request(e): # return jsonify({'errors': [ # { # 'status': '400', # 'title': str(e), # } # ]} @flat_api.route('/v1/organization', methods=['GET']) def organization_get(): """Retrieve information about the MDM home organization. Only returns a pseudo JSON-API representation because the standard has no definition for `singleton` resources. """ try: o = db.session.query(Organization).one() except NoResultFound: return abort(400, 'No organization details found') org_schema = OrganizationFlatSchema() result = org_schema.dumps(o) return jsonify(result.data) @flat_api.route('/v1/download/certificates/') def download_certificate(certificate_id: int): """Download a certificate in PEM format :reqheader Accept: application/x-pem-file :reqheader Accept: application/x-x509-user-cert :reqheader Accept: application/x-x509-ca-cert :resheader Content-Type: application/x-pem-file :resheader Content-Type: application/x-x509-user-cert :resheader Content-Type: application/x-x509-ca-cert :statuscode 200: OK :statuscode 404: There is no certificate configured :statuscode 400: Can't produce requested encoding """ c = db.session.query(Certificate).filter(Certificate.id == certificate_id).one() bio = io.BytesIO(c.pem_data) return send_file(bio, 'application/x-pem-file', True, 'certificate.pem') @flat_api.route('/v1/rsa_private_keys//download') def download_key(rsa_private_key_id: int): """Download an RSA private key in PEM or DER format :reqheader Accept: application/x-pem-file :reqheader Accept: application/pkcs8 :resheader Content-Type: application/x-pem-file :resheader Content-Type: application/pkcs8 :statuscode 200: OK :statuscode 404: Not found :statuscode 400: Can't produce requested encoding """ if not current_app.debug: abort(500, 'Not supported in this mode') c = db.session.query(RSAPrivateKey).filter(RSAPrivateKey.id == rsa_private_key_id).one() bio = io.BytesIO(c.pem_data) return send_file(bio, 'application/x-pem-file', True, 'rsa_private_key.pem') @flat_api.route('/v1/devices/test/', methods=['POST']) def device_test(device_id: int): """Testing endpoint for quick and dirty command checking""" d = db.session.query(Device).filter(Device.id == device_id).one() #ia = commands.InstallApplication(ManifestURL='https://localhost:5443/static/appmanifest/munkitools-3.1.0.3430.plist') ia = commands.Settings(bluetooth=False) dbc = Command.from_model(ia) dbc.device = d db.session.add(dbc) db.session.commit() return 'OK' @flat_api.route('/v1/devices/inventory/') def device_inventory(device_id: int): """Enqueue ALL inventory commands to refresh the device's entire inventory. :statuscode 200: OK """ d = db.session.query(Device).filter(Device.id == device_id).one() # DeviceInformation di = commands.DeviceInformation.for_platform(d.platform, d.os_version) db_command = Command.from_model(di) db_command.device = d db.session.add(db_command) # InstalledApplicationList - Pretty taxing so don't run often # ial = commands.InstalledApplicationList() # db_command_ial = Command.from_model(ial) # db_command_ial.device = d # db.session.add(db_command_ial) # CertificateList cl = commands.CertificateList() dbc = Command.from_model(cl) dbc.device = d db.session.add(dbc) # SecurityInfo si = commands.SecurityInfo() dbsi = Command.from_model(si) dbsi.device = d db.session.add(dbsi) # ProfileList pl = commands.ProfileList() db_pl = Command.from_model(pl) db_pl.device = d db.session.add(db_pl) # AvailableOSUpdates au = commands.AvailableOSUpdates() au_pl = Command.from_model(au) au_pl.device = d db.session.add(au_pl) mal = commands.ManagedApplicationList() mal_pl = Command.from_model(mal) mal_pl.device = d db.session.add(mal_pl) db.session.commit() return 'OK' @flat_api.route('/v1/devices//clear_passcode', methods=['POST']) def clear_passcode(device_id: int): """Enqueues a ClearPasscode command for the device id specified. :reqheader Accept: application/vnd.api+json :reqheader Content-Type: ? :resheader Content-Type: application/vnd.api+json :statuscode 201: command created :statuscode 400: not applicable to this device :statuscode 404: device with this identifier was not found :statuscode 500: system error """ d = db.session.query(Device).filter(Device.id == device_id).one() if d.platform == Platform.macOS: return abort(400, 'ClearPasscode is not supported on Mac computers') if d.unlock_token is None: return abort(400, 'No UnlockToken is available for this device') cp = commands.ClearPasscode(UnlockToken=urlsafe_b64encode(d.unlock_token).decode('utf-8')) cp_pl = Command.from_model(cp) cp_pl.device = d db.session.add(cp_pl) db.session.commit() return 'OK', 201, {} @flat_api.route('/v1/devices//lock', methods=['POST']) def lock(device_id: int): """Enqueues a DeviceLock command for the device id specified. If the target device is a macOS device, a 6 digit Find My Mac PIN will be automatically generated and stored with the device record (and also output in the response). :reqheader Accept: application/vnd.api+json :reqheader Content-Type: ? :resheader Content-Type: application/vnd.api+json :statuscode 201: command created :statuscode 400: not applicable to this device :statuscode 404: device with this identifier was not found :statuscode 500: system error """ d = db.session.query(Device).filter(Device.id == device_id).one() if d.platform == Platform.macOS: return abort(400, 'Not Implemented') dl = commands.DeviceLock() dl_pl = Command.from_model(dl) dl_pl.device = d db.session.add(dl_pl) db.session.commit() return 'OK', 201, {} @flat_api.route('/v1/devices//restart', methods=['POST']) def restart(device_id: int): """Enqueues a RestartDevice command for the device id specified. :reqheader Accept: application/json :reqheader Content-Type: application/json :resheader Content-Type: application/json :statuscode 201: command created :statuscode 400: not applicable to this device. returned if this device is not supervised or not capable of taking command. :statuscode 404: device with this identifier was not found :statuscode 500: system error """ d: Device = db.session.query(Device).filter(Device.id == device_id).one() if d.model_name == 'iPhone' and not d.is_supervised: return 'Cannot restart an unsupervised iOS device', 400, {} cmd = commands.RestartDevice() orm_cmd = Command.from_model(cmd) orm_cmd.device = d db.session.add(orm_cmd) db.session.commit() return 'OK' @flat_api.route('/v1/devices//shutdown', methods=['POST']) def shutdown(device_id: int): """Enqueues a Shutdown command for the device id specified. :reqheader Accept: application/json :reqheader Content-Type: application/json :resheader Content-Type: application/json :statuscode 201: command created :statuscode 400: not applicable to this device :statuscode 404: device with this identifier was not found :statuscode 500: system error """ d = db.session.query(Device).filter(Device.id == device_id).one() if d.model_name == 'iPhone' and not d.is_supervised: return 'Cannot shut down an unsupervised iOS device', 400, {} cmd = commands.ShutDownDevice() orm_cmd = Command.from_model(cmd) orm_cmd.device = d db.session.add(orm_cmd) db.session.commit() return 'OK' @flat_api.route('/v1/upload/profiles', methods=['POST']) def upload_profile(): """Upload a custom profile using multipart/form-data I.E from an upload input. Encrypted profiles are not supported. The profiles contents will be stored using the following process: - For the top level profile (and each payload) there is a marshmallow schema which maps the payload keys into the SQLAlchemy model keys. It is also the responsibility of the marshmallow schema to be the validator for uploaded profiles. - The profile itself is inserted as a Profile model. - Each payload is unmarshalled using marshmallow to a specific Payload model. Each specific model contains a join table inheritance to the base ``payloads`` table. The returned body contains a jsonapi object with details of the newly created profile and associated payload ID's. Note: Does not support ``application/x-www-form-urlencoded`` TODO: - Support signed profiles :reqheader Accept: application/vnd.api+json :reqheader Content-Type: multipart/form-data :resheader Content-Type: application/vnd.api+json :statuscode 201: profile created :statuscode 400: If the request contained malformed or missing payload data. :statuscode 500: If something else went wrong with parsing or persisting the payload(s) """ if 'file' not in request.files: abort(400, 'no file uploaded in request data') f = request.files['file'] if not f.content_type == 'application/x-apple-aspen-config': abort(400, 'incorrect MIME type in request') try: data = f.read() plist = plistlib.loads(data) profile = ProfilePlistSchema().load(plist).data except plistlib.InvalidFileException as e: current_app.logger.error(e) abort(400, 'invalid plist format supplied') except BaseException as e: # TODO: separate errors for exceptions caught here current_app.logger.error(e) abort(400, 'cannot parse the supplied profile') profile.data = data db.session.add(profile) db.session.commit() profile_schema = ProfileSchema() model_data = profile_schema.dump(profile).data resp = make_response(jsonify(model_data), 201, {'Content-Type': 'application/vnd.api+json'}) return resp @flat_api.route('/v1/download/profiles/') def download_profile(profile_id: int): """Download a profile. The profile is reconstructed from its database representation. Args: profile_id (int): The profile id :reqheader Accept: application/x-apple-aspen-config :resheader Content-Type: application/x-apple-aspen-config :statuscode 200: :statuscode 404: :statuscode 500: """ try: profile = db.session.query(Profile).filter(Profile.id == profile_id).one() except NoResultFound: abort(404) return profile.data, 200, {'Content-Type': 'application/x-apple-aspen-config'} ================================================ FILE: commandment/api/app_jsonapi.py ================================================ """ This module contains all of the API generated using the Flask-REST-JSONAPI extension. """ from flask import Blueprint from flask_rest_jsonapi import Api from .resources import CertificatesList, CertificateDetail, CertificateSigningRequestList, \ CertificateSigningRequestDetail, PushCertificateList, SSLCertificatesList, \ CACertificateList, PrivateKeyDetail, DeviceList, DeviceDetail, \ DeviceRelationship, \ TagsList, TagDetail, TagRelationship # PayloadsList, PayloadDetail, api_app = Blueprint('api_app', __name__) api = Api(blueprint=api_app) # Certificates api.route(CertificatesList, 'certificates_list', '/v1/certificates/') api.route(CertificateDetail, 'certificate_detail', '/v1/certificates/') api.route(CertificateSigningRequestList, 'certificate_signing_request_list', '/v1/certificate_signing_requests') api.route(CertificateSigningRequestDetail, 'certificate_signing_request_detail', '/v1/certificate_signing_requests/') api.route(PushCertificateList, 'push_certificates_list', '/v1/push_certificates/') api.route(SSLCertificatesList, 'ssl_certificates_list', '/v1/ssl_certificates/') api.route(CACertificateList, 'ca_certificates_list', '/v1/ca_certificates/') api.route(PrivateKeyDetail, 'private_key_detail', '/v1/rsa_private_keys/') # Devices api.route(DeviceList, 'devices_list', '/v1/devices', '/v1/device_groups//devices', '/v1/dep/profiles//devices', '/v1/managed_applications//devices') api.route(DeviceDetail, 'device_detail', '/v1/devices/') api.route(DeviceRelationship, 'device_commands', '/v1/devices//relationships/commands') api.route(DeviceRelationship, 'device_tags', '/v1/devices//relationships/tags') api.route(DeviceRelationship, 'device_dep_profile', '/v1/devices//relationships/dep_profile') # Organizations # api.route(OrganizationList, 'organizations_list', '/v1/organizations') # api.route(OrganizationDetail, 'organization_detail', '/v1/organizations/') # Tags api.route(TagsList, 'tags_list', '/v1/tags', '/v1/devices//tags') api.route(TagDetail, 'tag_detail', '/v1/tags/') api.route(TagRelationship, 'tag_devices', '/v1/tags//relationships/devices') ================================================ FILE: commandment/api/configuration.py ================================================ """ This module contains a Blueprint for API endpoints relating to system configuration. """ from flask import Blueprint, abort, jsonify, request from sqlalchemy.orm.exc import NoResultFound from commandment.models import db, Organization, SCEPConfig from .schema import OrganizationFlatSchema, SCEPConfigFlatSchema from commandment.profiles.schema import ProfileSchema configuration_app = Blueprint('configuration_app', __name__) @configuration_app.route('/organization', methods=['GET']) def organization_get(): """Retrieve information about the MDM home organization. :reqheader Accept: application/json :reqheader Content-Type: application/json :resheader Content-Type: application/json :statuscode 200: Success :statuscode 404: No configuration available :statuscode 500: Other error """ try: o = db.session.query(Organization).one() except NoResultFound: return abort(404, 'No organization details found') schema = OrganizationFlatSchema() dump = schema.dumps(o) return dump.data, 200, {'Content-Type': 'application/json'} @configuration_app.route('/organization', methods=['PATCH', 'POST']) def organization_post(): """Update information about the MDM home organization. :reqheader Accept: application/json :reqheader Content-Type: application/json :resheader Content-Type: application/json :statuscode 201: Success :statuscode 400: Validation Error :statuscode 500: Other error """ schema = OrganizationFlatSchema() data = request.data result = schema.loads(data) db.session.commit() dump = schema.dumps(result.data) return dump.data, 200, {'Content-Type': 'application/json'} @configuration_app.route('/scep', methods=['GET']) def scep_get(): """Retrieve information about SCEP enrollment configuration :reqheader Accept: application/json :reqheader Content-Type: application/json :resheader Content-Type: application/json :statuscode 200: Success :statuscode 404: No configuration available :statuscode 500: Other error """ try: c = db.session.query(SCEPConfig).one() except NoResultFound: return abort(404, 'No organization details found') schema = SCEPConfigFlatSchema() dump = schema.dumps(c) return dump.data, 200, {'Content-Type': 'application/json'} @configuration_app.route('/scep', methods=['PATCH', 'POST']) def scep_post(): """Update information about SCEP enrollment configuration :reqheader Accept: application/json :reqheader Content-Type: application/json :resheader Content-Type: application/json :statuscode 201: Success :statuscode 400: Validation Error :statuscode 500: Other error """ schema = SCEPConfigFlatSchema() data = request.data result = schema.loads(data) db.session.commit() dump = schema.dumps(result.data) return dump.data, 200, {'Content-Type': 'application/json'} ================================================ FILE: commandment/api/resources.py ================================================ """ This module defines resources, as required by the Flask-REST-JSONAPI package. This represents most of the REST API. """ from flask_rest_jsonapi.exceptions import ObjectNotFound from sqlalchemy.orm.exc import NoResultFound from .schema import DeviceSchema, CertificateSchema, PrivateKeySchema, \ CertificateSigningRequestSchema, OrganizationSchema, TagSchema from commandment.models import db, Device, Organization, Tag, Command from commandment.pki.models import Certificate, CertificateSigningRequest, SSLCertificate, PushCertificate, \ CACertificate from commandment.mdm import commands as mdmcommands, CommandType from commandment.auth import oauth2 from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship class DeviceList(ResourceList): # decorators = (oauth2.require_oauth(''),) schema = DeviceSchema data_layer = {'session': db.session, 'model': Device} class DeviceDetail(ResourceDetail): schema = DeviceSchema data_layer = { 'session': db.session, 'model': Device, 'url_field': 'device_id' } def before_patch(self, args, kwargs, data=None): """Custom logic when updating a device: - If the `device_name` field would change, we queue a new Settings command to change the name of the device. - If there already was an undelivered Settings command, it should be replaced by the new one. - If the `hostname` field would change, that should also be sent via a Settings command (will be coalesced with Device Name). """ if 'device_name' in data or 'hostname' in data: # settings_commands = self.data_layer['model'].commands cmd: mdmcommands.Settings = mdmcommands.Command.new_request_type("Settings", {}) if 'device_name' in data: cmd.device_name = data['device_name'] del data['device_name'] if 'hostname' in data: cmd.hostname = data['hostname'] del data['hostname'] model = Command.from_model(cmd) self.data_layer['session'].add(model) class DeviceRelationship(ResourceRelationship): schema = DeviceSchema data_layer = { 'session': db.session, 'model': Device, 'url_field': 'device_id' } def before_post(self, args, kwargs, json_data=None): """Custom logic for relationship management: - If the dep_profile relationship is created, we need to check if the DEP Profile exists or has been uploaded yet. """ pass def before_patch(self, args, kwargs, json_data=None): pass def after_patch(self, result): """Device relationship post-processing: - If `dep_profiles` relationship was changed, update the DEP profile on the apple side. """ pass # dep = get_dep() class CertificatesList(ResourceList): schema = CertificateSchema data_layer = {'session': db.session, 'model': Certificate} class CertificateDetail(ResourceDetail): schema = CertificateSchema data_layer = {'session': db.session, 'model': Certificate} class CertificateTypeDetail(ResourceDetail): schema = CertificateSchema data_layer = {'session': db.session, 'model': Certificate} class PrivateKeyDetail(ResourceDetail): schema = PrivateKeySchema data_layer = {'session': db.session, 'model': Certificate} class CertificateSigningRequestList(ResourceList): schema = CertificateSigningRequestSchema data_layer = { 'session': db.session, 'model': CertificateSigningRequest, } class CertificateSigningRequestDetail(ResourceDetail): schema = CertificateSigningRequestSchema data_layer = { 'session': db.session, 'model': CertificateSigningRequest } class PushCertificateList(ResourceList): schema = CertificateSchema data_layer = { 'session': db.session, 'model': PushCertificate } class CACertificateList(ResourceList): schema = CertificateSchema data_layer = { 'session': db.session, 'model': CACertificate } class SSLCertificatesList(ResourceList): schema = CertificateSchema data_layer = { 'session': db.session, 'model': SSLCertificate } class OrganizationList(ResourceList): schema = OrganizationSchema data_layer = { 'session': db.session, 'model': Organization } class OrganizationDetail(ResourceDetail): schema = OrganizationSchema data_layer = { 'session': db.session, 'model': Organization } class TagsList(ResourceList): schema = TagSchema data_layer = { 'session': db.session, 'model': Tag } view_kwargs = True class TagDetail(ResourceDetail): schema = TagSchema data_layer = { 'session': db.session, 'model': Tag, 'url_field': 'tag_id' } class TagRelationship(ResourceRelationship): schema = TagSchema data_layer = { 'session': db.session, 'model': Tag, 'url_field': 'tag_id' } ================================================ FILE: commandment/api/schema.py ================================================ """ This module contains schema definitions for Marshmallow-JSONAPI and therefore Flask-REST-JSONAPI. It also contains non subpackage specific JSON schema definitions. """ from marshmallow_jsonapi import fields from marshmallow_jsonapi.flask import Relationship, Schema from marshmallow import Schema as FlatSchema, post_load from commandment.models import db, Organization, SCEPConfig class DeviceSchema(Schema): class Meta: type_ = 'devices' self_view = 'api_app.device_detail' self_view_kwargs = {'device_id': ''} self_view_many = 'api_app.devices_list' strict = True id = fields.Int(dump_only=True) udid = fields.Str(dump_only=True) topic = fields.Str() build_version = fields.Str() # device_name and hostname are "pseudo" read-only in that writing them does not affect the field but enqueues # an MDM command to change the name. device_name = fields.Str() hostname = fields.Str() local_hostname = fields.Str(dump_only=True) model = fields.Str() model_name = fields.Str() os_version = fields.Str() product_name = fields.Str() serial_number = fields.Str() awaiting_configuration = fields.Bool() last_seen = fields.DateTime(dump_only=True) available_device_capacity = fields.Float() device_capacity = fields.Float() wifi_mac = fields.Str() bluetooth_mac = fields.Str() # private # push_magic = fields.Str() # token = fields.Str() # unlock_token = fields.Str() tokenupdate_at = fields.DateTime() # SecurityInfo passcode_present = fields.Bool() passcode_compliant = fields.Bool() passcode_compliant_with_profiles = fields.Bool() fde_enabled = fields.Bool() fde_has_prk = fields.Bool() fde_has_irk = fields.Bool() firewall_enabled = fields.Bool() block_all_incoming = fields.Bool() stealth_mode_enabled = fields.Bool() sip_enabled = fields.Bool() battery_level = fields.Float(dump_only=True) imei = fields.Str(dump_only=True) is_cloud_backup_enabled = fields.Bool(dump_only=True) itunes_store_account_is_active = fields.Bool(dump_only=True) last_cloud_backup_date = fields.DateTime(dump_only=True) # TODO: Relationship to dep_config # certificate = Relationship( # self_view='api_app.device_certificate', # self_view_kwargs={'certificate_id': ''}, # related_view='api_app.certificate_detail', # related_view_kwargs={'certificate_id': ''}, # ) # DEP is_dep = fields.Bool() description = fields.Str(dump_only=True) color = fields.Str(dump_only=True) asset_tag = fields.Str(dump_only=True) profile_status = fields.Str(dump_only=True) profile_uuid = fields.UUID(dump_only=True) profile_assign_time = fields.DateTime(dump_only=True) profile_push_time = fields.DateTime(dump_only=True) device_assigned_date = fields.DateTime(dump_only=True) device_assigned_by = fields.Str(dump_only=True) os = fields.Str(dump_only=True) device_family = fields.Str(dump_only=True) commands = Relationship( related_view='api_app.commands_list', related_view_kwargs={'device_id': ''}, many=True, schema='CommandSchema', type_='commands' ) installed_certificates = Relationship( related_view='api_app.installed_certificates_list', related_view_kwargs={'device_id': ''}, many=True, schema='InstalledCertificateSchema', type_='installed_certificates' ) installed_applications = Relationship( related_view='api_app.installed_applications_list', related_view_kwargs={'device_id': ''}, many=True, schema='InstalledApplicationSchema', type_='installed_applications' ) tags = Relationship( related_view='api_app.tags_list', related_view_kwargs={'device_id': ''}, many=True, schema='TagSchema', type_='tags' ) available_os_updates = Relationship( related_view='api_app.available_os_updates_list', related_view_kwargs={'device_id': ''}, many=True, schema='AvailableOSUpdateSchema', type_='available_os_updates' ) dep_profile = Relationship( related_view='dep_app.dep_profile_detail', related_view_kwargs={'dep_profile_id': ''}, many=False, schema='DEPProfileSchema', type_='dep_profiles', ) class PrivateKeySchema(Schema): class Meta: type_ = 'private_keys' self_view = 'api_app.private_key_detail' self_view_kwargs = {'private_key_id': ''} strict = True id = fields.Int(dump_only=True) pem_key = fields.Str() class CertificateSchema(Schema): class Meta: type_ = 'certificates' self_view = 'api_app.certificate_detail' self_view_kwargs = {'certificate_id': ''} self_view_many = 'api_app.certificates_list' strict = True id = fields.Int(dump_only=True) type = fields.Str(attribute='type') x509_cn = fields.Str(dump_only=True) not_before = fields.DateTime(dump_only=True) not_after = fields.DateTime(dump_only=True) # fingerprint = fields.Str(dump_only=True) pem_certificate = fields.Str() private_key = Relationship( self_view='api_app.certificate_private_keys', self_view_kwargs={'id': ''}, related_view='api_app.private_key_detail', related_view_kwargs={'private_key_id': ''}, many=False, schema='PrivateKeySchema', type_='private_keys' ) class CertificateSigningRequestSchema(Schema): class Meta: type_ = 'certificate_signing_requests' self_view = 'api_app.certificate_signing_request_detail' self_view_kwargs = {'certificate_signing_request_id': ''} self_view_many = 'api_app.certificate_signing_request_list' id = fields.Int(dump_only=True) purpose = fields.Str(load_only=True, attribute='req_type') subject = fields.Str() pem_request = fields.Str() class OrganizationSchema(Schema): class Meta: type_ = 'organizations' self_view = 'api_app.organization_detail' self_view_kwargs = {'organization_id': ''} id = fields.Int(dump_only=True) name = fields.Str() payload_prefix = fields.Str() x509_ou = fields.Str() x509_o = fields.Str() x509_st = fields.Str() x509_c = fields.Str() class OrganizationFlatSchema(FlatSchema): name = fields.Str(required=True) payload_prefix = fields.Str(required=True) x509_ou = fields.Str() x509_o = fields.Str() x509_st = fields.Str() x509_c = fields.Str() @post_load def make_organization(self, data: dict) -> Organization: """Construct a model from a parsed JSON schema.""" rows = db.session.query(Organization).count() if rows == 1: db.session.query(Organization).update(data) o = db.session.query(Organization).first() else: o = Organization(**data) db.session.add(o) return o class SCEPConfigFlatSchema(FlatSchema): source_type = fields.String() url = fields.Url(relative=False, schemes=['http', 'https'], required=True) challenge_enabled = fields.Boolean() ca_fingerprint = fields.String() subject = fields.String() key_size = fields.Integer() key_type = fields.String(dump_only=True) key_usage = fields.Integer() subject_alt_name = fields.String() retries = fields.Integer() retry_delay = fields.Integer() certificate_renewal_time_interval = fields.Integer() @post_load def make_scepconfig(self, data: dict) -> SCEPConfig: """Construct a model from a parsed JSON schema.""" rows = db.session.query(SCEPConfig).count() if rows == 1: db.session.query(SCEPConfig).update(data) o = db.session.query(SCEPConfig).first() else: o = SCEPConfig(**data) db.session.add(o) return o class TagSchema(Schema): class Meta: type_ = 'tags' self_view = 'api_app.tag_detail' self_view_kwargs = {'tag_id': ''} self_view_many = 'api_app.tags_list' id = fields.Int(dump_only=True) name = fields.Str() color = fields.Str() devices = Relationship( self_view='api_app.tag_devices', self_view_kwargs={'tag_id': ''}, related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, schema='DeviceSchema', many=True, type_='devices' ) # profiles = Relationship( # related_view='api_app.profiles_list', # related_view_kwargs={'profile_id': ''}, # schema='ProfileSchema', # many=True, # type_='profiles' # ) ================================================ FILE: commandment/apns/__init__.py ================================================ ================================================ FILE: commandment/apns/app.py ================================================ from datetime import datetime from flask import Blueprint, request, abort, current_app, jsonify from sqlalchemy.orm.exc import NoResultFound from commandment.errors import JSONAPIError from commandment.models import db, Device from commandment.pki.models import RSAPrivateKey, CertificateSigningRequest, CACertificate, \ EncryptionCertificate from commandment.pki import ssl as cmdssl from .push import push_to_device from .schema import PushResponseFlatSchema from .mdmcert import submit_mdmcert_request, decrypt_mdmcert import ssl api_push_app = Blueprint('api_push_app', __name__) MDMCERT_REQ_URL = 'https://mdmcert.download/api/v1/signrequest' # PLEASE! Do not take this key and use it for another product/project. It's # only for Commandment's use. If you'd like to get your own (free!) key # contact the mdmcert.download administrators and get your own key for your # own project/product. We're trying to keep statistics on which products are # requesting certs (per Apple T&C). Don't force Apple's hand and # ruin it for everyone! MDMCERT_API_KEY = 'b742461ff981756ca3f924f02db5a12e1f6639a9109db047ead1814aafc058dd' @api_push_app.route('/v1/devices//push', methods=['POST', 'GET']) def push(device_id: int): """Send a (Blank) push notification to the specified device by its Commandment ID. This causes the device to check back with the MDM for pending commands. :statuscode 400: impossible to push to device (no token or invalid token) :statuscode 404: device does not exist :statuscode 200: push complete """ device = db.session.query(Device).filter(Device.id == device_id).one() if device.token is None or device.push_magic is None: abort(jsonify(error=True, message='Cannot request push on a device that has no device token or push magic')) try: response = push_to_device(device) except ssl.SSLError: return abort(400, jsonify(error=True, message="The push certificate has expired")) current_app.logger.info("[APNS2 Response] Status: %d, Reason: %s, APNS ID: %s, Timestamp", response.status_code, response.reason, response.apns_id.decode('utf-8')) device.last_push_at = datetime.utcnow() if response.status_code == 200: device.last_apns_id = response.apns_id db.session.commit() push_res_schema = PushResponseFlatSchema() result = push_res_schema.dumps(response) return result @api_push_app.route('/v1/mdmcert/request/', methods=['GET']) def mdmcert_request(email: str): """Ask the mdmcert.download service to generate a new Certificate Signing Request for the given e-mail address. If an encryption certificate does not exist on the system, one will be generated to process the resulting encrypted and signed CSR. The common name of the certificate will be the e-mail address that is registered with the mdmcert.download service, and the type will be an EncryptionCertificate. :reqheader Accept: application/json :resheader Content-Type: application/json """ try: apns_csr_model = db.session.query(CertificateSigningRequest).\ filter(CertificateSigningRequest.x509_cn == "commandment-apns").one() except NoResultFound: private_key, csr = cmdssl.generate_signing_request('commandment-apns') private_key_model = RSAPrivateKey.from_crypto(private_key) db.session.add(private_key_model) apns_csr_model = CertificateSigningRequest.from_crypto(csr) apns_csr_model.rsa_private_key = private_key_model db.session.add(apns_csr_model) db.session.commit() try: encrypt_cert_model = db.session.query(EncryptionCertificate).\ filter(EncryptionCertificate.x509_cn == email).one() except NoResultFound: encrypt_key, encrypt_with_cert = cmdssl.generate_self_signed_certificate(email) encrypt_key_model = RSAPrivateKey.from_crypto(encrypt_key) db.session.add(encrypt_key_model) encrypt_cert_model = EncryptionCertificate.from_crypto(encrypt_with_cert) encrypt_cert_model.rsa_private_key = encrypt_key_model db.session.add(encrypt_cert_model) db.session.commit() current_app.logger.info("Submitting request to mdmcert.download for %s", email) mdmcert_result = submit_mdmcert_request( email=email, csr_pem=apns_csr_model.pem_data, encrypt_with_pem=encrypt_cert_model.pem_data, ) return jsonify(mdmcert_result) @api_push_app.route('/v1/mdmcert/decrypt', methods=['POST']) def mdmcert_decrypt(): """Upload the encrypted, signed request from mdmcert.download that was received via e-mail. The filename looks something like :file:`mdm_signed_request.YYMMDD_HHMMSS_NNN.plist.b64.p7` It is a hex-encoded PKCS#7 message. :reqheader Accept: application/json :reqheader Content-Type: multipart/form-data :statuscode 200: successfully decrypted request :statuscode 415: invalid or no certificate supplied :statuscode 501: impossible to serve the request because we don't have the matching key """ if 'file' not in request.files: return abort(415, 'no file uploaded in request data') encrypted_payload = request.files['file'].stream.read() try: # TODO: Identify the specific certificate used to generate the request encrypt_cert: EncryptionCertificate = db.session.query(EncryptionCertificate).first() except NoResultFound: return abort(500, 'unable to decrypt, there was no decryption cert') pk = encrypt_cert.rsa_private_key.to_crypto() try: result = decrypt_mdmcert(encrypted_payload, pk) except ValueError as e: raise JSONAPIError( title="Unable to decrypt signed request", status=415, detail="Could not find a suitable private key to decrypt the given request", ) return result, 200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename=mdm_signed_request.%s.plist.b64' % datetime.now().strftime('%Y%m%d_%H%M%S'), } ================================================ FILE: commandment/apns/mdmcert.py ================================================ """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ from typing import Dict from flask import Response import json from base64 import b64encode import requests from binascii import unhexlify from cryptography import x509 from cryptography.hazmat.primitives import serialization, padding from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKeyWithSerialization from commandment.dep.smime import decrypt, decrypt_smime_content MDMCERT_REQ_URL = 'https://mdmcert.download/api/v1/signrequest' # PLEASE! Do not take this key and use it for another product/project. It's # only for Commandment's use. If you'd like to get your own (free!) key # contact the mdmcert.download administrators and get your own key for your # own project/product. We're trying to keep statistics on which products are # requesting certs (per Apple T&C). Don't force Apple's hand and # ruin it for everyone! MDMCERT_API_KEY = 'b742461ff981756ca3f924f02db5a12e1f6639a9109db047ead1814aafc058dd' CERT_REQ_TYPE = 'mdmcert.pushcert' def submit_mdmcert_request(email: str, csr_pem: str, encrypt_with_pem: str, api_key: str = MDMCERT_API_KEY) -> Dict: """Submit a CSR signing request to mdmcert.download. Note: Need to ``export OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES`` on High Sierra + Example Response: {'reason': 'Invalid email address. Have you registered yet at https://mdmcert.download/?', 'result': 'failure'} on success: {'result': 'success'} Args: email (str): Your registered mdmcert.download e-mail address. api_key (str): Your registered mdmcert.download API key. csr_pem (str): The MDM CSR to sign. encrypt_with_pem (str): The certificate which will be used to encrypt the response. Returns: dict: Response from the mdmcert.download service. """ base64_csr = b64encode(csr_pem) base64_recipient = b64encode(encrypt_with_pem) mdmcert_dict = { 'csr': base64_csr.decode('utf8'), 'email': email, 'key': api_key, 'encrypt': base64_recipient.decode('utf8'), } session = requests.Session() # This was necessary because i had Charles proxy on macOS which caused the subprocess to abort trap 6. The reason # is interlinked with request's ability to read system proxy settings. session.trust_env = False # Don't read proxy settings from OS. res = session.post( MDMCERT_REQ_URL, data=json.dumps(mdmcert_dict).encode('utf8'), headers={ 'Content-Type': 'application/json', 'User-Agent': 'coMmanDMent/0.1', }) return res.json() class FixedLocationResponse(Response): # override Werkzeug default behaviour of "fixing up" once-non-compliant # relative location headers. now permitted in rfc7231 sect. 7.1.2 autocorrect_location_header = False def decrypt_mdmcert(response: bytes, decrypt_with: RSAPrivateKeyWithSerialization) -> bytes: """Decrypt a .plist.b64.p7 supplied by mdmcert.download. In order to decrypt this we need to: - decode the payload using unhexlify() - find the private key that corresponded to the request. Args: response (bytes): The still encryped and hex encoded payload decrypt_with (RSAPrivateKeyWithSerialization): The private key that should be used to decrypt the payload. Returns: bytes - the decrypted response """ decoded_payload = unhexlify(response) # try: result = decrypt_smime_content(decoded_payload, decrypt_with) # except ValueError as e: # return abort(400, e) # result = decrypt_with.decrypt( # decoded_payload, # padding.PKCS7(block_size=8) # ) return result ================================================ FILE: commandment/apns/push.py ================================================ """ Copyright (c) 2015 Jesse Peterson Licensed under the MIT license. See the included LICENSE.txt file for details. Attributes: apns_cxns (dict): A dictionary containing APNS connections keyed by the push certificate topic. """ import os import apns2 from cryptography import x509 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.backends import default_backend from oscrypto.keys import parse_pkcs12 from flask import g, current_app from commandment.models import Device import json def get_apns() -> apns2.APNSClient: apns = getattr(g, '_apns', None) if apns is None: push_certificate_path = current_app.config['PUSH_CERTIFICATE'] if not os.path.exists(push_certificate_path): raise RuntimeError('You specified a push certificate at: {}, but it does not exist.'.format(push_certificate_path)) client_cert = push_certificate_path # can be a single path or tuple of 2 # We can handle loading PKCS#12 but APNS2Client specifically requests PEM encoded certificates push_certificate_basename, ext = os.path.splitext(push_certificate_path) if ext.lower() == '.p12': pem_key_path = push_certificate_basename + '.key' pem_certificate_path = push_certificate_basename + '.crt' if not os.path.exists(pem_key_path) or not os.path.exists(pem_certificate_path): current_app.logger.info('You provided a PKCS#12 push certificate, we will have to encode it as PEM to continue...') current_app.logger.info('.key and .crt files will be saved in the same location') with open(push_certificate_path, 'rb') as fd: if 'PUSH_CERTIFICATE_PASSWORD' in current_app.config: key, certificate, intermediates = parse_pkcs12(fd.read(), bytes(current_app.config['PUSH_CERTIFICATE_PASSWORD'], 'utf8')) else: key, certificate, intermediates = parse_pkcs12(fd.read()) crypto_key = serialization.load_der_private_key(key.dump(), None, default_backend()) with open(pem_key_path, 'wb') as fd: fd.write(crypto_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())) crypto_cert = x509.load_der_x509_certificate(certificate.dump(), default_backend()) with open(pem_certificate_path, 'wb') as fd: fd.write(crypto_cert.public_bytes(serialization.Encoding.PEM)) client_cert = pem_certificate_path, pem_key_path try: apns = g._apns = apns2.APNSClient(mode='prod', client_cert=client_cert) except: raise RuntimeError('Your push certificate is expired or invalid') return apns class MDMPayload(apns2.Payload): """A class representing an MDM APNs message payload.""" def __init__(self, push_magic: str) -> None: """Constructor Args: push_magic (str): The push magic token that was supplied by an enrolled device. """ super(MDMPayload, self).__init__(custom={'mdm': push_magic}) self._push_magic = push_magic def to_json(self) -> str: return json.dumps({'mdm': self._push_magic}) def push_to_device(device: Device) -> apns2.Response: """Issue a `Blank Push` to a device. If the push token is invalid then it will be automatically set to None Args: device (Device): The device model to push to, must have a valid apns token and push magic Raises: ssl.SSLError [SSL: SSLV3_ALERT_CERTIFICATE_EXPIRED] sslv3 alert certificate expired (_ssl.c:777) if the push certificate has expired and the system attempts a push. Returns: APNS2Client Response object """ current_app.logger.debug('Sending a push notification to {} on topic {}, using push magic: {}'.format( device.hex_token, device.topic, device.push_magic )) client = get_apns() payload = MDMPayload(device.push_magic) notification = apns2.Notification(payload, priority=apns2.PRIORITY_LOW) response: apns2.response.Response = client.push(notification, device.hex_token, device.topic) # 410 means that the token is no longer valid for this device, so don't attempt to push any more if response.status_code == 410: device.token = None device.push_magic = None return response ================================================ FILE: commandment/apns/schema.py ================================================ from marshmallow import Schema, fields class PushResponseFlatSchema(Schema): """This structure mimics the fields of an APNS2 service reply.""" apns_id = fields.Integer() status_code = fields.Integer() reason = fields.Str() timestamp = fields.DateTime() ================================================ FILE: commandment/apns/threads.py ================================================ from typing import Tuple import logging import threading from datetime import datetime import dateutil.parser from flask import Flask import ssl from commandment.mdm import CommandStatus from commandment.models import db, Device, Command from commandment.apns.push import push_to_device import sqlalchemy.orm.exc from sqlalchemy import func push_thread = None push_start = 2 push_time = 90 push_thread_stopped = threading.Event() logger = logging.getLogger('push thread') def start(app: Flask): """Start the APNS Pusher thread""" logger.info('PUSH thread will start in %d second(s). polling at intervals of %d second(s).', push_start, push_time) push_thread = threading.Timer(push_start, push_thread_callback, [app]) push_thread.daemon = True push_thread.start() def stop(): """Stop the APNS Pusher thread""" logger.info('PUSH thread will stop') push_thread_stopped.set() global push_thread if push_thread is threading.Timer: push_thread.cancel() def push_thread_callback(app: Flask): """Process outstanding MDM commands by issuing a push to device(s). TODO: A push with no response needs an exponential backoff time. Commands that are ready to send must satisfy these criteria: - Command is in Queued state. - Command.after is null. - Command.ttl is not zero. - Device is enrolled (is_enrolled) """ while not push_thread_stopped.wait(push_time): app.logger.info('Push Thread checking for outstanding commands...') with app.app_context(): pending: Tuple[Device, int] = db.session.query(Device, func.Count(Command.id)).\ filter(Device.id == Command.device_id).\ filter(Command.status == CommandStatus.Queued).\ filter(Command.ttl > 0).\ filter(Command.after == None).\ filter(Device.is_enrolled == True).\ group_by(Device.id).\ all() for d, c in pending: app.logger.info('PENDING: %d command(s) for device UDID %s', c, d.udid) if d.token is None or d.push_magic is None: app.logger.warn('Cannot request push on a device that has no device token or push magic') continue try: response = push_to_device(d) except ssl.SSLError: return stop() app.logger.info("[APNS2 Response] Status: %d, Reason: %s, APNS ID: %s, Timestamp", response.status_code, response.reason, response.apns_id.decode('utf-8')) d.last_push_at = datetime.utcnow() if response.status_code == 200: d.last_apns_id = response.apns_id db.session.commit() ================================================ FILE: commandment/app.py ================================================ from commandment import create_app app = create_app(None) ================================================ FILE: commandment/apps/__init__.py ================================================ from enum import Enum class ManagedAppStatus(Enum): """A list of possible Managed Application statuses returned by the `ManagedApplicationList` command.""" NeedsRedemption = 'NeedsRedemption' Redeeming = 'Redeeming' Prompting = 'Prompting' PromptingForLogin = 'PromptingForLogin' Installing = 'Installing' ValidatingPurchase = 'ValidatingPurchase' Managed = 'Managed' ManagedButUninstalled = 'ManagedButUninstalled' PromptingForUpdate = 'PromptingForUpdate' PromptingForUpdateLogin = 'PromptingForUpdateLogin' PromptingForManagement = 'PromptingForManagement' Updating = 'Updating' ValidatingUpdate = 'ValidatingUpdate' Unknown = 'Unknown' # Transient UserInstalledApp = 'UserInstalledApp' UserRejected = 'UserRejected' UpdateRejected = 'UpdateRejected' ManagementRejected = 'ManagementRejected' Failed = 'Failed' # Commandment ONLY - To indicate that the command for IA is queued but not yet acked Queued = 'Queued' ================================================ FILE: commandment/apps/app_jsonapi.py ================================================ from flask import Blueprint, request from flask_rest_jsonapi import Api from commandment.apps.resources import ApplicationDetail, ApplicationList, ApplicationRelationship, \ ManagedApplicationList, ManagedApplicationDetail, ManagedApplicationRelationship, MASApplicationList, \ MASApplicationDetail, IOSApplicationList, IOSApplicationDetail api_app = Blueprint('applications_api', __name__) api = Api(blueprint=api_app) api.route(ApplicationList, 'applications_list', '/v1/applications') api.route(ApplicationDetail, 'application_detail', '/v1/applications/') api.route(ApplicationRelationship, 'application_tags', '/v1/applications//relationships/tags') api.route(ManagedApplicationList, 'managed_applications_list', '/v1/managed_applications', '/v1/applications//managed_applications') api.route(ManagedApplicationDetail, 'managed_application_detail', '/v1/managed_applications/') api.route(ManagedApplicationRelationship, 'managed_application_device', '/v1/managed_applications//relationships/device') # Platform specific subclasses api.route(MASApplicationList, 'mas_applications_list', '/v1/applications/store/mac') api.route(MASApplicationDetail, 'mas_application_detail', '/v1/applications/store/mac/') api.route(IOSApplicationList, 'ios_applications_list', '/v1/applications/store/ios') api.route(IOSApplicationDetail, 'ios_application_detail', '/v1/applications/store/ios/') ================================================ FILE: commandment/apps/models.py ================================================ from enum import Enum, IntEnum, IntFlag from commandment.apps import ManagedAppStatus from ..models import db class ManagementFlag(IntFlag): """This enum of integer bitwise OR flags represents all the fields available as part of the ``ManagementFlag`` option to the ``InstallApplication`` command.""" NOTHING = 0 REMOVE_APP_WITH_ENROLLMENT = 1 PREVENT_APPDATA_BACKUP = 4 class PurchaseMethod(IntEnum): """Purchase methods, the flag should almost always be VPP_APP_ASSIGNMENT""" LEGACY_VPP = 0 VPP_APP_ASSIGNMENT = 1 class ApplicationType(Enum): """A list of the polymorphic identities available for subclasses of Application.""" ENTERPRISE_MAC = 'enterprise_mac' ENTERPRISE_IOS = 'enterprise_ios' APPSTORE_MAC = 'appstore_mac' APPSTORE_IOS = 'appstore_ios' application_tags = db.Table( 'application_tags', db.metadata, db.Column('application_id', db.Integer, db.ForeignKey('applications.id')), db.Column('tag_id', db.Integer, db.ForeignKey('tags.id')), ) class Application(db.Model): """This table holds details of each individual application that may be managed (either app store or enterprise application). :table: applications """ __tablename__ = 'applications' id = db.Column(db.Integer, primary_key=True) """id (db.Integer): ID""" display_name = db.Column(db.String, nullable=False) """display_name (db.String): The name of the application displayed in the MDM.""" description = db.Column(db.String) """description (db.String): Description of this application, possibly including release notes.""" version = db.Column(db.String) """version (db.String): Application version.""" itunes_store_id = db.Column(db.Integer) """itunes_store_id (db.Integer): The application’s iTunes Store ID.""" bundle_id = db.Column(db.String, index=True, nullable=False) """bundle_id (db.String): The application bundle identifier.""" purchase_method = db.Column(db.Enum(PurchaseMethod)) """purchase_method (db.Integer): Used in the Options key of InstallApplication to denote the purchase method.""" manifest_url = db.Column(db.String) """manifest_url (db.String): The application manifest URL if iTunesStoreID is not supplied (an enterprise app).""" management_flags = db.Column(db.Integer) """management_flags (ManagementFlag): Denotes whether app is removed with MDM profile, and whether the user may back up application data.""" change_management_state = db.Column(db.String, default="Managed") """change_management_state (db.String): Take ownership of an existing application that is unmanaged.""" discriminator = db.Column(db.String(20)) """discriminator (str): The type of application""" # iTunes Search API - Cached Result country = db.Column(db.String(2)) """country (str): The two letter country code of the store country. We cache this to avoid assigning apps to devices that cannot even install them due to the Apple ID residing in a different locale.""" artist_id = db.Column(db.Integer) """artist_id (int): The iTunes Artist ID, which is commonly the developer in the app store.""" artist_name = db.Column(db.String) """artist_id (str): The iTunes Artist Name, which is commonly the developer in the app store.""" artist_view_url = db.Column(db.String) artwork_url60 = db.Column(db.String) """artwork_url60 (str): A URL to the 60x60 icon for this result.""" artwork_url100 = db.Column(db.String) """artwork_url100 (str): A URL to the 100x100 icon for this result.""" artwork_url512 = db.Column(db.String) """artwork_url512 (str): A URL to the 512x512 icon for this result.""" release_notes = db.Column(db.String) release_date = db.Column(db.DateTime) minimum_os_version = db.Column(db.String) file_size_bytes = db.Column(db.BigInteger) tags = db.relationship( 'Tag', secondary=application_tags, backref='applications' ) __mapper_args__ = { 'polymorphic_on': discriminator, 'polymorphic_identity': 'applications', } class EnterpriseMacApplication(Application): """Polymorphic single table inheritance specifically for Enterprise Mac Applications. These applications are .pkg files which are often distributed by the MDM or from a host outside of the App Store. """ __mapper_args__ = { 'polymorphic_identity': ApplicationType.ENTERPRISE_MAC.value } class EnterpriseiOSApplication(Application): """Polymorphic single table inheritance specifically for Enterprise iOS Applications. These applications are .ipa files which are often distributed by the MDM or from a host outside of the App Store. With or without provisioning profiles. """ __mapper_args__ = { 'polymorphic_identity': ApplicationType.ENTERPRISE_IOS.value } class AppstoreMacApplication(Application): """Polymorphic single table inheritance specifically for MAS (App Store) Mac Applications. These applications are distributed by VPP using an iTunes Store ID """ __mapper_args__ = { 'polymorphic_identity': ApplicationType.APPSTORE_MAC.value } class AppstoreiOSApplication(Application): """Polymorphic single table inheritance specifically for App Store iOS Applications. These applications are distributed by VPP using an iTunes Store ID """ __mapper_args__ = { 'polymorphic_identity': ApplicationType.APPSTORE_IOS.value } class ApplicationManifest(db.Model): """An application manifest describes a non-App store installable application. See: `macOS Application `_. :table: application_manifests """ __tablename__ = 'application_manifests' id = db.Column(db.Integer, primary_key=True) """id (db.Integer): ID""" bundle_id = db.Column(db.String, index=True, nullable=False) """bundle_id (db.String): Bundle Identifier of the top-level distribution package.""" bundle_version = db.Column(db.String, index=True) """bundle_version (db.String): Bundle Version of the top-level distribution package.""" kind = db.Column(db.String, default='software') """kind (db.String): Type of item to install, at the moment ignored and always set to 'software'.""" size_in_bytes = db.Column(db.BigInteger) """size_in_bytes (db.BigInteger): Size of the package (in bytes).""" subtitle = db.Column(db.String) """subtitle (db.String):""" title = db.Column(db.String) """title (db.String):""" full_size_image_url = db.Column(db.String) """full_size_image_url (db.String): URL to full size image. may be null""" full_size_image_needs_shine = db.Column(db.Boolean, default=False) """full_size_image_needs_shine (db.Boolean): Whether the image needs the shine effect placed over it.""" display_image_url = db.Column(db.String) """display_image_url (db.String): URL to display image. may be null""" display_image_needs_shine = db.Column(db.Boolean, default=False) """display_image_needs_shine (db.Boolean): Whether the display image needs the shine effect placed over it.""" checksums = db.relationship('ApplicationManifestChecksum', back_populates='application_manifest') class ApplicationManifestChecksum(db.Model): __tablename__ = 'application_manifest_checksums' id = db.Column(db.Integer, primary_key=True) """id (db.Integer): ID""" application_manifest_id = db.Column(db.Integer, db.ForeignKey('application_manifests.id')) """application_manifest_id (db.Integer): Foreign key reference to the parent manifest.""" application_manifest = db.relationship(ApplicationManifest, back_populates='checksums') """application_manifest (db.relationship): Relationship to the parent manifest.""" checksum_index = db.Column(db.Integer, nullable=False) """checksum_index (db.Integer): Index of this checksum in the sequence of checksums.""" checksum_value = db.Column(db.String(32), nullable=False) """checksum_value (db.String): 32 byte MD5 checksum of this chunk. Chunk size is defined as 10485760 bytes (10mb)""" class AppSourceType(Enum): S3 = 'S3' Munki = 'Munki' class ApplicationSource(db.Model): """This table holds rows indicating sources that may referenced in ``InstallApplication`` commands. The MDM may require write access to create application manifests from existing items. :table: application_sources """ __tablename__ = 'application_sources' id = db.Column(db.Integer, primary_key=True) """id (db.Integer): ID""" name = db.Column(db.String) """name (db.String): A short, descriptive name for the source. Only used in display.""" source_type = db.Column(db.Enum(AppSourceType), default=AppSourceType.Munki) """source_type (AppSourceType): The application source type.""" endpoint = db.Column(db.String) """endpoint (db.String): The hostname for object storage or URI for read-only munki repositories.""" mount_uri = db.Column(db.String) """mount_uri (db.String): The R/W mount URI for munki repositories only.""" use_ssl = db.Column(db.Boolean) """use_ssl (Boolean): Use SSL when connecting to endpoint. Used when endpoint is host only.""" # For S3 / Minio access_key = db.Column(db.String) """access_key (db.String): The access key for S3 / Minio that uniquely identifies this client.""" secret_key = db.Column(db.String) """secret_key (db.String): The secret key for S3 / Minio that authenticates this client.""" bucket = db.Column(db.String) """bucket (db.String): The bucket name that holds installation packages.""" class ManagedApplication(db.Model): """This table holds rows for application installation statuses that are reported by the `ManagedApplicationList` command.""" __tablename__ = 'managed_applications' id = db.Column(db.Integer, primary_key=True) """id (db.Integer): ID""" device_id = db.Column(db.ForeignKey('devices.id')) """(int): Device foreign key ID.""" device = db.relationship('Device', backref='managed_applications') """(db.relationship): Device relationship""" bundle_id = db.Column(db.String) """(db.String): The bundle identifier of the application being installed.""" external_version_id = db.Column(db.Integer) """(db.Integer): The external version identifier (which is also shown in the vpp contentMetadataUrl lookup)""" has_configuration = db.Column(db.Boolean) """(db.Boolean): Whether the app has managed app configuration or not.""" has_feedback = db.Column(db.Boolean) """(db.Boolean): Whether the app has managed app feedback or not.""" is_validated = db.Column(db.Boolean) """(db.Boolean): Whether the app has been validated.""" management_flags = db.Column(db.Integer) """(db.Integer): Which management flags the application has been installed with.""" status = db.Column(db.Enum(ManagedAppStatus)) """(ManagedAppStatus): The status of the managed application.""" application_id = db.Column(db.ForeignKey('applications.id'), nullable=True) """(db.ForeignKey): Foreign key reference to the application row which was assigned to the device""" application = db.relationship('Application', backref='managed_applications') """(db.relationship): relationship to the defined application object.""" ia_command_id = db.Column(db.ForeignKey('commands.id'), nullable=True) """(db.ForeignKey): Foreign key reference to the last `InstallApplication` command that installed this app on the device.""" ia_command = db.relationship('Command', backref='managed_application') """(db.relationship): Relationship to the last command that was sent in regards to this application entry""" ================================================ FILE: commandment/apps/resources.py ================================================ from sqlalchemy.orm.exc import NoResultFound from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship from flask_rest_jsonapi.exceptions import ObjectNotFound from commandment.apps.schema import ApplicationManifestSchema, ApplicationSchema, ManagedApplicationSchema from commandment.apps.models import db, ApplicationManifest, Application, ManagedApplication, AppstoreMacApplication, \ AppstoreiOSApplication, EnterpriseMacApplication, EnterpriseiOSApplication class ApplicationManifestDetail(ResourceDetail): schema = ApplicationManifestSchema data_layer = { 'session': db.session, 'model': ApplicationManifest, 'url_field': 'application_manifest_id' } class ApplicationDetail(ResourceDetail): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': Application, 'url_field': 'application_id' } class ApplicationList(ResourceList): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': Application, 'url_field': 'application_id' } class ApplicationRelationship(ResourceRelationship): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': Application, 'url_field': 'application_id' } class MASApplicationDetail(ResourceDetail): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': AppstoreMacApplication, 'url_field': 'application_id' } class MASApplicationList(ResourceList): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': AppstoreMacApplication, 'url_field': 'application_id' } class IOSApplicationDetail(ResourceDetail): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': AppstoreiOSApplication, 'url_field': 'application_id' } class IOSApplicationList(ResourceList): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': AppstoreiOSApplication, 'url_field': 'application_id' } class EnterpriseMacApplicationList(ResourceList): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': EnterpriseMacApplication, 'url_field': 'application_id' } class EnterpriseMacApplicationDetail(ResourceDetail): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': EnterpriseMacApplication, 'url_field': 'application_id' } class EnterpriseIosApplicationList(ResourceList): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': EnterpriseiOSApplication, 'url_field': 'application_id' } class EnterpriseIosApplicationDetail(ResourceDetail): schema = ApplicationSchema data_layer = { 'session': db.session, 'model': EnterpriseiOSApplication, 'url_field': 'application_id' } class ManagedApplicationDetail(ResourceDetail): schema = ManagedApplicationSchema data_layer = { 'session': db.session, 'model': ManagedApplication, 'url_field': 'managed_application_id', } class ManagedApplicationList(ResourceList): def query(self, view_kwargs): query_ = self.session.query(ManagedApplication) if view_kwargs.get('application_id') is not None: try: self.session.query(Application).filter_by(id=view_kwargs['application_id']).one() except NoResultFound: raise ObjectNotFound({'parameter': 'application_id'}, "Application: {} not found".format(view_kwargs['application_id'])) else: query_ = query_.join(Application).filter(Application.id == view_kwargs['application_id']) return query_ schema = ManagedApplicationSchema data_layer = { 'session': db.session, 'model': ManagedApplication, 'url_field': 'managed_application_id', 'methods': {'query': query}, } class ManagedApplicationRelationship(ResourceRelationship): schema = ManagedApplicationSchema data_layer = { 'session': db.session, 'model': ManagedApplication, 'url_field': 'managed_application_id', } ================================================ FILE: commandment/apps/schema.py ================================================ from marshmallow_jsonapi import fields from marshmallow_jsonapi.flask import Relationship, Schema class ApplicationSchema(Schema): class Meta: type_ = 'applications' self_view = 'applications_api.application_detail' self_view_kwargs = {'application_id': ''} self_view_many = 'applications_api.applications_list' strict = True id = fields.Int(dump_only=True) display_name = fields.Str() description = fields.Str() version = fields.Str() itunes_store_id = fields.Int() bundle_id = fields.Str() purchase_method = fields.Int() manifest_url = fields.Url() management_flags = fields.Int() change_management_state = fields.Str() # iTunes Search API cache country = fields.Str() artist_id = fields.Int() artist_name = fields.Str() artist_view_url = fields.Url() artwork_url60 = fields.Url() artwork_url100 = fields.Url() artwork_url512 = fields.Url() release_notes = fields.Str() release_date = fields.DateTime() minimum_os_version = fields.Str() file_size_bytes = fields.Number() # expose the underlying polymorphic identity for lists that contain all types of apps discriminator = fields.Str() tags = Relationship( related_view='api_app.tags_list', related_view_kwargs={'application_id': ''}, many=True, schema='TagSchema', type_='tags' ) class ManagedApplicationSchema(Schema): class Meta: type_ = 'managed_applications' self_view = 'applications_api.managed_application_detail' self_view_kwargs = {'managed_application_id': ''} self_view_many = 'applications_api.managed_applications_list' id = fields.Int(dump_only=True) bundle_id = fields.Str() external_version_id = fields.Int() has_configuration = fields.Bool() has_feedback = fields.Bool() is_validated = fields.Bool() management_flags = fields.Int() status = fields.Str() device = Relationship( related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, many=False, schema='DeviceSchema', type_='devices', ) class ApplicationManifestSchema(Schema): class Meta: type_ = 'application_manifests' self_view = 'applications_api.application_manifest_detail' self_view_kwargs = {'application_manifest_id': ''} self_view_many = 'applications_api.application_manifest_list' strict = True checksums = Relationship( related_view='applications_api.application_manifest_checksum_detail', related_view_kwargs={'application_checksum_id': ''}, many=True, schema='ApplicationManifestChecksumSchema', type_='application_manifest_checksums' ) full_size_image_url = fields.Url() display_image_url = fields.Url() class ApplicationManifestChecksumSchema(Schema): class Meta: type_ = 'application_manifest_checksums' self_view = 'applications_api.application_manifest_checksum_detail' self_view_kwargs = {'application_checksum_id': ''} self_view_many = 'applications_api.application_manifest_checksum_list' strict = True ================================================ FILE: commandment/auth/__init__.py ================================================ ================================================ FILE: commandment/auth/app.py ================================================ from flask import Blueprint, request from .oauth2 import authorization oauth_app = Blueprint('oauth_app', __name__) # @bp.route('/authorize', methods=['GET', 'POST']) # def authorize(): # if current_user: # form = ConfirmForm() # else: # form = LoginConfirmForm() # # if form.validate_on_submit(): # if form.confirm.data: # # granted by current user # grant_user = current_user # else: # grant_user = None # return authorization.create_authorization_response(grant_user) # try: # grant = authorization.validate_authorization_request() # except OAuth2Error as error: # # TODO: add an error page # payload = dict(error.get_body()) # return jsonify(payload), error.status_code # # client = OAuth2Client.get_by_client_id(request.args['client_id']) # return render_template( # 'account/authorize.html', # grant=grant, # scopes=scopes, # client=client, # form=form, # ) @oauth_app.route('/token', methods=['POST']) def issue_token(): return authorization.create_token_response(request=request) @oauth_app.route('/revoke', methods=['POST']) def revoke_token(): return authorization.create_revocation_response() ================================================ FILE: commandment/auth/models.py ================================================ from commandment.models import db from authlib.flask.oauth2.sqla import OAuth2ClientMixin, OAuth2TokenMixin class User(db.Model): __tablename__ = 'users' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String) fullname = db.Column(db.String) password = db.Column(db.String) def get_user_id(self): """This method is implemented as part of the Resource Owner interface for Authlib.""" return self.id class OAuth2Client(db.Model, OAuth2ClientMixin): """OAuth 2 Client""" __tablename__ = 'oauth2_clients' id = db.Column(db.Integer, primary_key=True) user_id = db.Column( db.Integer, db.ForeignKey('users.id', ondelete='CASCADE') ) user = db.relationship('User') class OAuth2Token(db.Model, OAuth2TokenMixin): """Bearer Token""" __tablename__ = 'oauth2_tokens' id = db.Column(db.Integer, primary_key=True) user_id = db.Column( db.Integer, db.ForeignKey('users.id', ondelete='CASCADE') ) user = db.relationship('User') ================================================ FILE: commandment/auth/oauth2.py ================================================ from flask import current_app from authlib.flask.oauth2 import ( AuthorizationServer, ResourceProtector, ) from authlib.flask.oauth2.sqla import ( create_query_token_func, create_save_token_func, create_query_client_func, ) from authlib.specs.rfc6749.grants import ( AuthorizationCodeGrant as _AuthorizationCodeGrant, ImplicitGrant as _ImplicitGrant, ResourceOwnerPasswordCredentialsGrant as _PasswordGrant, ClientCredentialsGrant as _ClientCredentialsGrant, RefreshTokenGrant as _RefreshTokenGrant, ) from authlib.specs.rfc7009 import RevocationEndpoint as _RevocationEndpoint from werkzeug.security import gen_salt from .models import ( db, User, OAuth2Client, # OAuth2AuthorizationCode, OAuth2Token, ) from authlib.oauth2.rfc6750 import BearerTokenValidator from authlib.oauth2 import ( OAuth2Error, ) from authlib.oauth2.rfc6749 import ( MissingAuthorizationError, ) # # class AuthorizationCodeGrant(_AuthorizationCodeGrant): # def create_authorization_code(self, client, user, request): # code = gen_salt(48) # item = OAuth2AuthorizationCode( # code=code, # client_id=client.client_id, # redirect_uri=request.redirect_uri, # scope=request.scope, # user_id=user.id, # ) # db.session.add(item) # db.session.commit() # return code # # def parse_authorization_code(self, code, client): # item = OAuth2AuthorizationCode.query.filter_by( # code=code, client_id=client.client_id).first() # if item and not item.is_expired(): # return item # # def delete_authorization_code(self, authorization_code): # db.session.delete(authorization_code) # db.session.commit() # # def create_access_token(self, token, client, authorization_code): # item = OAuth2Token( # client_id=client.client_id, # user_id=authorization_code.user_id, # **token # ) # db.session.add(item) # db.session.commit() # token['user_id'] = authorization_code.user_id class ImplicitGrant(_ImplicitGrant): def create_access_token(self, token, client, grant_user): item = OAuth2Token( client_id=client.client_id, user_id=grant_user.id, **token ) db.session.add(item) db.session.commit() class PasswordGrant(_PasswordGrant): def authenticate_user(self, username, password): current_app.logger.info('user: %s logging in using resource owner password grant', username) user = User.query.filter_by(name=username).first() return user # if user.check_password(password): # return user def create_access_token(self, token, client, user): item = OAuth2Token( client_id=client.client_id, user_id=user.id, **token ) db.session.add(item) db.session.commit() token['user_id'] = user.id class ClientCredentialsGrant(_ClientCredentialsGrant): def create_access_token(self, token, client): item = OAuth2Token( client_id=client.client_id, user_id=client.user_id, **token ) db.session.add(item) db.session.commit() class RefreshTokenGrant(_RefreshTokenGrant): def authenticate_refresh_token(self, refresh_token): item = OAuth2Token.query.filter_by(refresh_token=refresh_token).first() if item and not item.is_refresh_token_expired(): return item def create_access_token(self, token, authenticated_token): item = OAuth2Token( client_id=authenticated_token.client_id, user_id=authenticated_token.user_id, **token ) db.session.add(item) db.session.delete(authenticated_token) db.session.commit() class RevocationEndpoint(_RevocationEndpoint): def query_token(self, token, token_type_hint, client): q = OAuth2Token.query.filter_by(client_id=client.client_id) if token_type_hint == 'access_token': return q.filter_by(access_token=token).first() elif token_type_hint == 'refresh_token': return q.filter_by(refresh_token=token).first() # without token_type_hint item = q.filter_by(access_token=token).first() if item: return item return q.filter_by(refresh_token=token).first() def invalidate_token(self, token): db.session.delete(token) db.session.commit() query_client = create_query_client_func(db.session, OAuth2Client) save_token = create_save_token_func(db.session, OAuth2Token) authorization = AuthorizationServer(query_client=query_client, save_token=save_token) # support all grants # authorization.register_grant_endpoint(AuthorizationCodeGrant) authorization.register_grant(ImplicitGrant) authorization.register_grant(PasswordGrant) authorization.register_grant(ClientCredentialsGrant) authorization.register_grant(RefreshTokenGrant) # support revocation # authorization.register_grant(RevocationEndpoint) # scopes definition scopes = { 'email': 'Access to your email address.', 'connects': 'Access to your connected networks.' } class CommandmentBearerTokenValidator(BearerTokenValidator): def authenticate_token(self, token_string): return OAuth2Token.query.filter_by(access_token=token_string).first() def request_invalid(self, request): return False def token_revoked(self, token): return token.revoked class FlaskJSONAPIResourceProtector(ResourceProtector): """This class pretends to be the Flask-OAuthlib manager for Flask-Rest-JSONAPI""" _after_request_funcs = [] def verify_request(self, scopes): current_app.logger.info('verifying token against scopes: %s', scopes) try: # self.acquire_token(scopes) self.acquire_token('') # We are currently not checking scopes. except MissingAuthorizationError as error: self.raise_error_response(error) except OAuth2Error as error: self.raise_error_response(error) return True, [] # protect resource query_token = create_query_token_func(db.session, OAuth2Token) require_oauth = FlaskJSONAPIResourceProtector() require_oauth.register_token_validator(CommandmentBearerTokenValidator()) def init_app(app): authorization.init_app(app) ================================================ FILE: commandment/cli.py ================================================ #!/usr/bin/env python """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ import os from commandment import create_app from commandment.pki.ssl import generate_self_signed_certificate from cryptography.hazmat.primitives import serialization from commandment.apns.push import get_apns def server(): """Run server in standalone development mode.""" app = create_app(os.environ['COMMANDMENT_SETTINGS']) # Werkzeug, in debug mode, will launch the app using the debug file-system # watching auto-reloader. For threads this means that there would be two # sets of threads launched. Here we try to guard against that by only # starting our runner threads when either the reloader (debug) is off, or # only in the reloader sub-process and not the reloader parent process to # avoid extraneous threads being created. # TODO: re-enable runner after python3 rewrite with app.app_context(): apns = get_apns() # # # if not app.config.get('DEBUG') or werkzeug.serving.is_running_from_reloader(): # start_runner() # atexit.register(stop_runner) cert_path = os.path.join(app.root_path, app.config.get('SSL_CERTIFICATE')) key_path = os.path.join(app.root_path, app.config.get('SSL_RSA_KEY')) app.logger.debug('Using RSA Private Key From: %s', os.path.abspath(key_path)) app.logger.debug('Using SSL Certificate From: %s', os.path.abspath(cert_path)) # pk, csr = generate_signing_request(app.config['PUBLIC_HOSTNAME']) # app.logger.debug('Generated signing request for', app.config['PUBLIC_HOSTNAME']) if not os.path.exists(cert_path) and not os.path.exists(key_path): app.logger.info('Generating Self Signed Certificate') pk, cert = generate_self_signed_certificate(app.config['PUBLIC_HOSTNAME']) pem_key = pk.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) with open(key_path, 'wb') as fd: fd.write(pem_key) pem_cert = cert.public_bytes( encoding=serialization.Encoding.PEM ) with open(cert_path, 'wb') as fd: fd.write(pem_cert) # http://werkzeug.pocoo.org/docs/0.11/serving/#werkzeug.serving.run_simple app.run( host='0.0.0.0', port=app.config.get('PORT'), ssl_context=(cert_path, key_path), threaded=True) ================================================ FILE: commandment/cms/__init__.py ================================================ from typing import Union, Optional, Type from asn1crypto.cms import CertificateSet, SignerIdentifier, Certificate, SignedDigestAlgorithm, DigestAlgorithm from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import padding def _certificate_by_signer_identifier(certificates: CertificateSet, sid: SignerIdentifier) -> Optional[Certificate]: """Find a signer certificate by its SignerIdentifier. Args: certificates (CertificateSet): Set of certificates parsed by asn1crypto. sid (SignerIdentifier): Signer Identifier, usually IssuerAndSerialNumber. Returns: cms.Certificate or None """ if sid.name != 'issuer_and_serial_number': return None # Only IssuerAndSerialNumber for now #: IssuerAndSerialNumber ias = sid.chosen for c in certificates: if c.name != 'certificate': continue # we only support certificate for now chosen = c.chosen #: Certificate if chosen.serial_number != ias['serial_number'].native: continue if chosen.issuer == ias['issuer']: return chosen return None def _cryptography_hash_function(algorithm: DigestAlgorithm) -> Union[None, Type[hashes.SHA1], Type[hashes.SHA256], Type[hashes.SHA512]]: """Find the cryptography hash function given the string output from asn1crypto SignedDigestAlgorithm. Todo: There should be a better way to do this? Args: algorithm (DigestAlgorithm): The asn1crypto Signed Digest Algorithm Returns: Union[Type[hashes.SHA1], Type[hashes.SHA256], Type[hashes.SHA512]] A cryptography hash function for use with signature verification. """ hash_algo = algorithm['algorithm'].native if hash_algo == "sha1": return hashes.SHA1 elif hash_algo == "sha256": return hashes.SHA256 elif hash_algo == "sha512": return hashes.SHA512 else: return None def _cryptography_pad_function(algorithm: SignedDigestAlgorithm) -> Union[None, Type[padding.PKCS1v15]]: """Find the cryptography pad function given a signed digest algorithm from asn1crypto. Args: algorithm (SignedDigestAlgorithm): The asn1crypto Signed Digest Algorithm Returns: Union[None, Type[padding.PKCS1v15]]: The padding function for the signed digest """ signature_algo = algorithm.signature_algo if signature_algo == "rsassa_pkcs1v15": return padding.PKCS1v15 else: return None ================================================ FILE: commandment/cms/decorators.py ================================================ from typing import List, Tuple from asn1crypto.cms import CMSAttribute from cryptography.exceptions import InvalidSignature from flask import request, g, current_app, abort from functools import wraps from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from asn1crypto import cms from base64 import b64decode, b64encode from . import _certificate_by_signer_identifier, _cryptography_hash_function, _cryptography_pad_function def _verify_cms_signers(signed_data: bytes, detached: bool = False) -> Tuple[List[x509.Certificate], bytes]: ci = cms.ContentInfo.load(signed_data) assert ci['content_type'].native == 'signed_data' signed: cms.SignedData = ci['content'] current_app.logger.debug("CMS request contains %d certificate(s)", len(signed['certificates'])) signers = [] for signer in signed['signer_infos']: asn_certificate = _certificate_by_signer_identifier(signed['certificates'], signer['sid']) assert asn_certificate is not None certificate = x509.load_der_x509_certificate(asn_certificate.dump(), default_backend()) digest_algorithm = signer['digest_algorithm'] signature_algorithm = signer['signature_algorithm'] hash_function = _cryptography_hash_function(digest_algorithm) pad_function = _cryptography_pad_function(signature_algorithm) if hash_function is None or pad_function is None: raise ValueError('Unsupported signature algorithm: {}'.format(signature_algorithm)) else: current_app.logger.debug("Using signature algorithm: %s", signature_algorithm.native) assert signed['encap_content_info']['content_type'].native == 'data' if detached: data = request.data else: data = signed['encap_content_info']['content'].native if 'signed_attrs' in signer and len(signer['signed_attrs']) > 0: for i in range(0, len(signer['signed_attrs'])): signed_attr: CMSAttribute = signer['signed_attrs'][i] if signed_attr['type'].native == "message_digest": current_app.logger.debug("SignerInfo digest: %s", b64encode(signed_attr['values'][0].native)) certificate.public_key().verify( signer['signature'].native, signer['signed_attrs'].dump(), pad_function(), hash_function() ) else: # No signed attributes means we are only validating the digest certificate.public_key().verify( signer['signature'].native, data, pad_function(), hash_function() ) signers.append(certificate) # TODO: Don't assume that content is OctetString if detached: return signers, request.data else: return signers, signed['encap_content_info']['content'].native def verify_cms_signers(f): """Verify the signers of a request containing a CMS/PKCS#7, DER encoded body. The certificate of each signer is placed on the global **g** variable as **g.signers** and the signed data is set as **g.signed_data**. In unit tests, this decorator is completely disabled by the presence of testing = True Raises: - TypeError if *Content-Type* header is not "application/pkcs7-signature" - SigningError if any signer on the CMS content is not valid. """ @wraps(f) def decorator(*args, **kwargs): if current_app.testing: return f(*args, **kwargs) current_app.logger.debug('Verifying CMS Request Data for request to %s', request.url) if request.headers['Content-Type'] != "application/pkcs7-signature": raise TypeError("verify_cms_signers expects application/pkcs7-signature, got: {}".format( request.headers['Content-Type'])) g.signers, g.signed_data = _verify_cms_signers(request.data) return f(*args, **kwargs) return decorator def verify_mdm_signature(f): """Verify the signature supplied by the client in the request using the ``Mdm-Signature`` header. If the authenticity of the message has been verified, then the signer is attached to the **g** object as **g.signer**. In unit tests, this decorator is completely disabled by the presence of app.testing = True. You can also disable enforcement in dev by setting the flask setting DEBUG to true. :reqheader Mdm-Signature: BASE64-encoded CMS Detached Signature of the message. (if `SignMessage` was true) """ @wraps(f) def decorator(*args, **kwargs): if current_app.testing: return f(*args, **kwargs) if 'Mdm-Signature' not in request.headers: raise TypeError('Client did not supply an Mdm-Signature header but signature is required.') detached_signature = b64decode(request.headers['Mdm-Signature']) try: signers, signed_data = _verify_cms_signers(detached_signature, detached=True) g.signers = signers g.signed_data = signed_data except InvalidSignature as e: current_app.logger.warn("Invalid Signature in Mdm-Signature header") if not current_app.config.get('DEBUG', False): return abort(403) return f(*args, **kwargs) return decorator ================================================ FILE: commandment/dbtypes.py ================================================ from sqlalchemy.types import TypeDecorator, CHAR from sqlalchemy.dialects.postgresql import UUID import uuid import json from datetime import datetime from sqlalchemy.types import TypeDecorator from sqlalchemy import Text class GUID(TypeDecorator): """Platform-independent GUID type. Uses Postgresql's UUID type, otherwise uses CHAR(32), storing as stringified hex values. """ impl = CHAR def load_dialect_impl(self, dialect): if dialect.name == 'postgresql': return dialect.type_descriptor(UUID()) else: return dialect.type_descriptor(CHAR(32)) def process_bind_param(self, value, dialect): if value is None: return value elif dialect.name == 'postgresql': return str(value) else: if not isinstance(value, uuid.UUID): return "%.32x" % uuid.UUID(value).int else: # hexstring return "%.32x" % value.int def process_result_value(self, value, dialect): if value is None: return value else: return uuid.UUID(value) def json_datetime_serializer(o): """Serialize datetime objects into ISO format string dates Raises: TypeError: If the https://mdmcert.download/api/v1/signrequestobject cannot be serialized. """ if isinstance(o, datetime): return o.isoformat() raise TypeError(repr(o) + " is not JSON serializable") class JSONEncodedDict(TypeDecorator): """Represents an immutable structure as a json-encoded string""" impl = Text def process_bind_param(self, value, dialect): if value is None: return None return json.dumps(value, separators=(',', ':'), default=json_datetime_serializer) def process_result_value(self, value, dialect): if not value: return None return json.loads(value) class SetOfEnumValues(TypeDecorator): """Represents a Set of Enumeration values, encoded as a json array of enum names.""" impl = Text def __init__(self, *arg, **kw): TypeDecorator.__init__(self, *arg, **kw) self.values = arg[0] def process_bind_param(self, value, dialect): # type: (List[Enum], any) -> str if value is None: return None return json.dumps([v.value for v in value], separators=(',', ':'), default=json_datetime_serializer) def process_result_value(self, value, dialect): if not value: return None values = json.loads(value) evalues = [self.values(v) for v in values] return evalues ================================================ FILE: commandment/decorators.py ================================================ from functools import wraps from flask import request, abort, current_app, g from cryptography import x509 from cryptography.exceptions import UnsupportedAlgorithm from cryptography.hazmat.backends import default_backend import plistlib def parse_plist_input_data(f): """Parses plist data as HTTP input from request. The unserialized data is attached to the global **g** object as **g.plist_data**. :status 400: If invalid plist data was supplied in the request. """ @wraps(f) def decorator(*args, **kwargs): try: if current_app.debug: current_app.logger.debug(request.data) g.plist_data = plistlib.loads(request.data) except: current_app.logger.info('could not parse property list input data') abort(400, 'invalid input data') return f(*args, **kwargs) return decorator def pem_certificate_upload(f): """Parse PEM formatted certificate in request data TODO: form field name option """ @wraps(f) def decorator(*args, **kwargs): try: certificate_data = request.files['file'].read() g.certificate = x509.load_pem_x509_certificate(certificate_data, backend=default_backend()) except UnsupportedAlgorithm as e: current_app.logger.info('could not parse PEM certificate data') abort(400, 'invalid input data') return f(*args, **kwargs) return decorator ================================================ FILE: commandment/default_settings.py ================================================ # Flask Dev Server PORT = 5443 # Flask-Alembic imports configuration from here instead of the alembic.ini ALEMBIC = { 'script_location': '%(here)s/alembic/versions' } ALEMBIC_CONTEXT = { 'render_as_batch': True, # Necessary to support SQLite ALTER on constraints } # Describes a static OAuth 2 Client which is the Commandment UI OAUTH2_CLIENT_UI = { 'client_id': 'F8955645-A21D-44AE-9387-42B0800ADF15', 'client_secret': 'A', 'token_endpoint_auth_method': 'client_secret_basic', 'grant_type': 'password', 'response_type': 'token', 'scope': 'profile', 'client_name': 'Commandment UI' } # http://flask-sqlalchemy.pocoo.org/2.1/config/ SQLALCHEMY_DATABASE_URI = 'sqlite:///commandment/commandment.db' # FSADeprecationWarning: SQLALCHEMY_TRACK_MODIFICATIONS adds significant overhead and will be disabled by default in the future. SQLALCHEMY_TRACK_MODIFICATIONS = False # PLEASE! Do not take this key and use it for another product/project. It's # only for Commandment's use. If you'd like to get your own (free!) key # contact the mdmcert.download administrators and get your own key for your # own project/product. We're trying to keep statistics on which products are # requesting certs (per Apple T&C). Don't force Apple's hand and # ruin it for everyone! MDMCERT_API_KEY = 'b742461ff981756ca3f924f02db5a12e1f6639a9109db047ead1814aafc058dd' PLISTIFY_MIMETYPE = 'application/xml' # Internal CA - Certificate X.509 Attributes INTERNAL_CA_CN = 'COMMANDMENT-CA' INTERNAL_CA_O = 'Commandment' # -------------- # SCEPy Defaults # -------------- # Directory where certs, revocation lists, serials etc will be kept SCEPY_CA_ROOT = "CA" # X.509 Name Attributes used to generate the CA Certificate SCEPY_CA_X509_CN = 'SCEPY-CA' SCEPY_CA_X509_O = 'SCEPy' SCEPY_CA_X509_C = 'US' # Force a single certificate to be returned as a PKCS#7 Degenerate instead of raw DER data SCEPY_FORCE_DEGENERATE_FOR_SINGLE_CERT = False # These applications will not be shown in inventory. IGNORED_APPLICATION_BUNDLE_IDS = [ 'com.apple.MigrateAssistant', 'com.apple.keychainaccess', 'com.apple.grapher', 'com.apple.Grab', 'com.apple.ActivityMonitor', 'com.apple.backup.launcher', # Time Machine 'com.apple.TextEdit', 'com.apple.systempreferences', 'com.apple.CoreLocationAgent', 'com.apple.CaptiveNetworkAssistant', 'com.apple.CalendarFileHandler', 'com.apple.BluetoothUIServer', 'com.apple.BluetoothSetupAssistant', 'com.apple.AutomatorRunner', 'com.apple.AppleFileServer', 'com.apple.AirportBaseStationAgent', 'com.apple.AirPlayUIAgent', 'com.apple.AddressBook.UrlForwarder', 'com.apple.AVB-Audio-Configuration', 'com.apple.ScriptMonitor', 'com.apple.ScreenSaver.Engine', 'com.apple.systemevents', 'com.apple.stocks', 'com.apple.Spotlight', 'com.apple.SoftwareUpdate', 'com.apple.SocialPushAgent', 'com.apple.Siri', 'com.apple.screencapturetb', 'com.apple.rcd', 'com.apple.CloudKit.ShareBear', 'com.apple.cloudphotosd', 'com.apple.wifi.WiFiAgent', 'com.apple.weather', 'com.apple.VoiceOver', 'com.apple.UserNotificationCenter', 'com.apple.UnmountAssistantAgent', 'com.apple.UniversalAccessControl', 'com.apple.Ticket-Viewer', 'com.apple.ThermalTrap', 'com.apple.systemuiserver', 'com.apple.check_afp', 'com.apple.AddressBook.sync', 'com.apple.AddressBookSourceSync', 'com.apple.AddressBook.abd', 'com.apple.ABAssistantService', 'com.apple.FontRegistryUIAgent', 'com.apple.speech.synthesis.SpeechSynthesisServer', 'com.apple.print.PrinterProxy', 'com.apple.StorageManagementLauncher', 'com.apple.Terminal', 'com.apple.PhotoBooth', 'com.apple.mail', 'com.apple.notificationcenter.widgetsimulator', 'com.apple.quicklook.ui.helper', 'com.apple.quicklook.QuickLookSimulator', 'com.apple.QuickLookDaemon32', 'com.apple.QuickLookDaemon', 'com.apple.syncserver', 'com.apple.WebKit.PluginHost', 'com.apple.AirScanScanner', 'com.apple.MakePDF', 'com.apple.BuildWebPage', 'com.apple.VIM-Container', 'com.apple.TrackpadIM-Container', 'com.apple.inputmethod.Tamil', 'com.apple.TCIM-Container', 'com.apple.exposelauncher', 'com.apple.iChat', 'com.apple.Maps', 'com.apple.launchpad.launcher', 'com.apple.FaceTime', 'com.apple.Dictionary', 'com.apple.dashboardlauncher', 'com.apple.DVDPlayer', 'com.apple.Chess', 'com.apple.iCal', 'com.apple.calculator', 'com.apple.Automator', 'com.apple.KIM-Container', 'com.apple.CharacterPaletteIM', 'com.apple.inputmethod.AssistiveControl', 'com.apple.VirtualScanner', 'com.apple.Type8Camera', 'com.apple.loginwindow', 'com.apple.SetupAssistant', 'com.apple.PhotoLibraryMigrationUtility', 'com.apple.notificationcenterui', 'com.apple.ManagedClient', 'com.apple.helpviewer', 'com.apple.finder.Open-iCloudDrive', 'com.apple.finder.Open-Recents', 'com.apple.finder.Open-Network', 'com.apple.finder.Open-Computer', 'com.apple.finder.Open-AllMyFiles', 'com.apple.finder.Open-AirDrop', 'com.apple.finder', 'com.apple.dock', 'com.apple.coreservices.uiagent', 'com.apple.controlstrip', 'com.apple.CertificateAssistant', 'com.apple.wifi.diagnostics', 'com.apple.SystemImageUtility', 'com.apple.RAIDUtility', 'com.apple.NetworkUtility', 'com.apple.FolderActionsSetup', 'com.apple.DirectoryUtility', 'com.apple.AboutThisMacLauncher', 'com.apple.AppleScriptUtility', 'com.apple.AppleGraphicsWarning', 'com.apple.print.add', 'com.apple.archiveutility', 'com.apple.appstore', 'com.apple.Console', 'com.apple.bootcampassistant', 'com.apple.BluetoothFileExchange', 'com.apple.siri.launcher', 'com.apple.reminders', 'com.apple.QuickTimePlayerX', 'com.apple.Image_Capture', 'com.apple.accessibility.universalAccessAuthWarn', 'com.apple.accessibility.universalAccessHUD', 'com.apple.accessibility.DFRHUD', 'com.apple.syncservices.syncuid', 'com.apple.syncservices.ConflictResolver', 'com.apple.STMFramework.UIHelper', 'com.apple.speech.SpeechRecognitionServer', 'com.apple.speech.SpeechDataInstallerd', 'com.apple.ScreenReaderUIServer', 'com.apple.PubSubAgent', 'com.apple.nbagent', 'com.apple.soagent', 'com.apple.imtransferservices.IMTransferAgent', 'com.apple.IMAutomaticHistoryDeletionAgent', 'com.apple.imagent', 'com.apple.imavagent', 'com.apple.idsfoundation.IDSRemoteURLConnectionAgent', 'com.apple.identityservicesd', 'com.apple.FindMyMacMessenger', 'com.apple.Family', 'com.apple.familycontrols.useragent', 'com.apple.eap8021x.eaptlstrust', 'com.apple.frameworks.diskimages.diuiagent', 'com.apple.FollowUpUI', 'com.apple.CCE.CIMFindInputCode', 'com.apple.cmfsyncagent', 'com.apple.storeuid', 'com.apple.lateragent', 'com.apple.bird', # iCloud Drive 'com.apple.AskPermissionUI', 'com.apple.Calibration-Assistant', 'com.apple.AccessibilityVisualsAgent', 'com.apple.AOSPushRelay', 'com.apple.AOSHeartbeat', 'com.apple.AOSAlertManager', 'com.apple.iCloudUserNotificationsd', 'com.apple.SCIM-Container', 'com.apple.PAH-Container', 'com.apple.inputmethod.PluginIM', 'com.apple.KeyboardViewer', 'com.apple.PIPAgent', 'com.apple.OSDUIHelper', 'com.apple.ODSAgent', 'com.apple.OBEXAgent', 'com.apple..NowPlayingWidgetContainer', 'com.apple.NowPlayingTouchUI', 'com.apple.NetAuthAgent', 'com.apple.MemorySlotUtility', 'com.apple.locationmenu', 'com.apple.Language-Chooser', 'com.apple.security.Keychain-Circle-Notification', 'com.apple.KeyboardSetupAssistant', 'com.apple.JavaWebStart', 'com.apple.JarLauncher', 'com.apple.Installer-Progress', 'com.apple.PackageKit.Install-in-Progress', 'com.apple.dt.CommandLineTools.installondemand', 'com.apple.imageevents', 'com.apple.gamecenter', 'com.apple.FolderActionsDispatcher', 'com.apple.ExpansionSlotUtility', 'com.apple.EscrowSecurityAlert', 'com.apple.DwellControl', 'com.apple.DiscHelper', 'com.apple.databaseevents', 'com.apple.ColorSyncCalibrator', 'com.apple.print.AirScanLegacyDiscovery', 'com.apple.ScriptEditor.id.image-file-processing-droplet-template', 'com.apple.ScriptEditor.id.file-processing-droplet-template', 'com.apple.ScriptEditor.id.droplet-with-settable-properties-template', 'com.apple.ScriptEditor.id.cocoa-applet-template', 'com.apple.inputmethod.Ainu', 'com.apple.50onPaletteIM', 'com.apple.AutoImporter', 'com.apple.Type5Camera', 'com.apple.Type4Camera', 'com.apple.PTPCamera', 'com.apple.MassStorageCamera', 'com.apple.imautomatichistorydeletionagent', 'com.apple.SyncServices.AppleMobileSync', 'com.apple.SyncServices.AppleMobileDeviceHelper', 'com.apple.coreservices.UASharedPasteboardProgressUI', 'com.apple.SummaryService', 'com.apple.ImageCaptureService', 'com.apple.ChineseTextConverterService', 'com.apple.Pass-Viewer', 'com.apple.PowerChime', 'com.apple.ProblemReporter', 'com.apple.pluginIM.pluginIMRegistrator', 'com.apple.ReportPanic', 'com.apple.RemoteDesktopAgent', 'com.apple.RapportUIAgent', 'com.apple.MRT', 'com.apple.AirPortBaseStationAgent', 'com.apple.appstore.AppDownloadLauncher', 'com.apple.appleseed.FeedbackAssistant', 'com.apple.ScreenSharing', 'com.apple.FirmwareUpdateHelper', 'com.apple.SecurityFixer', 'com.apple.ZoomWindow.app', 'com.apple.IMServicePlugInAgent', 'com.apple.itunes.connect.ApplicationLoader', 'com.apple.DiskImageMounter', 'com.apple.NetworkDiagnostics', 'com.apple.installer', 'com.apple.VoiceOverQuickstart', ] ================================================ FILE: commandment/dep/__init__.py ================================================ from typing import Set, Dict from enum import Enum class SetupAssistantStep(Enum): """This enumeration contains all possible steps of Setup Assistant that can be skipped. See Also: - `DEP Web Services: Define Profile `_. """ """Skips Apple ID setup.""" AppleID = 'AppleID' """Skips Touch ID setup.""" Biometric = 'Biometric' """Disables automatically sending diagnostic information.""" Diagnostics = 'Diagnostics' """Skips DisplayTone setup.""" DisplayTone = 'DisplayTone' """Disables Location Services.""" Location = 'Location' """Hides and disables the passcode pane.""" Passcode = 'Passcode' """Skips Apple Pay setup.""" Payment = 'Payment' """Skips privacy pane.""" Privacy = 'Privacy' """Disables restoring from backup.""" Restore = 'Restore' SIMSetup = 'SIMSetup' """Disables Siri.""" Siri = 'Siri' """Skips Terms and Conditions.""" TOS = 'TOS' """Skips zoom setup.""" Zoom = 'Zoom' """If the Restore pane is not skipped, removes Move from Android option from it.""" Android = 'Android' """Skips the Home Button screen in iOS.""" HomeButtonSensitivity = 'HomeButtonSensitivity' """Skips on-boarding informational screens for user education (“Cover Sheet, Multitasking & Control Center”, for example) in iOS.""" iMessageAndFaceTime = 'iMessageAndFaceTime' """Skips the iMessage and FaceTime screen in iOS.""" OnBoarding = 'OnBoarding' """Skips the screen for Screen Time in iOS.""" ScreenTime = 'ScreenTime' """Skips the mandatory software update screen in iOS.""" SoftwareUpdate = 'SoftwareUpdate' """Skips the screen for watch migration in iOS.""" WatchMigration = 'WatchMigration' """Skips the Choose Your Look screen in macOS.""" Appearance = 'Appearance' """Disables FileVault Setup Assistant screen in macOS.""" FileVault = 'FileVault' """Skips iCloud Analytics screen in macOS.""" iCloudDiagnostics = 'iCloudDiagnostics' """Skips iCloud Documents and Desktop screen in macOS.""" iCloudStorage = 'iCloudStorage' """Disables registration screen in macOS""" Registration = 'Registration' # ATV """Skips the tvOS screen about using aerial screensavers in ATV.""" ScreenSaver = 'ScreenSaver' """Skips the Tap To Set Up option in ATV about using an iOS device to set up your ATV (instead of entering all your account information and setting choices separately).""" TapToSetup = 'TapToSetup' """Skips TV home screen layout sync screen in tvOS.""" TVHomeScreenSync = 'TVHomeScreenSync' """Skips the TV provider sign in screen in tvOS.""" TVProviderSignIn = 'TVProviderSignIn' """Skips the “Where is this Apple TV?” screen in tvOS.""" TVRoom = 'TVRoom' SkipSetupSteps = Set[SetupAssistantStep] class DEPProfileRemovalStatus(Enum): SUCCESS = "SUCCESS" NOT_ACCESSIBLE = "NOT_ACCESSIBLE" FAILED = "FAILED" SerialNumber = str DEPProfileRemovals = Dict[SerialNumber, DEPProfileRemovalStatus] class DEPOrgType(Enum): """This enum specifies allowable values for the ``org_type`` field of the dep /account endpoint.""" Education = 'edu' Organization = 'org' class DEPOrgVersion(Enum): """This enum specifies allowable values for the ``org_version`` field of the dep /account endpoint.""" v1 = 'v1' # Apple Deployment Programmes v2 = 'v2' # Apple School Manager class DEPOperationType(Enum): """This enum describes the types of operations returned in a DEP Sync Devices result.""" Added = 'added' Modified = 'modified' Deleted = 'deleted' ================================================ FILE: commandment/dep/app.py ================================================ import sqlalchemy.orm.exc import datetime import dateutil.parser from flask import Blueprint, jsonify, g, current_app, abort, request from flask_rest_jsonapi import Api from cryptography.hazmat.primitives.serialization import Encoding from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from cryptography.x509 import NameOID from base64 import urlsafe_b64encode from commandment.models import db from commandment.pki.models import RSAPrivateKey, CertificateSigningRequest from commandment.dep.models import DEPServerTokenCertificate, DEPAccount from commandment.enroll.util import generate_enroll_profile from commandment.cms.decorators import verify_cms_signers from commandment.plistutil.nonewriter import dumps as dumps_none from commandment.profiles.plist_schema import ProfileSchema from commandment.profiles import PROFILE_CONTENT_TYPE from commandment.pki.ca import get_ca from commandment.dep import smime from .resources import DEPProfileList, DEPProfileDetail, DEPProfileRelationship, DEPAccountList, DEPAccountDetail import plistlib import json dep_app = Blueprint('dep_app', __name__) api = Api(blueprint=dep_app) api.route(DEPProfileList, 'dep_profiles_list', '/api/v1/dep/profiles/', '/api/v1/dep/accounts//profiles') api.route(DEPProfileDetail, 'dep_profile_detail', '/api/v1/dep/profiles/') api.route(DEPProfileRelationship, 'dep_profile_devices', '/api/v1/dep/profiles//relationships/devices') api.route(DEPProfileRelationship, 'dep_profile_dep_account', '/api/v1/dep/profiles//relationships/dep_account') api.route(DEPAccountList, 'dep_accounts_list', '/api/v1/dep/accounts/') api.route(DEPAccountDetail, 'dep_account_detail', '/api/v1/dep/accounts/') @dep_app.route('/dep/certificate/download', methods=["GET"]) def certificate_download(): """Create a new key/certificate to upload to the DEP/ASM/ABM portal. The private key generated for this certificate will be the key recipient of the DEP S/MIME payload. """ try: certificate_model = db.session.query(DEPServerTokenCertificate).filter_by(x509_cn='COMMANDMENT-DEP').one() except sqlalchemy.orm.exc.NoResultFound: ca = get_ca() private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) private_key_model = RSAPrivateKey.from_crypto(private_key) db.session.add(private_key_model) name = x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, 'COMMANDMENT-DEP'), x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'commandment') ]) builder = x509.CertificateSigningRequestBuilder() builder = builder.subject_name(name) builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) request = builder.sign( private_key, hashes.SHA256(), default_backend() ) request_model = CertificateSigningRequest.from_crypto(request) request_model.rsa_private_key = private_key_model db.session.add(request_model) certificate = ca.sign(request) certificate_model = DEPServerTokenCertificate.from_crypto(certificate) certificate_model.rsa_private_key = private_key_model db.session.add(certificate_model) db.session.commit() return certificate_model.pem_data, 200, {'Content-Type': 'application/x-x509-ca-cert', 'Content-Disposition': 'attachment; filename="commandment-dep.cer"'} @dep_app.route('/dep/stoken/upload', methods=["POST"]) def stoken_upload(): """Upload the smime.p7m supplied from the DEP, ASM or ABM portals and decrypt it with a matching private key from our database, storing the result in the ``dep_configurations`` table. :reqheader Accept: application/vnd.api+json :reqheader Content-Type: multipart/form-data :statuscode 200: token decrypted ok :statuscode 400: token was unable to be decrypted. :statuscode 500: system error """ if 'file' not in request.files: abort(400, 'no file uploaded in request data') f = request.files['file'] try: certificate_model = db.session.query(DEPServerTokenCertificate).filter_by(x509_cn='COMMANDMENT-DEP').one() except sqlalchemy.orm.exc.NoResultFound: return abort(400, "No DEP certificate generated, impossible to decrypt the DEP token") pk: RSAPrivateKey = certificate_model.rsa_private_key if pk is None: return abort(500, 'Missing RSA Private Key for uploaded DEP token.') pk_crypto = pk.to_crypto() smime_data = f.read() payload = smime.decrypt(smime_data, pk_crypto) # dirty, dirty hacks for now. python email does not strip boundaries payload = payload.replace('-----BEGIN MESSAGE-----', '').replace('-----END MESSAGE-----', '') try: stoken = json.loads(payload) except json.decoder.JSONDecodeError: current_app.logger.debug(payload) return abort(400, "Failed to decode token, could not parse JSON data inside S/MIME data") try: dep_account = db.session.query(DEPAccount).one() except sqlalchemy.orm.exc.NoResultFound: dep_account = DEPAccount() dep_account.certificate = certificate_model dep_account.consumer_key = stoken['consumer_key'] dep_account.consumer_secret = stoken['consumer_secret'] dep_account.access_token = stoken['access_token'] dep_account.access_secret = stoken['access_secret'] dep_account.access_token_expiry = dateutil.parser.parse(stoken['access_token_expiry']) dep_account.token_updated_at = datetime.datetime.utcnow() db.session.commit() current_app.logger.debug('Saved DEP stoken') return jsonify(stoken) @dep_app.route('/dep/enroll', methods=["POST"]) @verify_cms_signers def profile(): """Accept a CMS Signed DER encoded XML data containing device information. This starts the DEP enrollment process. The absolute url to this endpoint should be present in the DEP profile's enrollment URL. The signed data contains a plist with the following keys: :UDID: The device’s UDID. :SERIAL: The device's Serial Number. :PRODUCT: The device’s product type: e.g., iPhone5,1. :VERSION: The OS version installed on the device: e.g., 7A182. :IMEI: The device’s IMEI (if available). :MEID: The device’s MEID (if available). :LANGUAGE: The user’s currently-selected language: e.g., en. See Also: - `Mobile Device Management Protocol: Request to a Profile URL `_. """ g.plist_data = plistlib.loads(g.signed_data) profile = generate_enroll_profile() schema = ProfileSchema() result = schema.dump(profile) plist_data = dumps_none(result.data, skipkeys=True) return plist_data, 200, {'Content-Type': PROFILE_CONTENT_TYPE} @dep_app.route('/dep/anchor_certs', methods=["GET"]) def anchor_certs(): """Download a list of certificates to trust the MDM The response is a JSON array of base64 encoded DER certs as described in the DEP profile creation documentation.""" anchors = [] if 'CA_CERTIFICATE' in current_app.config: with open(current_app.config['CA_CERTIFICATE'], 'rb') as fd: pem_data = fd.read() c: x509.Certificate = x509.load_pem_x509_certificate(pem_data, backend=default_backend()) der = c.public_bytes(Encoding.DER) anchors.append(urlsafe_b64encode(der)) if 'SSL_CERTIFICATE' in current_app.config: with open(current_app.config['SSL_CERTIFICATE'], 'rb') as fd: pem_data = fd.read() c: x509.Certificate = x509.load_pem_x509_certificate(pem_data, backend=default_backend()) der = c.public_bytes(Encoding.DER) anchors.append(urlsafe_b64encode(der)) return jsonify(anchors) ================================================ FILE: commandment/dep/apple_schema.py ================================================ from marshmallow import fields, Schema from marshmallow_enum import EnumField from . import SetupAssistantStep class AppleDEPProfileSchema(Schema): """marshmallow schema for a DEP profile. See Also: - `/profile endpoint `_. - `Mobile Device Management Protocol Reference `_ "Define Profile" pg. 120 """ # uuid = fields.UUID(dump_only=True) # """str: The Apple assigned UUID of this DEP Profile""" profile_name = fields.String(required=True) """str: A human-readable name for the profile.""" url = fields.Url(required=False) # Should be required """str: The URL of the MDM server.""" allow_pairing = fields.Boolean(default=True) """bool: If true, any device can pair with this device, supervision certs are not required.""" is_supervised = fields.Boolean(default=False) """bool: If true, the device must be supervised""" is_multi_user = fields.Boolean(default=False) """bool: If true, tells the device to configure for Shared iPad.""" is_mandatory = fields.Boolean(default=False) """bool: If true, the user may not skip applying the profile returned by the MDM server""" await_device_configured = fields.Boolean() """bool: If true, Setup Assistant does not continue until the MDM server sends DeviceConfigured.""" is_mdm_removable = fields.Boolean() """bool: If false, the MDM payload delivered by the configuration URL cannot be removed by the user via the user interface on the device""" support_phone_number = fields.String(allow_none=True) """str: A support phone number for the organization.""" auto_advance_setup = fields.Boolean() """bool: If set to true, the device will tell tvOS Setup Assistant to automatically advance though its screens.""" support_email_address = fields.String(allow_none=True) # No need to perform validation here """str: A support email address for the organization.""" org_magic = fields.String(allow_none=True) """str: A string that uniquely identifies various services that are managed by a single organization.""" anchor_certs = fields.List(fields.String()) """List[str]: Each string should contain a DER-encoded certificate converted to Base64 encoding. If provided, these certificates are used as trusted anchor certificates when evaluating the trust of the connection to the MDM server URL.""" supervising_host_certs = fields.List(fields.String()) """List[str]: Each string contains a DER-encoded certificate converted to Base64 encoding. If provided, the device will continue to pair with a host possessing one of these certificates even when allow_pairing is set to false""" skip_setup_items = fields.List(EnumField(SetupAssistantStep)) """Set[SetupAssistantStep]: A list of setup panes to skip""" department = fields.String(allow_none=True) """str: The user-defined department or location name.""" ================================================ FILE: commandment/dep/cli.py ================================================ import argparse import logging import asyncio from commandment.dep.dep import DEP parser = argparse.ArgumentParser() parser.add_argument("consumer_key", help="The decrypted consumer_key from the DEP stoken") parser.add_argument("consumer_secret", help="The decrypted consumer_secret from the DEP stoken") parser.add_argument("access_token", help="The decrypted access_token from the DEP stoken") parser.add_argument("access_secret", help="The decrypted access_secret from the DEP stoken") parser.add_argument("--url", help="The URL of the DEP Service", default="https://mdmenrollment.apple.com") logger = logging.getLogger(__name__) logging.getLogger('asyncio').setLevel(logging.WARNING) async def initial_dep_fetch(dep: DEP): """Perform the initial DEP fetch, if required.""" for page in dep.devices(): for device in page: pass async def dep_sync(consumer_key: str, consumer_secret: str, access_token: str, access_secret: str, url: str): dep = DEP(consumer_key, consumer_secret, access_token, access_secret, url) initial_fetch = await initial_dep_fetch(dep) def main(): args = parser.parse_args() logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() loop.run_until_complete(dep_sync( args.consumer_key, args.consumer_secret, args.access_token, args.access_secret, args.url, )) try: loop.run_forever() finally: loop.run_until_complete(loop.shutdown_asyncgens()) loop.close() if __name__ == "__main__": main() ================================================ FILE: commandment/dep/dep.py ================================================ from collections.abc import Iterator from typing import Union, List, Optional import requests from requests.auth import AuthBase from requests_oauthlib import OAuth1 import re from datetime import timedelta, datetime from dateutil import parser as dateparser from locale import atof import json import logging from flask import g, current_app from commandment.dep import DEPProfileRemovals from .errors import DEPServiceError, DEPClientError from email.utils import parsedate # Necessary for HTTP-Date logger = logging.getLogger(__name__) # def get_dep(): # type: () -> DEP # dep = getattr(g, '_dep', None) # # if dep is None: # dep_account: DEPAccount = db.session.query(DEPAccount).one() # dep = DEP( # consumer_key=dep_account.consumer_key, # consumer_secret=dep_account.consumer_secret, # access_token=dep_account.access_token, # access_secret=dep_account.access_secret, # ) # # g._dep = dep # # return dep class DEPAuth(AuthBase): """Attach X-ADM-Auth-Session token to the request. Example: session.get("https://something", auth=DEPAuth(token)) """ def __init__(self, token: str) -> None: self.token = token def __call__(self, r): r.headers['X-ADM-Auth-Session'] = self.token return r class DEP: UserAgent = 'commandment' def __init__(self, consumer_key: str = None, consumer_secret: str = None, access_token: str = None, access_secret: str = None, access_token_expiry: Optional[str] = None, url: str = "https://mdmenrollment.apple.com") -> None: self._session_token: Optional[str] = None self._oauth = OAuth1( consumer_key, client_secret=consumer_secret, resource_owner_key=access_token, resource_owner_secret=access_secret, ) if access_token_expiry is not None: access_token_expiry_date = dateparser.parse(access_token_expiry) self._access_token_expiry = access_token_expiry_date else: self._access_token_expiry = None self._url = url self._session = requests.session() self._session.headers.update({ "X-Server-Protocol-Version": "3", "Content-Type": "application/json;charset=UTF8", "User-Agent": DEP.UserAgent, }) self._retry_after: Optional[datetime] = None @property def session_token(self) -> Optional[str]: return self._session_token @classmethod def from_token(cls, token: str): # (str) -> DEP """Instantiate the DEP client instance from a string holding the service token json content.""" stoken = json.loads(token) return cls(**stoken) def _response_hook(self, r: requests.Response, *args, **kwargs): """This method always exists as a response hook in order to keep some of the state returned by the DEP service internally such as: - The last value of the `X-ADM-Auth-Session` header, which is used on subsequent requests. - The last value of the `Retry-After` header, which is used to set an instance variable to indicate when we may make another request. See Also: - `Footnote about **X-ADM-Auth-Session** under Response Payload `_. """ if r.status_code == 401: # Token may be expired, or token is invalid pass # TODO: Need token refresh as decorator # If the service gives us another session token, that replaces our current token. if 'X-ADM-Auth-Session' in r.headers: self._session_token = r.headers['X-ADM-Auth-Session'] # If the service wants to rate limit us, store that information locally. if 'Retry-After' in r.headers: after = r.headers['Retry-After'] if re.compile(r"/[0-9]+/").match(after): d = timedelta(seconds=atof(after)) self._retry_after = datetime.utcnow() + d else: # HTTP Date self._retry_after = datetime(*parsedate(after)[:6]) def send(self, req: requests.Request, **kwargs) -> Optional[requests.Response]: """Send a request to the DEP service. If the service responds that the token has expired, fetch a new session token and re-issue the request. Args: req (requests.Request): The request, which will have DEP auth headers added to it. Returns: requests.Response: The response """ if self._access_token_expiry is not None and datetime.now() > self._access_token_expiry: raise DEPClientError("DEP Service Token has expired, please generate a new one.") if self._retry_after is not None: # refuse to send request return None if self.session_token is None: self.fetch_token() req.hooks = dict(response=self._response_hook) req.auth = DEPAuth(self._session_token) prepared = self._session.prepare_request(req) res = self._session.send(prepared, **kwargs) try: res.raise_for_status() except requests.HTTPError as e: raise DEPServiceError(response=res, request=res.request) from e return res def fetch_token(self) -> Union[str, None]: """Request a new session token using our DEP credentials. Returns: Union[str, None]: The token that was returned (already set on this instance), or None if it failed. """ res = self._session.get(self._url + "/session", auth=self._oauth) try: res.raise_for_status() except requests.HTTPError as e: raise DEPServiceError(response=res, request=res.request) from e self._session_token = res.json().get("auth_session_token", None) return self._session_token def account(self) -> Union[None, dict]: """Get Account Details The details are returned in the following dict format:: { 'server_name': 'MDM Server Name entered in the portal', 'server_uuid': '<32 char UUID without separators>', 'facilitator_id': 'E-mail of facilitator', 'admin_id': 'Administrator E-mail Address', 'org_name': 'Organization Name', 'org_email': 'Organization E-mail', 'org_phone': 'Organization Contact Phone', 'org_address': 'Organization Physical Address' } Returns: Union[None, dict]: The account information, or None if it failed. """ logger.debug("Fetching DEP account information") res = self.send(requests.Request("GET", self._url + "/account")) return res.json() def fetch_devices(self, cursor: Union[str, None] = None, limit: int = 100) -> dict: """Fetch a list of DEP devices Args: cursor (str): The cursor from the last fetch (must be younger than 7 days). limit (int): Limit the number of records in the response. Default is 100 Returns: dict: Response as per the sync devices documentation. """ req = requests.Request("POST", self._url + "/server/devices", json={'limit': limit, 'cursor': cursor}) res = self.send(req) return res.json() def sync_devices(self, cursor: str, limit: int = 100) -> dict: """Fetch devices changed since the cursor was issued. Args: cursor (str): The cursor from the last sync (must be younger than 7 days). limit (int): Limit the number of records in the response. Default is 100 Returns: dict: Response as per the sync devices documentation. """ req = requests.Request("POST", self._url + "/devices/sync", json={'limit': limit, 'cursor': cursor}) res = self.send(req) return res.json() def devices(self, cursor: Union[str, None] = None) -> Iterator: """Get an iterable object which calls fetch or sync to retrieve all device records. Args: cursor (str): If supplied, the cursor returned will perform the sync operation. Otherwise you will receive a cursor that performs a fetch for each iteration, until the fetch cursor is exhausted. Returns: Union[DEPSyncCursor, DEPFetchCursor]: A cursor that is iterable """ if cursor is not None: # Could actually be an expired cursor here return DEPSyncCursor(self, cursor=cursor) else: return DEPFetchCursor(self) def device_detail(self, *serial_numbers: Union[str, List[str]]): """Fetch detail about a list of devices Args: serial_numbers (List[str]): A list of device serial numbers to fetch details for. Returns: dict: Device information """ req = requests.Request("POST", self._url + "/devices", json={'devices': serial_numbers}) res = self.send(req) return res.json() def define_profile(self, profile: dict): """Define a DEP profile Args: profile (dict): A DEP profile. """ req = requests.Request("POST", self._url + "/profile", json=profile) res = self.send(req) return res.json() def assign_profile(self, profile_uuid: str, *serial_numbers: List[str]) -> dict: """Assign an existing profile to device(s) Args: profile_uuid (str): The UUID of the profile to assign. serial_numbers (List[str]): A list of serial numbers to assign to that profile. Returns: dict: Assignment information """ req = requests.Request("POST", self._url + "/profile/devices", json={'profile_uuid': profile_uuid, 'devices': serial_numbers}) res = self.send(req) return res.json() def remove_profile(self, *serial_numbers: List[str]) -> DEPProfileRemovals: """Unassign all profiles from device(s) Args: serial_numbers (List[str]): A list of serial numbers to unassign from that profile. Returns: dict: Assignment information """ req = requests.Request("DELETE", self._url + "/profile/devices", json={'devices': serial_numbers}) res = self.send(req) return res.json() def profile(self, uuid: str) -> dict: """Get an existing profile by its UUID. Args: uuid (str): Profile UUID Returns: dict: Profile """ params = {'profile_uuid': uuid} if uuid is not None else None req = requests.Request("GET", self._url + "/profile", params=params) res = self.send(req) return res.json() def activation_lock(self, serial_number: str, escrow_key: Optional[str] = None, lost_message: Optional[str] = None): """Lock a device with Activation Lock.""" pass def activation_lock_bypass(self, serial_number: str, product_type: str, org_name: str, guid: str, escrow_key: str, imei: Optional[str] = None, meid: Optional[str] = None): """Remove Activation Lock from a device.""" pass def disown(self, *serial_numbers: List[str]): """Disown devices. This action is PERMANENT (except in the case of iPads added via Apple Configurator 2). """ pass class DEPBaseCursor(object): """DEPCursor is the base class for DEP Fetch and Sync cursors. Attributes: owner (DEP): The DEP instance that created this iterator. results (dict): The current response results. """ def __init__(self, owner: DEP, results: Optional[dict] = None) -> None: self.owner = owner self.results = results @property def cursor(self) -> Optional[str]: if not self.results: return None return self.results.get('cursor', None) @property def more_to_follow(self) -> bool: if not self.results: return True return self.results.get('more_to_follow', False) def __iter__(self): return self class DEPFetchCursor(DEPBaseCursor, Iterator): """DEPFetchCursor wraps the DEP device fetch cursor as an iterable object.""" def __next__(self): if not self.more_to_follow: raise StopIteration() if self.cursor is None: self.results = self.owner.fetch_devices() else: self.results = self.owner.fetch_devices(cursor=self.cursor) return self.results class DEPSyncCursor(DEPBaseCursor, Iterator): """DEPSyncCursor wraps the DEP device sync cursor as an iterable object.""" def __init__(self, owner: DEP, cursor: str, results: Optional[dict] = None) -> None: super(DEPSyncCursor, self).__init__(owner, results) self.results = {'cursor': cursor, 'more_to_follow': True} def __next__(self): if not self.more_to_follow: raise StopIteration() self.results = self.owner.sync_devices(cursor=self.cursor) return self.results ================================================ FILE: commandment/dep/errors.py ================================================ from requests import Response, HTTPError class DEPServiceError(HTTPError): """DEPServiceError inherits from request's HTTPError to provide the response and request as part of the exception. Additionally, the error tracks information about the body content as this can sometimes be the only way to distinguish an error. Attributes: text (str): The reserved string that was returned in the error body. """ def __init__(self, *args, **kwargs): super(DEPServiceError, self).__init__(*args, **kwargs) if 'response' in kwargs: # Quote characters (") must be stripped, because the body may contain the reason inside double quotes. self.text = kwargs.get('response').content.decode('utf8').strip("\"\n\r") else: self.text = "NO_REASON_GIVEN" def __str__(self): return '{}: {}'.format(self.response.status_code, self.text) class DEPClientError(Exception): """DEPClientError describes errors that happen on the client side, often as a result of failed validations.""" pass ================================================ FILE: commandment/dep/models.py ================================================ from cryptography import x509 from commandment.dep import SkipSetupSteps, DEPOrgType, DEPOrgVersion, SetupAssistantStep from commandment.models import db from commandment.mutablelist import MutableList from commandment.pki.models import CertificateType, Certificate from commandment.dbtypes import GUID, JSONEncodedDict, SetOfEnumValues class DEPServerTokenCertificate(Certificate): """DEP Server Token Certificate""" __mapper_args__ = { 'polymorphic_identity': CertificateType.STOKEN.value } @classmethod def from_crypto(cls, certificate: x509.Certificate): m = Certificate.from_crypto_type(certificate, CertificateType.STOKEN) return m class DEPAnchorCertificate(Certificate): """DEP Anchor Certificate""" __mapper_args__ = { 'polymorphic_identity': CertificateType.ANCHOR.value } class DEPSupervisionCertificate(Certificate): """DEP Supervision Certificate""" __mapper_args__ = { 'polymorphic_identity': CertificateType.SUPERVISION.value } class DEPAccount(db.Model): """DEP Account This table stores information about a single DEP account (aka one 'MDM Server' in the portal), and its current token. """ __tablename__ = 'dep_accounts' id = db.Column(db.Integer, primary_key=True) # certificate for PKI of server token certificate_id = db.Column(db.ForeignKey('certificates.id')) certificate = db.relationship('DEPServerTokenCertificate', backref='dep_configurations') # OAuth creds consumer_key = db.Column(db.String()) consumer_secret = db.Column(db.String()) access_token = db.Column(db.String()) access_secret = db.Column(db.String()) access_token_expiry = db.Column(db.DateTime()) token_updated_at = db.Column(db.DateTime()) # Current session token auth_session_token = db.Column(db.String()) # Information synchronised from the /account endpoint server_name = db.Column(db.String()) server_uuid = db.Column(GUID) admin_id = db.Column(db.String()) facilitator_id = db.Column(db.String()) org_name = db.Column(db.String()) org_email = db.Column(db.String()) org_phone = db.Column(db.String()) org_address = db.Column(db.String()) org_type = db.Column(db.Enum(DEPOrgType)) org_version = db.Column(db.Enum(DEPOrgVersion)) org_id = db.Column(db.String()) org_id_hash = db.Column(db.String()) url = db.Column(db.String()) # Hold the state of the in-progress fetch/sync in case the DEP thread dies cursor = db.Column(db.String()) more_to_follow = db.Column(db.Boolean()) fetched_until = db.Column(db.DateTime()) default_dep_profile_id = db.Column(db.Integer, db.ForeignKey('dep_profiles.id')) default_dep_profile = db.relationship('DEPProfile', backref='default_for_accounts', foreign_keys=[default_dep_profile_id]) dep_profile_anchor_certificates = db.Table( 'dep_profile_anchor_certificates', db.metadata, db.Column('dep_profile_id', db.Integer, db.ForeignKey('dep_profiles.id')), db.Column('certificate_id', db.Integer, db.ForeignKey('certificates.id')), ) dep_profile_supervision_certificates = db.Table( 'dep_profile_supervision_certificates', db.metadata, db.Column('dep_profile_id', db.Integer, db.ForeignKey('dep_profiles.id')), db.Column('certificate_id', db.Integer, db.ForeignKey('certificates.id')), ) class DEPProfile(db.Model): __tablename__ = 'dep_profiles' id = db.Column(db.Integer, primary_key=True) uuid = db.Column(GUID, index=True) # A profile is defined under a single DEP account dep_account_id = db.Column(db.Integer, db.ForeignKey('dep_accounts.id')) dep_account = db.relationship('DEPAccount', backref='dep_profiles', foreign_keys=[dep_account_id]) profile_name = db.Column(db.String, nullable=False) url = db.Column(db.String, nullable=False) allow_pairing = db.Column(db.Boolean, default=True) is_supervised = db.Column(db.Boolean, default=False) is_multi_user = db.Column(db.Boolean, default=False) is_mandatory = db.Column(db.Boolean, default=False) await_device_configured = db.Column(db.Boolean, default=False) is_mdm_removable = db.Column(db.Boolean, default=True) support_phone_number = db.Column(db.String) auto_advance_setup = db.Column(db.Boolean, default=False) support_email_address = db.Column(db.String) org_magic = db.Column(db.String) skip_setup_items = db.Column(SetOfEnumValues(SetupAssistantStep)) department = db.Column(db.String) # language = db.Column(db.String) # region = db.Column(db.String) # last_upload_at = db.Column(db.DateTime) anchor_certs = db.relationship( 'DEPAnchorCertificate', secondary=dep_profile_anchor_certificates, # back_populates='anchor_dep_profiles' ) supervising_host_certs = db.relationship( 'DEPSupervisionCertificate', secondary=dep_profile_supervision_certificates, # back_populates='supervising_dep_profiles' ) ================================================ FILE: commandment/dep/resources.py ================================================ from flask import url_for from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship from .schema import DEPProfileSchema, DEPAccountSchema from .models import db, DEPProfile, DEPAccount class DEPProfileList(ResourceList): schema = DEPProfileSchema data_layer = { 'session': db.session, 'model': DEPProfile, } def before_post(self, args, kwargs, data=None): """Generate an MDM enrollment URL if none was given.""" if 'url' not in data or data['url'] is None: data['url'] = url_for('dep_app.profile', _external=True) class DEPProfileDetail(ResourceDetail): schema = DEPProfileSchema data_layer = { 'session': db.session, 'model': DEPProfile, 'url_field': 'dep_profile_id' } class DEPProfileRelationship(ResourceRelationship): schema = DEPProfileSchema data_layer = { 'session': db.session, 'model': DEPProfile, 'url_field': 'dep_profile_id' } class DEPAccountList(ResourceList): schema = DEPAccountSchema data_layer = { 'session': db.session, 'model': DEPAccount, } class DEPAccountDetail(ResourceDetail): schema = DEPAccountSchema data_layer = { 'session': db.session, 'model': DEPAccount, 'url_field': 'dep_account_id' } ================================================ FILE: commandment/dep/schema.py ================================================ from flask import url_for from marshmallow_jsonapi.flask import Relationship, Schema from marshmallow_jsonapi import fields from marshmallow_enum import EnumField from . import SetupAssistantStep class DEPProfileSchema(Schema): """marshmallow schema for a DEP profile. See Also: - `/profile endpoint `_. - `Mobile Device Management Protocol Reference `_ "Define Profile" pg. 120 """ class Meta: type_ = 'dep_profiles' self_view = 'dep_app.dep_profile_detail' self_view_kwargs = {'dep_profile_id': ''} self_view_many = 'dep_app.dep_profiles_list' strict = True id = fields.Int(dump_only=True) uuid = fields.UUID(dump_only=True) """str: The Apple assigned UUID of this DEP Profile""" profile_name = fields.String(required=True) """str: A human-readable name for the profile.""" url = fields.Url(required=False) # Should be required """str: The URL of the MDM server.""" allow_pairing = fields.Boolean(default=True) """bool: If true, any device can pair with this device, supervision certs are not required.""" is_supervised = fields.Boolean(default=False) """bool: If true, the device must be supervised""" is_multi_user = fields.Boolean(default=False) """bool: If true, tells the device to configure for Shared iPad.""" is_mandatory = fields.Boolean(default=False) """bool: If true, the user may not skip applying the profile returned by the MDM server""" await_device_configured = fields.Boolean() """bool: If true, Setup Assistant does not continue until the MDM server sends DeviceConfigured.""" is_mdm_removable = fields.Boolean() """bool: If false, the MDM payload delivered by the configuration URL cannot be removed by the user via the user interface on the device""" support_phone_number = fields.String(allow_none=True) """str: A support phone number for the organization.""" auto_advance_setup = fields.Boolean() """bool: If set to true, the device will tell tvOS Setup Assistant to automatically advance though its screens.""" support_email_address = fields.String(allow_none=True) # No need to perform validation here """str: A support email address for the organization.""" org_magic = fields.String(allow_none=True) """str: A string that uniquely identifies various services that are managed by a single organization.""" anchor_certs = fields.List(fields.String()) """List[str]: Each string should contain a DER-encoded certificate converted to Base64 encoding. If provided, these certificates are used as trusted anchor certificates when evaluating the trust of the connection to the MDM server URL.""" supervising_host_certs = fields.List(fields.String()) """List[str]: Each string contains a DER-encoded certificate converted to Base64 encoding. If provided, the device will continue to pair with a host possessing one of these certificates even when allow_pairing is set to false""" skip_setup_items = fields.List(EnumField(SetupAssistantStep)) """Set[SetupAssistantStep]: A list of setup panes to skip""" department = fields.String(allow_none=True) """str: The user-defined department or location name.""" last_upload_at = fields.DateTime(dump_only=True) """datetime: The last time this profile was uploaded/synced to apple. null if it was never synced.""" devices = Relationship( related_view='api_app.devices_list', related_view_kwargs={'dep_profile_id': ''}, many=True, include_resource_linkage=True, schema='DeviceSchema', type_='devices' ) dep_account = Relationship( self_view='dep_app.dep_profile_dep_account', self_view_kwargs={'dep_profile_id': ''}, related_view='dep_app.dep_account_detail', related_view_kwargs={'dep_account_id': ''}, many=False, include_resource_linkage=True, schema='DEPAccountSchema', type_='dep_accounts' ) class DEPDeviceSchema(Schema): """The Device dictionary returned by the DEP Devices fetch endpoint. See Also: https://mdmenrollment.apple.com/server/devices """ serial_number = fields.String() model = fields.String() description = fields.String() color = fields.String() asset_tag = fields.String() profile_status = fields.String() profile_uuid = fields.UUID() profile_assign_time = fields.DateTime() profile_push_time = fields.DateTime() device_assigned_date = fields.DateTime() device_assigned_by = fields.Email() os = fields.String() device_family = fields.String() class DEPDeviceSyncSchema(Schema): """The device dictionary returned by the DEP Devices sync endpoint. This adds the operation type and date.""" op_type = fields.String() op_date = fields.DateTime() class DEPDeviceCursorSchema(Schema): """The response JSON literal of the device fetch endpoint""" cursor = fields.String() more_to_follow = fields.Boolean() devices = fields.Nested(DEPDeviceSchema, many=True) fetched_until = fields.DateTime() class MDMServiceURL(Schema): uri = fields.URL() http_method = fields.String() # limit class DEPAccountSchema(Schema): """DEP Account Details""" class Meta: type_ = 'dep_accounts' self_view = 'dep_app.dep_account_detail' self_view_kwargs = {'dep_account_id': ''} self_view_many = 'dep_app.dep_accounts_list' strict = True id = fields.Int(dump_only=True) # stoken consumer_key = fields.String() consumer_secret = fields.String(load_only=True) access_token = fields.String() access_secret = fields.String(load_only=True) access_token_expiry = fields.DateTime(dump_only=True) token_updated_at = fields.DateTime(dump_only=True) auth_session_token = fields.String(load_only=True) # org server_name = fields.String() server_uuid = fields.UUID() admin_id = fields.String() facilitator_id = fields.String() org_name = fields.String() org_email = fields.Email() org_phone = fields.String() org_address = fields.String() # urls = fields.Nested(MDMServiceURL, many=True) org_type = fields.String() org_version = fields.String() org_id = fields.String() org_id_hash = fields.String() url = fields.String() cursor = fields.String() more_to_follow = fields.Boolean() fetched_until = fields.DateTime() dep_profiles = Relationship( related_view='dep_app.dep_profile_detail', related_view_kwargs={'dep_profile_id': ''}, many=True, include_resource_linkage=True, schema='DEPProfileSchema', type_='dep_profiles' ) ================================================ FILE: commandment/dep/smime.py ================================================ from typing import Optional import email from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives.ciphers.algorithms import TripleDES, AES from cryptography.hazmat.primitives.ciphers import Cipher, modes from email.message import Message from base64 import b64decode from asn1crypto.cms import EnvelopedData, ContentInfo, RecipientInfo, IssuerAndSerialNumber, KeyTransRecipientInfo, \ RecipientIdentifier, EncryptionAlgorithm def decrypt(smime: bytes, key: rsa.RSAPrivateKey, serial: Optional[int] = None): """Decrypt an S/MIME message using the RSA Private Key given. The recipient can be hinted using the serial parameter, otherwise we assume single recipient = the given key. """ string_content = smime.decode('utf8') msg: Message = email.message_from_string(string_content) assert msg.get_content_type() == 'application/pkcs7-mime' assert msg.get_filename() == 'smime.p7m' assert msg.get('Content-Description') == 'S/MIME Encrypted Message' b64payload = msg.get_payload() payload = b64decode(b64payload) decrypted_data = decrypt_smime_content(payload, key) decrypted_msg: Message = email.message_from_bytes(decrypted_data) return decrypted_msg.get_payload() def decrypt_smime_content(payload: bytes, key: rsa.RSAPrivateKey) -> bytes: content_info = ContentInfo.load(payload) assert content_info['content_type'].native == 'enveloped_data' content: EnvelopedData = content_info['content'] matching_recipient = content['recipient_infos'][0] # Need to see if we hold the key for any valid recipient. # for recipient_info in content['recipient_infos']: # assert recipient_info.name == 'ktri' # Only support KeyTransRecipientInfo # ktri: KeyTransRecipientInfo = recipient_info.chosen # recipient_id: RecipientIdentifier = ktri['rid'] # assert recipient_id.name == 'issuer_and_serial_number' # Only support IssuerAndSerialNumber # matching_recipient = recipient_info encryption_algo = matching_recipient.chosen['key_encryption_algorithm'].native encrypted_key = matching_recipient.chosen['encrypted_key'].native assert encryption_algo['algorithm'] == 'rsa' # Get the content key plain_key = key.decrypt( encrypted_key, padding=padding.PKCS1v15(), ) # Now we have the plain key, we can decrypt the encrypted data encrypted_contentinfo = content['encrypted_content_info'] algorithm: EncryptionAlgorithm = encrypted_contentinfo['content_encryption_algorithm'] #: EncryptionAlgorithm encrypted_content_bytes = encrypted_contentinfo['encrypted_content'].native symkey = None if algorithm.encryption_cipher == 'aes': symkey = AES(plain_key) elif algorithm.encryption_cipher == 'tripledes': symkey = TripleDES(plain_key) else: print('Dont understand encryption cipher: ', algorithm.encryption_cipher) cipher = Cipher(symkey, modes.CBC(algorithm.encryption_iv), backend=default_backend()) decryptor = cipher.decryptor() decrypted_data = decryptor.update(encrypted_content_bytes) + decryptor.finalize() return decrypted_data ================================================ FILE: commandment/dep/threads.py ================================================ # -*- coding: utf-8 -*- """ Copyright (c) 2015 Jesse Peterson, 2018 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. Attributes: dep_thread (threading.Timer): dep_start (int): In seconds, time of first run dep_time (int): In seconds, time of subsequent runs Todo: * Currently we start this thread after the database context and configuration has already been. We envision a day when this runner runs standalone and thus we'll need to sort out separate configuration routines etc. """ import logging import threading import datetime import dateutil.parser from flask import Flask # Necessary because SQLAlchemy isn't threadsafe by default from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker from commandment.dep.apple_schema import AppleDEPProfileSchema from commandment.dep.errors import DEPServiceError from commandment.models import db, Device from commandment.dep.models import DEPAccount, DEPProfile from commandment.dep.dep import DEP from commandment.dep import DEPOrgType, DEPOrgVersion, DEPOperationType import sqlalchemy.orm.exc import sqlalchemy.exc dep_thread = None dep_start = 5 dep_time = 90 logger = logging.getLogger('dep thread') def start(app: Flask): """Start the StartUp thread""" logger.info('DEP thread will run in 5 seconds') dep_thread = threading.Timer(dep_start, dep_thread_callback, [app]) dep_thread.daemon = True dep_thread.start() def stop(): """Stop the runner thread""" logger.info('DEP thread will stop') global dep_thread if dep_thread is threading.Timer: dep_thread.cancel() def dep_sync_organization(app: Flask, dep: DEP): """Synchronise information from the DEP service to the local database. """ with app.app_context(): try: app.logger.debug('Querying for DEP Account information from the database') dep_account: DEPAccount = db.session.query(DEPAccount).one() # Refresh organisation information if there is none if dep_account.server_name is None or dep_account.server_uuid is None: app.logger.debug('Refreshing information about organization from the DEP service') account = dep.account() if account is not None: dep_account.server_uuid = account.get('server_uuid', None) dep_account.server_name = account.get('server_name', None) dep_account.facilitator_id = account.get('facilitator_id', None) dep_account.admin_id = account.get('admin_id', None) dep_account.org_name = account.get('org_name', None) dep_account.org_email = account.get('org_email', None) dep_account.org_phone = account.get('org_phone', None) dep_account.org_address = account.get('org_address', None) dep_account.org_id = account.get('org_id', None) dep_account.org_id_hash = account.get('org_id_hash', None) if 'org_type' in account: dep_account.org_type = DEPOrgType(account['org_type']) if 'org_version' in account: dep_account.org_version = DEPOrgVersion(account['org_version']) db.session.commit() app.logger.info('Successfully fetched DEP Organization: %s', dep_account.org_name) else: app.logger.warn('Failed to fetch DEP Organization') else: app.logger.info('DEP Organization already fetched: %s', dep_account.org_name) except sqlalchemy.orm.exc.NoResultFound: app.logger.info('Not attempting to fetch DEP account information. No DEP account is configured.') def dep_fetch_devices(app: Flask, dep: DEP, dep_account_id: int): """Perform fetch or sync of devices. TODO: If default DEP Profile is nominated, it is queued for assignment here. But may want to check `profile_status` to see whether only devices with the `removed` status are considered unassigned. See: https://docs.sqlalchemy.org/en/latest/orm/contextual.html """ thread_session = db.create_scoped_session() dep_account: DEPAccount = thread_session.query(DEPAccount).one() if dep_account.cursor is not None: app.logger.info('Syncing using previous cursor: %s', dep_account.cursor) else: app.logger.info('No DEP cursor found, performing a full fetch') # TODO: if fetched_until is quite recent, there's no reason to fetch again for device_page in dep.devices(dep_account.cursor): print(device_page) for device in device_page['devices']: if 'op_type' in device: # its a sync, not a fetch optype = DEPOperationType(device['op_type']) if optype == DEPOperationType.Added: app.logger.debug('DEP Added: %s', device['serial_number']) elif optype == DEPOperationType.Modified: app.logger.debug('DEP Modified: %s', device['serial_number']) elif optype == DEPOperationType.Deleted: app.logger.debug('DEP Deleted: %s', device['serial_number']) else: app.logger.error('DEP op_type not recognised (%s), skipping', device['op_type']) continue else: pass try: d: Device = thread_session.query(Device).filter(Device.serial_number == device['serial_number']).one() d.description = device['description'] d.model = device['model'] d.os = device['os'] d.device_family = device['device_family'] d.color = device['color'] d.profile_status = device['profile_status'] if device['profile_status'] != 'empty': d.profile_uuid = device.get('profile_uuid', None) # Only exists in DEP Sync not Fetch? d.profile_assign_time = dateutil.parser.parse(device['profile_assign_time']) d.device_assigned_by = device['device_assigned_by'] d.device_assigned_date = dateutil.parser.parse(device['device_assigned_date']) d.is_dep = True except sqlalchemy.orm.exc.NoResultFound: app.logger.debug('No existing device record for serial: %s', device['serial_number']) if device['profile_status'] != 'empty': device['profile_assign_time'] = dateutil.parser.parse(device['profile_assign_time']) device['device_assigned_date'] = dateutil.parser.parse(device['device_assigned_date']) if 'op_type' in device: del device['op_type'] del device['op_date'] del device['profile_assign_time'] del device['device_assigned_date'] d = Device(**device) d.is_dep = True thread_session.add(d) except sqlalchemy.exc.StatementError as e: app.logger.error('Got a statement error trying to insert a DEP device: {}'.format(e)) app.logger.debug('Last DEP Cursor was: %s', device_page['cursor']) dep_account.cursor = device_page.get('cursor', None) dep_account.more_to_follow = device_page.get('more_to_follow', None) dep_account.fetched_until = dateutil.parser.parse(device_page['fetched_until']) thread_session.commit() def dep_define_profiles(app: Flask, dep: DEP): """Create DEP profiles which have not yet been synced with Apple.""" thread_session = db.create_scoped_session() dep_profiles_pending = thread_session.query(DEPProfile).filter( DEPProfile.uuid.is_(None), DEPProfile.last_upload_at.is_(None)).all() app.logger.debug('There are %d pending DEP profile(s) to upload', len(dep_profiles_pending)) for dep_profile in dep_profiles_pending: try: schema = AppleDEPProfileSchema() dep_profile_apple = schema.dump(dep_profile) print(dep_profile_apple.data) response = dep.define_profile(dep_profile_apple.data) assert 'profile_uuid' in response dep_profile.uuid = response['profile_uuid'] dep_profile.last_uploaded_at = datetime.datetime.now() except Exception as e: app.logger.error('Got an exception trying to define a profile: {}'.format(e)) thread_session.commit() def dep_thread_callback(app: Flask): """Runner thread main procedure Todo: * Catch everything so we don't interrupt the thread (and it never reschedules) * Certificate expiration warnings/emails """ threadlocals = threading.local() with app.app_context(): try: dep_account: DEPAccount = db.session.query(DEPAccount).one() app.logger.info('Checking DEP state') dep = DEP( consumer_key=dep_account.consumer_key, consumer_secret=dep_account.consumer_secret, access_token=dep_account.access_token, access_secret=dep_account.access_secret, ) dep_sync_organization(app, dep) try: dep_fetch_devices(app, dep, dep_account.id) except DEPServiceError as dse: print(dse) if dse.text == 'EXPIRED_CURSOR': app.logger.info("Sync cursor had expired, clearing for next run...") dep_account.cursor = None db.session.add(dep_account) db.session.commit() dep_define_profiles(app, dep) except sqlalchemy.orm.exc.NoResultFound: app.logger.info('Not attempting a DEP sync, no account configured.') ================================================ FILE: commandment/deprecated/models.py ================================================ from enum import Enum from sqlalchemy import Column, Integer, String, ForeignKey, Table, Text, Boolean, DateTime, Enum as DBEnum, text, \ BigInteger, and_, or_, LargeBinary from sqlalchemy.orm import relationship from commandment.profiles.ad import ADMountStyle, ADNamespace, ADPacketSignPolicy, ADPacketEncryptPolicy, \ ADCertificateAcquisitionMechanism from commandment.profiles.email import EmailAuthenticationType, EmailAccountType from commandment.profiles.vpn import VPNType from commandment.profiles.wifi import WIFIEncryptionType, WIFIProxyType from ..dbtypes import GUID, JSONEncodedDict from uuid import uuid4 from .cert import KeyUsage from . import PayloadScope from ..models import db payload_dependencies = Table('payload_dependencies', db.metadata, Column('payload_uuid', GUID, ForeignKey('payloads.uuid')), Column('depends_on_payload_uuid', GUID, ForeignKey('payloads.uuid')), ) class Payload(db.Model): __tablename__ = 'payloads' id = Column(Integer, primary_key=True) type = Column(String, index=True, nullable=False) version = Column(Integer) identifier = Column(String) uuid = Column(GUID, index=True, default=uuid4(), nullable=False) display_name = Column(String) description = Column(Text) organization = Column(String) # Dependencies should be tracked in cases where the payload refers to another required payload. # eg. a reference to certificate payload in an 802.1x configuration. # depends_on = relationship("Payload", # secondary=payload_dependencies, # backref="dependents") __mapper_args__ = { 'polymorphic_identity': 'payload', 'polymorphic_on': type, } class ADCertPayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) certificate_description = Column(String) # Description was reserved from the base Payload table allow_all_apps_access = Column(Boolean) cert_server = Column(String, nullable=False) cert_template = Column(String, nullable=False, default='User') acquisition_mechanism = Column(DBEnum(ADCertificateAcquisitionMechanism), default=ADCertificateAcquisitionMechanism.RPC) certificate_authority = Column(String, nullable=False) renewal_time_interval = Column(Integer) identity_description = Column(String, nullable=True) key_is_extractable = Column(Boolean, default=False) prompt_for_credentials = Column(Boolean) keysize = Column(Integer, default=2048) __mapper_args__ = { 'polymorphic_identity': 'com.apple.ADCertificate.managed', } class ADPayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) host_name = Column(String, nullable=False) user_name = Column(String, nullable=False) password = Column(String, nullable=False) ad_organizational_unit = Column(String, nullable=False) ad_mount_style = Column(DBEnum(ADMountStyle), nullable=False) ad_default_user_shell = Column(String) ad_map_uid_attribute = Column(String) ad_map_gid_attribute = Column(String) ad_map_ggid_attribute = Column(String) ad_preferred_dc_server = Column(String) ad_domain_admin_group_list = Column(String) # JSON ad_namespace = Column(DBEnum(ADNamespace), default=ADNamespace.Domain) ad_packet_sign = Column(DBEnum(ADPacketSignPolicy), default=ADPacketSignPolicy.Allow) ad_packet_encrypt = Column(DBEnum(ADPacketEncryptPolicy), default=ADPacketEncryptPolicy.Allow) ad_restrict_ddns = Column(String) # JSON ad_trust_change_pass_interval = Column(Integer) # We will take null to mean that the flag is not set ad_create_mobile_account_at_login = Column(Boolean) ad_warn_user_before_creating_ma = Column(Boolean) ad_force_home_local = Column(Boolean) __mapper_args__ = { 'polymorphic_identity': 'com.apple.DirectoryService.managed', } # class EAPClientConfiguration(db.Model): # __table__ = 'eap_client_configurations' # # id = Column(Integer, primary_key=True) # accept_eap_types # payload_id = Column(Integer, ForeignKey('payloads.id')) #user_name = Column(String) #user_password = Column(String) #one_time_password = Column(Boolean) # payload_certificate_anchor_uuid # tls_trusted_server_names #tls_allow_trust_exceptions = Column(Boolean) #ttls_inner_authentication = Column(String) #outer_identity = Column(String) #system_mode_credentials_source = Column(String) #eap_fast_use_pac = Column(Boolean) #eap_fast_provision_pac = Column(Boolean) #eap_fast_provision_pac_anonymously = Column(Boolean) #eap_sim_number_of_rands = Column(Integer) class WIFIPayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) ssid_str = Column(String, nullable=False) hidden_network = Column(Boolean, default=False) auto_join = Column(Boolean, nullable=True) encryption_type = Column(DBEnum(WIFIEncryptionType), default=WIFIEncryptionType.Any) is_hotspot = Column(Boolean) domain_name = Column(String) service_provider_roaming_enabled = Column(Boolean) roaming_consortium_ois = Column(String) # JSON nai_realm_names = Column(String) # JSON mccs_and_mncs = Column(String) # JSON displayed_operator_name = Column(String) captive_bypass = Column(Boolean) # If WEP, WPA or Any password = Column(String) #eap_client_configuration_id = Column(Integer, ForeignKey('eap_client_configurations.id')) tls_certificate_required = Column(Boolean) payload_certificate_uuid = Column(GUID) # Manual Proxy proxy_type = Column(String) proxy_server = Column(String) proxy_server_port = Column(Integer) proxy_username = Column(String) proxy_password = Column(String) proxy_pac_url = Column(String) proxy_pac_fallback_allowed = Column(Boolean) __mapper_args__ = { 'polymorphic_identity': 'com.apple.wifi.managed', } class VPNPayload(Payload): """VPN Payload""" id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) user_defined_name = Column(String) override_primary = Column(Boolean, default=False) vpn_type = Column(DBEnum(VPNType), nullable=False) vpn_sub_type = Column(String) provider_bundle_identifier = Column(String) on_demand_enabled = Column(Integer) __mapper_args__ = { 'polymorphic_identity': 'com.apple.vpn.managed', } class EmailPayload(Payload): """E-mail Payload""" id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) email_account_description = Column(String) email_account_name = Column(String) email_account_type = Column(DBEnum(EmailAccountType), nullable=False) email_address = Column(String) incoming_auth = Column(DBEnum(EmailAuthenticationType), nullable=False) incoming_host = Column(String, nullable=False) incoming_port = Column(Integer) incoming_use_ssl = Column(Boolean, default=False) incoming_username = Column(String, nullable=False) incoming_password = Column(String) outgoing_password = Column(String) outgoing_incoming_same = Column(Boolean) outgoing_auth = Column(DBEnum(EmailAuthenticationType), nullable=False) __mapper_args__ = { 'polymorphic_identity': 'com.apple.mail.managed' } class CertificatePayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) certificate_file_name = Column(String) payload_content = Column(LargeBinary) password = Column(String) __mapper_args__ = { 'polymorphic_identity': 'certificate' } class PEMCertificatePayload(CertificatePayload): __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.pem' } class DERCertificatePayload(CertificatePayload): __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.pkcs1' } class PKCS12CertificatePayload(CertificatePayload): __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.pkcs12' } class PasswordPolicyPayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) allow_simple = Column(Boolean) force_pin = Column(Boolean) max_failed_attempts = Column(Integer) max_inactivity = Column(Integer) max_pin_age_in_days = Column(Integer) min_complex_chars = Column(Integer) min_length = Column(Integer) require_alphanumeric = Column(Boolean) pin_history = Column(Integer) max_grace_period = Column(Integer) allow_fingerprint_modification = Column(Boolean) __mapper_args__ = { 'polymorphic_identity': 'com.apple.mobiledevice.passwordpolicy' } class EnergySaverPayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) destroy_fv_key_on_standby = Column(Boolean) sleep_disabled = Column(Boolean) desktop_acpower_profilenumber = Column(Integer) portable_acpower_profilenumber = Column(Integer) portable_battery_profilenumber = Column(Integer) desktop_acpower = Column(JSONEncodedDict) portable_acpower = Column(JSONEncodedDict) portable_battery = Column(JSONEncodedDict) desktop_schedule = Column(JSONEncodedDict) __mapper_args__ = { 'polymorphic_identity': 'com.apple.MCX' } class MDMPayload(Payload): id = Column(Integer, ForeignKey('payloads.id'), primary_key=True) identity_certificate_uuid = Column(GUID, nullable=False) topic = Column(String, nullable=False) server_url = Column(String, nullable=False) server_capabilities = Column(String) sign_message = Column(Boolean) check_in_url = Column(String) check_out_when_removed = Column(Boolean) access_rights = Column(Integer) use_development_apns = Column(Boolean) __mapper_args__ = { 'polymorphic_identity': 'com.apple.mdm' } profile_payloads = Table('profile_payloads', db.metadata, Column('profile_id', Integer, ForeignKey('profiles.id')), Column('payload_id', Integer, ForeignKey('payloads.id'))) class Profile(db.Model): """Top level profile. In Commandment, multiple profiles may have an association with the same payload. See Also: - `Configuration Profile Keys `_. Attributes: """ __tablename__ = 'profiles' id = Column(Integer, primary_key=True) description = Column(Text) display_name = Column(String) expiration_date = Column(DateTime) # Only for old style OTA identifier = Column(String, nullable=False) organization = Column(String) uuid = Column(GUID, index=True, default=uuid4()) removal_disallowed = Column(Boolean) version = Column(Integer, default=1) scope = Column(DBEnum(PayloadScope), default=PayloadScope.User.value) removal_date = Column(DateTime) duration_until_removal = Column(BigInteger) consent_en = Column(Text) is_encrypted = Column(Boolean, default=False) payloads = relationship('Payload', secondary=profile_payloads, backref='profiles') ================================================ FILE: commandment/deprecated/schema.py ================================================ # @register_payload_schema('com.apple.ADCertificate.managed') # class ADCertificatePayload(Payload): # Description = fields.Str(attribute='description') # CertServer = fields.Str(attribute='cert_server') # CertTemplate = fields.Str(attribute='cert_template') # CertificateAuthority = fields.Str(attribute='certificate_authority') # CertificateAcquisitionMechanism = EnumField(ADCertificateAcquisitionMechanism, attribute='acquisition_mechanism') # CertificateRenewalTimeInterval = fields.Int(attribute='renewal_time_interval') # Keysize = fields.Int(attribute='keysize') # UserName = fields.Str(attribute='username') # Password = fields.Str(attribute='password') # PromptForCredentials = fields.Bool(attribute='prompt_for_credentials') # AllowAllAppsAccess = fields.Bool(attribute='allow_all_apps_access') # KeyIsExtractable = fields.Bool(attribute='key_is_extractable') # # @post_load # def make_payload(self, data) -> models.ADCertPayload: # return models.ADCertPayload(**data) class QoSMarkingPolicy(Schema): # QoSMarkingWhitelistedAppIdentifiers = fields.Array QoSMarkingAppleAudioVideoCalls = fields.Boolean() QoSMarkingEnabled = fields.Boolean() class EAPClientConfiguration(Schema): """EAPOLClient configuration properties. I have added several more unpublished properties from the EAP8012X source available via opensource.apple.com. """ UserName = fields.String() UserPassword = fields.String() UserPasswordKeychainItemID = fields.String() # Unconfirmed OneTimeUserPassword = fields.Boolean() # Unconfirmed OneTimePassword = fields.Boolean() # AcceptEAPTypes = fields.Integer() # InnerAcceptEAPTypes # Unconfirmed # PayloadCertificateAnchorUUID = fields.UUID() # TLSTrustedServerNames TLSAllowTrustExceptions = fields.Boolean() TLSCertificateIsRequired = fields.Boolean() """- TLS-based authentication protocol requires a certificate to authenticate - the default value is TRUE for EAP-TLS, FALSE otherwise - allows for two-factor authentication (certificate + name/password) when set to TRUE for EAP-TTLS, PEAP, EAP-FAST - allows for zero-factor authentication when set to FALSE for EAP-TLS""" # TLSTrustedCertificates array Unconfirmed # TLSSaveTrustExceptions # TLSTrustExceptionsDomain # exceptions domain values: # WirelessSSID # ProfileID # NetworkInterfaceName # TLSTrustExceptionsID # SaveCredentialsOnSuccessfulAuthentication # TLSVerifyServerCertificate # TLSEnableSessionResumption # TLSUserTrustProceedCertificateChain # SystemModeUseOpenDirectoryCredentials # SystemModeOpenDirectoryNodeName NewPassword = fields.String() OuterIdentity = fields.String() """OuterIdentity: Applies to TTLS, PEAP, EAP-FAST.""" TLSIdentityHandle = fields.String() """TLSIdentityHandle: TLS only""" SystemModeCredentialsSource = fields.String() TTLSInnerAuthentication = EnumField(TTLSInnerAuthentication) # EAP-FAST EAPFASTUsePAC = fields.Boolean() EAPFASTProvisionPAC = fields.Boolean() EAPFASTProvisionPACAnonymously = fields.Boolean() EAPSIMNumberOfRANDs = fields.Integer() # InnerEAPType # InnerEAPTypeName # TLSServerCertificateChain # To Check: In EAP8012X source # SystemModeUseOpenDirectoryCredentials # SystemModeOpenDirectoryNodeName # # # @register_payload_schema('com.apple.wifi.managed') # class WIFIPayload(Payload): # SSID_STR = fields.Str(attribute='ssid_str') # HIDDEN_NETWORK = fields.Boolean(attribute='hidden_network') # AutoJoin = fields.Boolean(attribute='auto_join', allow_none=True) # EncryptionType = EnumField(WIFIEncryptionType, attribute='encryption_type') # IsHotspot = fields.Boolean(attribute='is_hotspot', allow_none=True) # DomainName = fields.String(attribute='domain_name', allow_none=True) # ServiceProviderRoamingEnabled = fields.Boolean(attribute='service_provider_roaming_enabled', allow_none=True) # # RoamingConsortiumOIs = fields.Nested(fields.String(), many=True) # # NAIRealmNames # # MCCAndMNCs # DisplayedOperatorName = fields.String(attribute='displayed_operator_name', allow_none=True) # ProxyType = fields.String(attribute='proxy_type', allow_none=True) # CaptiveBypass = fields.Boolean(attribute='captive_bypass', allow_none=True) # QoSMarkingPolicy = fields.Nested(QoSMarkingPolicy(), allow_none=True) # # Password = fields.String(attribute='password', allow_none=True) # PayloadCertificateUUID = fields.UUID(attribute='payload_certificate_uuid', allow_none=True) # EAPClientConfiguration = fields.Nested(EAPClientConfiguration(), allow_none=True) # # @post_load # def make_payload(self, data: dict) -> models.WIFIPayload: # payload = models.WIFIPayload(**data) # return payload class EnergySaverSettings(Schema): AutomaticRestartOnPowerLoss = fields.Integer(load_from='Automatic Restart On Power Loss') # Pseudo boolean 0/1 DiskSleepTimerBoolean = fields.Boolean(load_from='Disk Sleep Timer-boolean') DiskSleepTimer = fields.Integer(load_from='Display Sleep Timer') SystemSleepTimer = fields.Integer(load_from='System Sleep Timer') WakeOnLAN = fields.Integer(load_from='Wake On LAN') # Pseudo boolean 0/1 class EnergySaverPowerSchedule(Schema): eventtype = EnumField(ScheduledPowerEventType) time = fields.Integer(validate=lambda n: 0 <= n <= 2400) weekdays = fields.Integer() class EnergySaverSchedules(Schema): RepeatingPowerOn = fields.Nested(EnergySaverPowerSchedule) RepeatingPowerOff = fields.Nested(EnergySaverPowerSchedule) @register_payload_schema('com.apple.MCX') class EnergySaverPayload(Payload): DestroyFVKeyOnStandby = fields.Boolean(attribute='destroy_fv_key_on_standby') SleepDisabled = fields.Boolean(attribute='sleep_disabled') DesktopACPowerProfileNumber = fields.Integer(load_from='com.apple.EnergySaver.desktop.ACPower-ProfileNumber') PortableACPowerProfileNumber = fields.Integer(load_from='com.apple.EnergySaver.portable.ACPower-ProfileNumber') PortableBatteryProfileNumber = fields.Integer(load_from='com.apple.EnergySaver.portable.BatteryPower-ProfileNumber') DesktopACPower = fields.Nested(EnergySaverSettings, load_from='com.apple.EnergySaver.desktop.ACPower') PortableACPower = fields.Nested(EnergySaverSettings, load_from='com.apple.EnergySaver.portable.ACPower') PortableBatteryPower = fields.Nested(EnergySaverSettings, load_from='com.apple.EnergySaver.portable.BatteryPower') Schedule = fields.Nested(EnergySaverSchedules, load_from='com.apple.EnergySaver.desktop.Schedule') ================================================ FILE: commandment/enroll/__init__.py ================================================ from enum import Enum class DeviceAttributes(Enum): """This enumeration describes all of the device attributes available to OTA profile enrolment. """ UDID = 'UDID' VERSION = 'VERSION' PRODUCT = 'PRODUCT' DEVICE_NAME = 'DEVICE_NAME' SERIAL = 'SERIAL' MODEL = 'MODEL' MAC_ADDRESS_EN0 = 'MAC_ADDRESS_EN0' MEID = 'MEID' IMEI = 'IMEI' ICCID = 'ICCID' COMPROMISED = 'COMPROMISED' DeviceID = 'DeviceID' # SPIROM = 'SPIROM' # MLB = 'MLB' AllDeviceAttributes = { DeviceAttributes.UDID.value, DeviceAttributes.VERSION.value, DeviceAttributes.PRODUCT.value, DeviceAttributes.DEVICE_NAME.value, DeviceAttributes.SERIAL.value, DeviceAttributes.MODEL.value, # DeviceAttributes.MAC_ADDRESS_EN0.value, DeviceAttributes.MEID.value, DeviceAttributes.IMEI.value, DeviceAttributes.ICCID.value, DeviceAttributes.COMPROMISED.value, DeviceAttributes.DeviceID.value, # DeviceAttributes.SPIROM.value, # DeviceAttributes.MLB.value, } ================================================ FILE: commandment/enroll/app.py ================================================ """ The enroll blueprint covers all enrolment scenarios such as: - Over-the-Air profile delivery - Direct enrolment (delivering a com.apple.mdm payload) """ from uuid import uuid4 import plistlib from flask import current_app, render_template, abort, Blueprint, make_response, url_for, request, g import os from commandment.enroll import AllDeviceAttributes from commandment.enroll.profiles import ca_trust_payload_from_configuration, scep_payload_from_configuration, \ identity_payload from commandment.profiles.models import MDMPayload, Profile, PEMCertificatePayload, DERCertificatePayload, SCEPPayload from commandment.profiles import PROFILE_CONTENT_TYPE, plist_schema as profile_schema, PayloadScope from commandment.models import db, Organization, SCEPConfig from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from commandment.plistutil.nonewriter import dumps as dumps_none from commandment.enroll.util import generate_enroll_profile from commandment.cms.decorators import verify_cms_signers from commandment.pki.ca import get_ca enroll_app = Blueprint('enroll_app', __name__) def base64_to_pem(crypto_type, b64_text, width=76): lines = '' for pos in range(0, len(b64_text), width): lines += b64_text[pos:pos + width] + '\n' return '-----BEGIN %s-----\n%s-----END %s-----' % (crypto_type, lines, crypto_type) @enroll_app.route('/trust.mobileconfig', methods=['GET']) def trust_mobileconfig(): """Generate a trust profile, if one is required. :resheader Content-Type: application/x-apple-aspen-config :statuscode 200: :statuscode 500: The system has not been configured, so we can't produce anything. """ try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') profile = Profile( identifier=org.payload_prefix + '.trust', uuid=uuid4(), display_name='Commandment Trust Profile', description='Allows your device to trust the MDM server', organization=org.name, version=1, scope=PayloadScope.System, ) if 'CA_CERTIFICATE' in current_app.config: # If you specified a CA certificate, we assume it isn't a CA trusted by Apple devices. ca_payload = ca_trust_payload_from_configuration() profile.payloads.append(ca_payload) if 'SSL_CERTIFICATE' in current_app.config: basepath = os.path.dirname(__file__) certpath = os.path.join(basepath, current_app.config['SSL_CERTIFICATE']) with open(certpath, 'rb') as fd: pem_payload = PEMCertificatePayload( uuid=uuid4(), identifier=org.payload_prefix + '.ssl', payload_content=fd.read(), display_name='Web Server Certificate', description='Required for your device to trust the server', type='com.apple.security.pkcs1', version=1 ) profile.payloads.append(pem_payload) schema = profile_schema.ProfileSchema() result = schema.dump(profile) plist_data = dumps_none(result.data, skipkeys=True) return plist_data, 200, {'Content-Type': PROFILE_CONTENT_TYPE, 'Content-Disposition': 'attachment; filename="trust.mobileconfig"'} @enroll_app.route('/profile', methods=['GET', 'POST']) def enroll(): """Generate an enrollment profile.""" ca = get_ca() key, csr = ca.create_device_csr('device-identity') device_certificate = ca.sign(csr) pkcs12_payload = identity_payload(key, device_certificate, 'sekret') profile = generate_enroll_profile(pkcs12_payload) schema = profile_schema.ProfileSchema() result = schema.dump(profile) plist_data = dumps_none(result.data, skipkeys=True) return plist_data, 200, {'Content-Type': PROFILE_CONTENT_TYPE} @enroll_app.route('/ota') def ota_enroll(): """Over-The-Air Profile Delivery Phase 1.5. This endpoint represents the delivery of the `Profile Service` profile that should be delivered AFTER the user has successfully authenticated. """ try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') profile = { 'PayloadType': 'Profile Service', 'PayloadIdentifier': org.payload_prefix + '.ota.enroll', 'PayloadUUID': 'FACC45E7-CB0E-4F8B-AA3E-E22DC161E25E', #str(uuid4()), 'PayloadVersion': 1, 'PayloadDisplayName': 'Commandment Profile Service', 'PayloadDescription': 'Enrolls your device with Commandment', 'PayloadOrganization': org.name, 'PayloadContent': { 'URL': 'https://{}:{}/enroll/ota_authenticate'.format( current_app.config['PUBLIC_HOSTNAME'], current_app.config['PORT'] ), 'DeviceAttributes': list(AllDeviceAttributes), 'Challenge': 'TODO', }, } plist_data = dumps_none(profile) return plist_data, 200, {'Content-Type': PROFILE_CONTENT_TYPE} @enroll_app.route('/ota_authenticate', methods=['POST']) @verify_cms_signers def ota_authenticate(): """Over-The-Air Profile Delivery Phase 3 and 4. This endpoint represents the OTA Phase 3 and 4, "/profile" endpoint as specified in apples document "Over-The-Air Profile Delivery". There are two types of requests made here: - The first request is signed by the iPhone Device CA and contains the challenge in the `Profile Service` payload, we respond with the SCEP detail. - The second request is signed by the issued SCEP certificate. We should respond with an enrollment profile. It also contains the same device attributes sent in the previous step, but this time they are authenticated by our SCEP CA. Examples: Signed plist given in the first request:: { 'CHALLENGE': '', 'IMEI': 'empty if macOS', 'MEID': 'empty if macOS', 'NotOnConsole': False, 'PRODUCT': 'MacPro6,1', 'SERIAL': 'C020000000000', 'UDID': '00000000-0000-0000-0000-000000000000', 'UserID': '00000000-0000-0000-0000-000000000000', 'UserLongName': 'Joe User', 'UserShortName': 'juser', 'VERSION': '16F73' } See Also: - `Over-the-Air Profile Delivery and Configuration `_. """ signed_data = g.signed_data # TODO: This should Validate to iPhone Device CA but we can't because: # http://www.openradar.me/31423312 device_attributes = plistlib.loads(signed_data) current_app.logger.debug(device_attributes) try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') # TODO: Behold, the stupidest thing ever just to get this working, theres no way this should be prod: # Phase 4 does not send a challenge but phase 3 does if 'CHALLENGE' in device_attributes: # Reply SCEP profile = Profile( identifier=org.payload_prefix + '.ota.phase3', uuid=uuid4(), display_name='Commandment OTA SCEP Enrollment', description='Retrieves a SCEP Certificate to complete OTA Enrollment', organization=org.name, version=1, scope=PayloadScope.System, ) scep_payload = scep_payload_from_configuration() profile.payloads.append(scep_payload) else: profile = generate_enroll_profile() schema = profile_schema.ProfileSchema() result = schema.dump(profile) plist_data = dumps_none(result.data, skipkeys=True) return plist_data, 200, {'Content-Type': PROFILE_CONTENT_TYPE} ================================================ FILE: commandment/enroll/profiles.py ================================================ import os.path from typing import Optional from uuid import uuid4 from flask import abort, current_app, url_for from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from commandment.profiles.certificates import KeyUsage from commandment.profiles.models import SCEPPayload, PEMCertificatePayload, PKCS12CertificatePayload from commandment.models import db, Organization, SCEPConfig from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from commandment.pki.openssl import create_pkcs12 def scep_payload_from_configuration() -> SCEPPayload: """Generate a SCEP Payload based upon the commandment system configuration. Returns: SCEPPayload: The created payload based upon current configuration. """ # try: # org = db.session.query(Organization).one() # except NoResultFound: # abort(500, 'No organization is configured, cannot generate enrollment profile.') # except MultipleResultsFound: # abort(500, 'Multiple organizations, backup your database and start again') try: scep_config = db.session.query(SCEPConfig).one() scep_payload = SCEPPayload( uuid=uuid4(), identifier='com.github.cmdmnt.commandment.scep', url=scep_config.url, name='', subject=[['CN', '%HardwareUUID%']], challenge=scep_config.challenge, key_size=scep_config.key_size, key_type='RSA', key_usage=scep_config.key_usage, display_name='Commandment SCEP Enroll Payload', description='Requests a certificate to identify your device to commandment', retries=scep_config.retries, retry_delay=scep_config.retry_delay, version=1 ) except NoResultFound: scep_payload = SCEPPayload( uuid=uuid4(), identifier='com.github.cmdmnt.commandment.scep', url=url_for('scep_app.scep', _external=True), name='COMMANDMENT-SCEP', subject=[['CN', '%HardwareUUID%']], challenge=current_app.config.get('SCEPY_CHALLENGE', None), key_size=2048, key_type='RSA', key_usage=KeyUsage.All, display_name='Commandment SCEP Enroll Payload', description='Requests a certificate to identify your device to commandment', retries=3, retry_delay=10, version=1 ) except MultipleResultsFound: return abort(500, 'Multiple SCEP configs, this should never happen.') return scep_payload def ca_trust_payload_from_configuration() -> PEMCertificatePayload: """Create a CA payload with the PEM representation of the Certificate Authority used by this instance. You need to check whether the app config contains 'CA_CERTIFICATE' before invoking this. """ try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') with open(current_app.config['CA_CERTIFICATE'], 'rb') as fd: pem_data = fd.read() pem_payload = PEMCertificatePayload( uuid=uuid4(), identifier=org.payload_prefix + '.ca', payload_content=pem_data, display_name='Certificate Authority', description='Required for your device to trust the server', type='com.apple.security.root', version=1 ) return pem_payload def ssl_trust_payload_from_configuration() -> PEMCertificatePayload: """Generate a PEM certificate payload in order to trust this host. """ try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') basepath = os.path.dirname(__file__) certpath = os.path.join(basepath, current_app.config['SSL_CERTIFICATE']) with open(certpath, 'rb') as fd: pem_payload = PEMCertificatePayload( uuid=uuid4(), identifier=org.payload_prefix + '.ssl', payload_content=fd.read(), display_name='Web Server Certificate', description='Required for your device to trust the server', type='com.apple.security.pkcs1', version=1 ) return pem_payload def identity_payload(private_key: rsa.RSAPrivateKeyWithSerialization, certificate: x509.Certificate, passphrase: Optional[str] = None) -> PKCS12CertificatePayload: """Generate a PKCS#12 certificate payload for device identity.""" try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') pkcs12_data = create_pkcs12(private_key, certificate, passphrase) pkcs12_payload = PKCS12CertificatePayload( uuid=uuid4(), certificate_file_name='device_identity.p12', identifier=org.payload_prefix + '.identity', display_name='Device Identity Certificate', description='Required to identify your device to the MDM', type='com.apple.security.pkcs12', password=passphrase, payload_content=pkcs12_data, version=1 ) return pkcs12_payload ================================================ FILE: commandment/enroll/util.py ================================================ import os.path from typing import Optional from flask import abort, current_app from commandment.enroll.profiles import scep_payload_from_configuration, ca_trust_payload_from_configuration, \ ssl_trust_payload_from_configuration from commandment.models import db, Organization from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.x509.name import NameOID from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from commandment.profiles import PayloadScope from commandment.profiles.models import Profile, MDMPayload, PKCS12CertificatePayload from uuid import uuid4 def generate_enroll_profile(pkcs12_payload: Optional[PKCS12CertificatePayload] = None) -> Profile: """Generate an enrollment profile. If the user specified a CA certificate, we assume that it won't be trusted by default, so it is included in the enrollment profile. If the user specified an SSL certificate, we assume that it won't be trusted by default. You need to have an organization configured to generate organization information in the profile, and to establish the payload prefix. The enrollment profile reserves the use of UUID: 1355300-1111-1111-1111-868EC47093C3 Args: pkcs12_payload (Optional[PKCS12CertificatePayload): A PKCS#12 Payload if we are supplying device identity without using SCEP """ try: org = db.session.query(Organization).one() except NoResultFound: abort(500, 'No organization is configured, cannot generate enrollment profile.') except MultipleResultsFound: abort(500, 'Multiple organizations, backup your database and start again') push_certificate_path = os.path.join(os.path.dirname(current_app.root_path), current_app.config['PUSH_CERTIFICATE']) if os.path.exists(push_certificate_path): push_certificate_basename, ext = os.path.splitext(push_certificate_path) if ext.lower() == '.p12': # push service will have re-exported the PKCS#12 container push_certificate_path = push_certificate_basename + '.crt' with open(push_certificate_path, 'rb') as fd: push_certificate = x509.load_pem_x509_certificate(fd.read(), backend=default_backend()) else: abort(500, 'No push certificate available at: {}'.format(push_certificate_path)) if not org.payload_prefix: abort(500, 'MDM configuration has no profile prefix') profile = Profile( identifier=org.payload_prefix + '.enroll', uuid=uuid4(), display_name='Commandment Enrollment Profile', description='Enrolls your device for Mobile Device Management', organization=org.name, version=1, scope=PayloadScope.System, ) if 'CA_CERTIFICATE' in current_app.config: # If you specified a CA certificate, we assume it isn't a CA trusted by Apple devices. ca_payload = ca_trust_payload_from_configuration() profile.payloads.append(ca_payload) # Include Self Signed Certificate if necessary # TODO: Check that cert is self signed. if 'SSL_CERTIFICATE' in current_app.config: ssl_payload = ssl_trust_payload_from_configuration() profile.payloads.append(ssl_payload) if pkcs12_payload is None: scep_payload = scep_payload_from_configuration() profile.payloads.append(scep_payload) cert_uuid = scep_payload.uuid else: profile.payloads.append(pkcs12_payload) cert_uuid = pkcs12_payload.uuid from commandment.mdm import AccessRights push_topics = push_certificate.subject.get_attributes_for_oid(NameOID.USER_ID) if len(push_topics) != 1: abort(500, 'Unexpected missing or invalid push topic in Push Certificate') push_topic = push_topics[0].value mdm_payload = MDMPayload( uuid=uuid4(), identifier=org.payload_prefix + '.mdm', identity_certificate_uuid=cert_uuid, topic=push_topic, server_url='https://{}:{}/mdm'.format(current_app.config['PUBLIC_HOSTNAME'], current_app.config['PORT']), access_rights=AccessRights.All.value, check_in_url='https://{}:{}/checkin'.format(current_app.config['PUBLIC_HOSTNAME'], current_app.config['PORT']), sign_message=True, check_out_when_removed=True, display_name='Device Configuration and Management', server_capabilities=['com.apple.mdm.per-user-connections'], description='Enrolls your device with the MDM server', version=1 ) profile.payloads.append(mdm_payload) return profile ================================================ FILE: commandment/errors.py ================================================ from typing import Optional, Dict from flask import jsonify class JSONAPIError(Exception): def __init__(self, title: str, status: int = 500, code: Optional[str] = None, detail: Optional[str] = None, source: Optional[Dict[str, str]] = None, meta=None, id=None): self.title = title self.status = status self.code = code self.detail = detail self.source = source self.meta = meta self.id = id def to_dict(self) -> Dict[str, any]: res = {'errors': []} error = {'title': self.title, 'status': self.status} if self.code is not None: error['code'] = self.code if self.detail is not None: error['detail'] = self.detail res['errors'].append(error) return res ================================================ FILE: commandment/inventory/__init__.py ================================================ # These applications should not be reported on because they are part of the operating system. DEFAULT_BUNDLE_ID_BLACKLIST = [ 'com.apple.PubSubAgent', 'com.apple.IMServicePlugInAgent', 'com.apple.print.PrinterProxy', 'com.apple.speech.synthesis.SpeechSynthesisServer', 'com.apple.FontRegistryUIAgent', 'com.apple.AddressBook.sync', 'com.apple.AddressBookSourceSync', 'com.apple.AddressBook.abd', 'com.apple.ABAssistantService', 'com.apple.check_afp', 'com.apple.screencapturetb', 'com.apple.rcd', 'com.apple.loginwindow', 'com.apple.CloudKit.ShareBear', 'com.apple.cloudphotosd', 'com.apple.ZoomWindow.app', 'com.apple.wifi.WiFiAgent', 'com.apple.weather', 'com.apple.UserNotificationCenter', 'com.apple.VoiceOver', 'com.apple.UnmountAssistantAgent', 'com.apple.UniversalAccessControl', 'com.apple.Ticket-Viewer', 'com.apple.ThermalTrap', 'com.apple.systemuiserver', 'com.apple.systemevents', 'com.apple.stocks', 'com.apple.SoftwareUpdate', 'com.apple.SocialPushAgent', 'com.apple.SecurityFixer', 'com.apple.ScriptMonitor', 'com.apple.ReportPanic', 'com.apple.RemoteDesktopAgent', 'com.apple.pluginIM.pluginIMRegistrator', 'com.apple.RapportUIAgent', 'com.apple.ProblemReporter', 'com.apple.PowerChime', 'com.apple.Pass-Viewer', 'com.apple.PIPAgent', 'com.apple.OSDUIHelper', 'com.apple.ODSAgent', 'com.apple.OBEXAgent', 'com.apple.notificationcenterui', 'com.apple.NowPlayingTouchUI', 'com.apple.NetworkDiagnostics', 'com.apple.NetAuthAgent', 'com.apple.MemorySlotUtility', 'com.apple.MRT', 'com.apple.locationmenu', 'com.apple.Language-Chooser', 'com.apple.security.Keychain-Circle-Notification', 'com.apple.KeyboardSetupAssistant', 'com.apple.JavaWebStart', 'com.apple.JarLauncher', 'com.apple.installer', 'com.apple.Installer-Progress', 'com.apple.PackageKit.Install-in-Progress', 'com.apple.dt.CommandLineTools.installondemand', 'com.apple.imageevents', 'com.apple.helpviewer', 'com.apple.gamecenter', 'com.apple.FolderActionsDispatcher', 'com.apple.FirmwareUpdateHelper', 'com.apple.finder.Open-iCloudDrive', 'com.apple.finder.Open-Network', 'com.apple.finder.Open-Computer', 'com.apple.finder.Open-AllMyFiles', 'com.apple.finder.Open-AirDrop', 'com.apple.ExpansionSlotUtility', 'com.apple.EscrowSecurityAlert', 'com.apple.DwellControl', 'com.apple.dock', 'com.apple.DiskImageMounter', 'com.apple.DiscHelper', 'com.apple.databaseevents', 'com.apple.coreservices.uiagent', 'com.apple.CoreLocationAgent', 'com.apple.controlstrip', 'com.apple.CaptiveNetworkAssistant', 'com.apple.CalendarFileHandler', 'com.apple.BluetoothUIServer', 'com.apple.BluetoothSetupAssistant', 'com.apple.AutomatorRunner', 'com.apple.wifi.diagnostics', 'com.apple.SystemImageUtility', 'com.apple.StorageManagementLauncher', 'com.apple.ScreenSharing', 'com.apple.RAIDUtility', 'com.apple.NetworkUtility', 'com.apple.FolderActionsSetup', 'com.apple.appleseed.FeedbackAssistant', 'com.apple.archiveutility', 'com.apple.AboutThisMacLauncher', 'com.apple.AppleScriptUtility', 'com.apple.AppleGraphicsWarning', 'com.apple.AppleFileServer', 'com.apple.appstore.AppDownloadLauncher', 'com.apple.AirPortBaseStationAgent', 'com.apple.AirPlayUIAgent', 'com.apple.AddressBook.UrlForwarder', 'com.apple.AVB-Audio-Configuration', 'com.apple.ColorSyncCalibrator', 'com.apple.SyncServices.AppleMobileSync', 'com.apple.SyncServices.AppleMobileDeviceHelper', 'com.apple.WebKit.PluginHost', 'com.apple.WebProcess', 'com.apple.WebKit.PluginProcess', 'com.apple.WebKit.NetworkProcess', 'com.apple.WebKit.DatabaseProcess', 'com.apple.mrt.uiagent', 'com.apple.WebKit.PluginHost', 'com.apple.syncserver', 'com.apple.ScreenSaver.Engine', 'com.apple.QuickLookDaemon32', 'com.apple.VoiceOverQuickstart', 'com.apple.CharacterPaletteIM', 'com.apple.DirectoryUtility', 'com.apple.SetupAssistant', 'com.apple.PhotoLibraryMigrationUtility', 'com.apple.NetworkSetupAssistant', 'com.apple.ManagedClient', 'com.apple.finder', 'com.apple.CertificateAssistant', 'com.apple.print.add', 'com.adobe.dynamiclinkmediaserver', 'com.apple.Family', 'com.apple.familycontrols.useragent', 'com.apple.frameworks.diskimages.diuiagent', 'com.apple.FollowUpUI', 'com.apple.CCE.CIMFindInputCode', 'com.apple.cmfsyncagent', 'com.apple.storeuid', 'com.apple.lateragent', 'com.apple.bird', 'com.apple.Calibration-Assistant', 'com.apple.AOSPushRelay', 'com.apple.AOSHeartbeat', 'com.apple.AOSAlertManager', 'com.apple.iCloudUserNotificationsd', 'com.apple.TrackpadIM-Container', 'com.apple.VIM-Container', 'com.apple.inputmethod.Tamil', 'com.apple.TCIM-Container', 'com.apple.inputmethod.AssistiveControl', 'com.apple.SCIM-Container', 'com.apple.PAH-Container', 'com.apple.inputmethod.PluginIM', 'com.apple.KIM-Container', 'com.apple.KeyboardViewer', 'com.apple.JapaneseIM-Container', 'com.apple.ink.inkserver', 'com.apple.HIM-Container', 'com.apple.inputmethod.EmojiFunctionRowItem-Container', 'com.apple.inputmethod.ironwood', 'com.apple.inputmethod.Ainu', 'com.apple.50onPaletteIM', 'com.apple.AutoImporter', 'com.apple.VirtualScanner', 'com.apple.Type8Camera', 'com.apple.Type5Camera', 'com.apple.Type4Camera', 'com.apple.PTPCamera', 'com.apple.MassStorageCamera', 'com.apple.AirScanScanner', 'com.apple.BuildWebPage', 'com.apple.WebKit.PluginHost', 'com.apple.cmfsyncagent', 'com.apple.imavagent', 'com.apple.idsfoundation.IDSRemoteURLConnectionAgent', 'com.apple.ids.IDSCredentialsAgent', 'com.apple.identityservicesd', 'com.apple.imautomatichistorydeletionagent', 'com.apple.imagent', 'com.apple.imtransferservices.IMTransferAgent', 'com.apple.ImageCaptureService', 'com.apple.syncservices.syncuid', 'com.apple.speech.SpeechDataInstallerd', 'com.apple.eap8021x.eaptlstrust', 'com.apple.AskPermissionUI', 'com.apple.MakePDF', 'com.apple.QuickLookDaemon', 'com.apple.quicklook.ui.helper', 'com.apple.notificationcenter.widgetsimulator', 'com.apple.Spotlight', 'com.apple.Siri', 'com.apple.InstallAssistant.HighSierra', 'com.apple.ManagedClient', 'com.apple.InstallAssistant.HighSierra', 'com.apple.FindMyMacMessenger', 'com.apple.idsfoundation.IDSRemoteURLConnectionAgent', 'com.apple.identityservicesd', 'com.apple.imavagent', 'com.apple.imagent', 'com.apple.imautomatichistorydeletionagent', 'com.apple.imtransferservices.IMTransferAgent', 'com.apple.soagent', 'com.apple.SyncServices.AppleMobileSync', 'com.apple.SyncServices.AppleMobileDeviceHelper', 'com.apple.nbagent', 'com.apple.ScreenReaderUIServer', 'com.apple.speech.SpeechRecognitionServer', 'com.apple.STMFramework.UIHelper', 'com.apple.syncservices.ConflictResolver', 'com.apple.accessibility.universalAccessAuthWarn', 'com.apple.accessibility.universalAccessHUD', 'com.apple.accessibility.DFRHUD', 'com.apple.coreservices.UASharedPasteboardProgressUI', 'com.apple.ChineseTextConverterService', 'com.apple.SummaryService', ] ================================================ FILE: commandment/inventory/api.py ================================================ from flask import Blueprint, send_file from flask_rest_jsonapi import Api import io from commandment.inventory.models import db, InstalledCertificate from commandment.inventory.resources import InstalledApplicationsList, InstalledApplicationDetail, \ InstalledCertificatesList, InstalledCertificateDetail, InstalledProfilesList, InstalledProfileDetail, \ AvailableOSUpdateList, AvailableOSUpdateDetail from commandment.api.app_jsonapi import api api_app = Blueprint('inventory_api_app', __name__) # api = Api(blueprint=api_app) # InstalledApplications api.route(InstalledApplicationsList, 'installed_applications_list', '/v1/installed_applications', '/v1/devices//installed_applications') api.route(InstalledApplicationDetail, 'installed_application_detail', '/v1/installed_applications/') # InstalledCertificates api.route(InstalledCertificatesList, 'installed_certificates_list', '/v1/installed_certificates', '/v1/devices//installed_certificates') api.route(InstalledCertificateDetail, 'installed_certificate_detail', '/v1/installed_certificates/') api.route(InstalledProfilesList, 'installed_profiles_list', '/v1/installed_profiles', '/v1/devices//installed_profiles') api.route(InstalledProfileDetail, 'installed_profile_detail', '/v1/installed_profiles/') # Available OS Updates api.route(AvailableOSUpdateList, 'available_os_updates_list', '/v1/available_os_updates', '/v1/devices//available_os_updates') api.route(AvailableOSUpdateDetail, 'available_os_update_detail', '/v1/available_os_updates/') @api_app.route('/v1/installed_certificates//download') def download_installed_certificate(installed_certificate_id: int): """Download an installed certificate asx a DER encoded X.509 certificate. The file name will be a stripped version of the X.509 Common Name, with a .crt extension. :reqheader Accept: application/x-x509-ca-cert :resheader Content-Type: application/x-x509-ca-cert :statuscode 200: OK :statuscode 404: Not found :statuscode 400: Can't produce requested encoding """ c = db.session.query(InstalledCertificate).filter(InstalledCertificate.id == installed_certificate_id).one() bio = io.BytesIO(c.der_data) prefix = c.x509_cn.strip('/\:') if c.x509_cn is not None else 'certificate' return send_file(bio, 'application/x-x509-ca-cert', True, '{}.crt'.format(prefix)) ================================================ FILE: commandment/inventory/models.py ================================================ from sqlalchemy.ext.mutable import MutableList from commandment.models import db from commandment.dbtypes import GUID, JSONEncodedDict class InstalledApplication(db.Model): """This model represents a single application that was returned as part of an ``InstalledApplicationList`` query. It is impossible to create a composite key to uniquely identify each row, therefore every time the device reports back we need to wipe all rows associated with a single device. The reason why a composite key won't work here is that macOS will often report the binary name and no identifier, version, or size (and sometimes iOS can do the inverse of that). :table: installed_applications See Also: - `InstalledApplicationList Command `_. """ __tablename__ = 'installed_applications' id = db.Column(db.Integer, primary_key=True) """id (int): Identifier""" device_udid = db.Column(db.String(40), index=True, nullable=False) """device_udid (GUID): Unique device identifier""" device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """device_id (int): Parent relationship ID of the device""" device = db.relationship('Device', backref='installed_applications') """device (db.relationship): SQLAlchemy relationship to the device.""" # Many of these can be empty, so there is no valid composite key bundle_identifier = db.Column(db.String, index=True) """bundle_identifier (str): The com.xxx.yyy bundle identifier for the application. May be empty.""" version = db.Column(db.String, index=True) """version (str): The long version for the application. May be empty.""" short_version = db.Column(db.String) """short_version (str): The short version for the application. May be empty.""" name = db.Column(db.String) """name (str): The application name""" bundle_size = db.Column(db.BigInteger) """bundle_size (int): The application size""" dynamic_size = db.Column(db.BigInteger) """dynamic_size (int): The dynamic data size (for iOS containers).""" is_validated = db.Column(db.Boolean) """is_validated (bool):""" external_version_identifier = db.Column(db.BigInteger, index=True) """external_version_identifier (int): The application’s external version ID. It can be used for comparison in the iTunes Search API to decide if the application needs to be updated.""" adhoc_codesigned = db.Column(db.Boolean) appstore_vendable = db.Column(db.Boolean) beta_app = db.Column(db.Boolean) device_based_vpp = db.Column(db.Boolean) has_update_available = db.Column(db.Boolean) installing = db.Column(db.Boolean) class InstalledCertificate(db.Model): """This model represents a single installed certificate on an enrolled device as returned by the ``CertificateList`` query. The response will usually include both certificates managed by profiles and certificates that were installed outside of a profile. :table: installed_certificates See Also: - `CertificateList Command `_. """ __tablename__ = 'installed_certificates' id = db.Column(db.Integer, primary_key=True) """(int): Installed Certificate ID""" device_udid = db.Column(db.String(40), index=True, nullable=False) """(GUID): Unique Device Identifier""" device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """(int): Device foreign key ID.""" device = db.relationship('Device', backref='installed_certificates') """(db.relationship): Device relationship""" x509_cn = db.Column(db.String) """(str): The X.509 Common Name of the certificate.""" is_identity = db.Column(db.Boolean) """(bool): Is the certificate an identity certificate?""" der_data = db.Column(db.LargeBinary, nullable=False) """(bytes): The DER encoded certificate data.""" fingerprint_sha256 = db.Column(db.String(64), nullable=False, index=True) """(str): SHA-256 fingerprint of the certificate.""" class InstalledProfile(db.Model): """This model represents a single installed profile on an enrolled device as returned by the ``ProfileList`` query. The response does not contain the entire contents of the profiles installed therefore the UUIDs returned are joined against our profiles table to ascertain whether profiles have been installed or not. :table: installed_profiles See Also: - `ProfileList Command `_. """ __tablename__ = 'installed_profiles' id = db.Column(db.Integer, primary_key=True) """(int): Installed Profile ID""" device_udid = db.Column(db.String(40), index=True, nullable=False) """(GUID): Unique Device Identifier""" device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """(int): Device foreign key ID.""" device = db.relationship('Device', backref='installed_profiles') """(db.relationship): Device relationship""" has_removal_password = db.Column(db.Boolean) """(bool): Does the installed profile have a removal password?""" is_encrypted = db.Column(db.Boolean) """(bool): Is the installed profile encrypted?""" is_managed = db.Column(db.Boolean) """(bool): Is the installed profile managed? which means it has been sourced from the MDM.""" payload_description = db.Column(db.String) """(str): Payload description (value of PayloadDescription)""" payload_display_name = db.Column(db.String) """(str): Payload display name""" payload_identifier = db.Column(db.String) payload_organization = db.Column(db.String) payload_removal_disallowed = db.Column(db.Boolean) payload_uuid = db.Column(GUID, index=True) # SignerCertificates class InstalledPayload(db.Model): __tablename__ = 'installed_payloads' id = db.Column(db.Integer, primary_key=True) """(int): Installed Payload ID""" profile_id = db.Column(db.ForeignKey('installed_profiles.id'), nullable=False) """(int): InstalledProfile foreign key ID.""" profile = db.relationship('InstalledProfile', backref='payload_content') device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """(int): Device foreign key ID.""" device = db.relationship('Device', backref='installed_payloads') """(db.relationship): Device relationship""" """(db.relationship): InstalledProfile relationship""" description = db.Column(db.String) """(str): Payload description (value of PayloadDescription)""" display_name = db.Column(db.String) """(str): Payload display name""" identifier = db.Column(db.String) organization = db.Column(db.String) payload_type = db.Column(db.String) uuid = db.Column(GUID()) class AvailableOSUpdate(db.Model): """This table holds the results of `AvailableOSUpdates` commands.""" __tablename__ = 'available_os_updates' id = db.Column(db.Integer, primary_key=True) device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """(int): Device foreign key ID.""" device = db.relationship('Device', backref='available_os_updates') """(db.relationship): Device relationship""" # Common to all platforms allows_install_later = db.Column(db.Boolean) human_readable_name = db.Column(db.String) is_critical = db.Column(db.Boolean) product_key = db.Column(db.String) restart_required = db.Column(db.Boolean) version = db.Column(db.String) # macOS Only app_identifiers_to_close = db.Column(MutableList.as_mutable(JSONEncodedDict)) human_readable_name_locale = db.Column(db.String) is_config_data_update = db.Column(db.Boolean) """(bool): This update is a config data update eg. for XProtect or Gatekeeper. These arent normally shown""" is_firmware_update = db.Column(db.Boolean) metadata_url = db.Column(db.String) # iOS Only product_name = db.Column(db.String) build = db.Column(db.String) download_size = db.Column(db.BigInteger) install_size = db.Column(db.BigInteger) ================================================ FILE: commandment/inventory/resources.py ================================================ from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship from flask_rest_jsonapi.exceptions import ObjectNotFound from sqlalchemy.orm.exc import NoResultFound from commandment.inventory.schema import InstalledApplicationSchema, InstalledCertificateSchema, \ InstalledProfileSchema, AvailableOSUpdateSchema from commandment.inventory.models import db, InstalledApplication, InstalledCertificate, InstalledProfile, \ AvailableOSUpdate from commandment.models import Device class InstalledApplicationsList(ResourceList): def query(self, view_kwargs): query_ = self.session.query(InstalledApplication) if view_kwargs.get('device_id') is not None: try: self.session.query(Device).filter_by(id=view_kwargs['device_id']).one() except NoResultFound: raise ObjectNotFound({'parameter': 'device_id'}, "Device: {} not found".format(view_kwargs['device_id'])) else: query_ = query_.join(Device).filter(Device.id == view_kwargs['device_id']) return query_ schema = InstalledApplicationSchema data_layer = { 'session': db.session, 'model': InstalledApplication, 'methods': {'query': query} } class InstalledApplicationDetail(ResourceDetail): schema = InstalledApplicationSchema data_layer = { 'session': db.session, 'model': InstalledApplication } class InstalledCertificatesList(ResourceList): def query(self, view_kwargs): query_ = self.session.query(InstalledCertificate) if view_kwargs.get('device_id') is not None: try: self.session.query(Device).filter_by(id=view_kwargs['device_id']).one() except NoResultFound: raise ObjectNotFound({'parameter': 'device_id'}, "Device: {} not found".format(view_kwargs['device_id'])) else: query_ = query_.join(Device).filter(Device.id == view_kwargs['device_id']) return query_ schema = InstalledCertificateSchema view_kwargs = True data_layer = { 'session': db.session, 'model': InstalledCertificate, 'methods': {'query': query} } class InstalledCertificateDetail(ResourceDetail): schema = InstalledCertificateSchema data_layer = { 'session': db.session, 'model': InstalledCertificate, 'url_field': 'installed_certificate_id' } # class PayloadsList(ResourceList): # schema = PayloadSchema # data_layer = { # 'session': db.session, # 'model': Payload # } # # # class PayloadDetail(ResourceDetail): # schema = PayloadSchema # data_layer = { # 'session': db.session, # 'model': Payload # } class InstalledProfilesList(ResourceList): def query(self, view_kwargs): query_ = self.session.query(InstalledProfile) if view_kwargs.get('device_id') is not None: try: self.session.query(Device).filter_by(id=view_kwargs['device_id']).one() except NoResultFound: raise ObjectNotFound({'parameter': 'device_id'}, "Device: {} not found".format(view_kwargs['device_id'])) else: query_ = query_.join(Device).filter(Device.id == view_kwargs['device_id']) return query_ schema = InstalledProfileSchema view_kwargs = True data_layer = { 'session': db.session, 'model': InstalledProfile, 'methods': {'query': query} } class InstalledProfileDetail(ResourceDetail): schema = InstalledProfileSchema data_layer = { 'session': db.session, 'model': InstalledProfile } class AvailableOSUpdateList(ResourceList): def query(self, view_kwargs): query_ = self.session.query(AvailableOSUpdate) if view_kwargs.get('device_id') is not None: try: self.session.query(Device).filter_by(id=view_kwargs['device_id']).one() except NoResultFound: raise ObjectNotFound({'parameter': 'device_id'}, "Device: {} not found".format(view_kwargs['device_id'])) else: query_ = query_.join(Device).filter(Device.id == view_kwargs['device_id']) return query_ schema = AvailableOSUpdateSchema view_kwargs = True data_layer = { 'session': db.session, 'model': AvailableOSUpdate, 'methods': {'query': query} } class AvailableOSUpdateDetail(ResourceDetail): schema = AvailableOSUpdateSchema data_layer = { 'session': db.session, 'model': AvailableOSUpdate, 'url_field': 'available_os_update_id' } ================================================ FILE: commandment/inventory/schema.py ================================================ from marshmallow_jsonapi import fields from marshmallow_jsonapi.flask import Relationship, Schema class InstalledProfileSchema(Schema): class Meta: type_ = 'installed_profiles' self_view = 'api_app.installed_profile_detail' self_view_kwargs = {'installed_profile_id': ''} self_view_many = 'api_app.installed_profiles_list' id = fields.Int(dump_only=True) has_removal_password = fields.Bool() is_encrypted = fields.Bool() payload_description = fields.Str() payload_display_name = fields.Str() payload_identifier = fields.Str() payload_organization = fields.Str() payload_removal_disallowed = fields.Boolean() payload_uuid = fields.UUID() # signer_certificates = fields.Nested() device = Relationship( related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, type_='devices', ) class InstalledCertificateSchema(Schema): class Meta: type_ = 'installed_certificates' self_view = 'api_app.installed_certificate_detail' self_view_kwargs = {'installed_certificate_id': ''} self_view_many = 'api_app.installed_certificates_list' strict = True id = fields.Int(dump_only=True) x509_cn = fields.Str(dump_only=True) is_identity = fields.Boolean(dump_only=True) fingerprint_sha256 = fields.String(dump_only=True) device = Relationship( related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, type_='devices', ) class InstalledApplicationSchema(Schema): class Meta: type_ = 'installed_applications' self_view = 'api_app.installed_application_detail' self_view_kwargs = {'installed_application_id': ''} self_view_many = 'api_app.installed_applications_list' strict = True id = fields.Int(dump_only=True) bundle_identifier = fields.Str(dump_only=True) name = fields.Str(dump_only=True) short_version = fields.Str(dump_only=True) version = fields.Str(dump_only=True) bundle_size = fields.Int(dump_only=True) dynamic_size = fields.Int(dump_only=True) is_validated = fields.Bool(dump_only=True) device = Relationship( related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, type_='devices', ) class AvailableOSUpdateSchema(Schema): class Meta: type_ = 'available_os_updates' self_view = 'api_app.available_os_update_detail' self_view_kwargs = {'available_os_update_id': ''} self_view_many = 'api_app.available_os_updates_list' id = fields.Int(dump_only=True) allows_install_later = fields.Boolean() # app_identifiers_to_close = fields.List(fields.String()) human_readable_name = fields.Str() human_readable_name_locale = fields.Str() is_config_data_update = fields.Boolean() is_critical = fields.Boolean() is_firmware_update = fields.Boolean() metadata_url = fields.URL() product_key = fields.String() restart_required = fields.Boolean() version = fields.String() device = Relationship( related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, type_='devices', ) ================================================ FILE: commandment/mdm/__init__.py ================================================ from typing import Set from enum import IntFlag, auto, Enum, IntEnum class CommandType(Enum): ProfileList = 'ProfileList' InstallProfile = 'InstallProfile' RemoveProfile = 'RemoveProfile' ProvisioningProfileList = 'ProvisioningProfileList' InstallProvisioningProfile = 'InstallProvisioningProfile' RemoveProvisioningProfile = 'RemoveProvisioningProfile' CertificateList = 'CertificateList' InstalledApplicationList = 'InstalledApplicationList' DeviceInformation = 'DeviceInformation' SecurityInfo = 'SecurityInfo' DeviceLock = 'DeviceLock' RestartDevice = 'RestartDevice' ShutDownDevice = 'ShutDownDevice' ClearPasscode = 'ClearPasscode' EraseDevice = 'EraseDevice' RequestMirroring = 'RequestMirroring' StopMirroring = 'StopMirroring' Restrictions = 'Restrictions' ClearRestrictionsPasscode = 'ClearRestrictionsPasscode' # Shared iPad UserList = 'UserList' UnlockUserAccount = 'UnlockUserAccount' LogOutUser = 'LogOutUser' DeleteUser = 'DeleteUser' EnableLostMode = 'EnableLostMode' PlayLostModeSound = 'PlayLostModeSound' DisableLostMode = 'DisableLostMode' DeviceLocation = 'DeviceLocation' # Managed Applications InstallApplication = 'InstallApplication' ApplyRedemptionCode = 'ApplyRedemptionCode' ManagedApplicationList = 'ManageApplicationList' RemoveApplication = 'RemoveApplication' InviteToProgram = 'InviteToProgram' ValidateApplications = 'ValidateApplications' # Books InstallMedia = 'InstallMedia' ManagedMediaList = 'ManagedMediaList' RemoveMedia = 'RemoveMedia' Settings = 'Settings' ManagedApplicationConfiguration = 'ManagedApplicationConfiguration' ApplicationConfiguration = 'ApplicationConfiguration' ManagedApplicationAttributes = 'ManagedApplicationAttributes' ManagedApplicationFeedback = 'ManagedApplicationFeedback' AccountConfiguration = 'AccountConfiguration' SetFirmwarePassword = 'SetFirmwarePassword' VerifyFirmwarePassword = 'VerifyFirmwarePassword' SetAutoAdminPassword = 'SetAutoAdminPassword' DeviceConfigured = 'DeviceConfigured' ScheduleOSUpdate = 'ScheduleOSUpdate' ScheduleOSUpdateScan = 'ScheduleOSUpdateScan' AvailableOSUpdates = 'AvailableOSUpdates' OSUpdateStatus = 'OSUpdateStatus' ActiveNSExtensions = 'ActiveNSExtensions' NSExtensionMappings = 'NSExtensionMappings' RotateFileVaultKey = 'RotateFileVaultKey' class Platform(Enum): """The platform of the managed device.""" Unknown = 'Unknown' # Not enough information macOS = 'macOS' iOS = 'iOS' tvOS = 'tvOS' class AccessRights(IntFlag): """The MDM protocol defines a bitmask for granting permissions to an MDM to perform certain operations. This enumeration contains all of those access rights flags. """ def _generate_next_value_(name, start, count, last_values): return 2 ** count ProfileInspection = auto() ProfileInstallRemove = auto() DeviceLockPasscodeRemoval = auto() DeviceErase = auto() QueryDeviceInformation = auto() QueryNetworkInformation = auto() ProvProfileInspection = auto() ProvProfileInstallRemove = auto() InstalledApplications = auto() RestrictionQueries = auto() SecurityQueries = auto() ChangeSettings = auto() ManageApps = auto() All = ProfileInspection | ProfileInstallRemove | DeviceLockPasscodeRemoval | DeviceErase | QueryDeviceInformation \ | QueryNetworkInformation | ProvProfileInspection | ProvProfileInstallRemove | InstalledApplications \ | RestrictionQueries | SecurityQueries | ChangeSettings | ManageApps AccessRightsSet = Set[AccessRights] class CommandStatus(Enum): """CommandStatus describes all the possible states of a command in the device command queue. The following statuses are based upon the return status of the MDM client: - Acknowledged - Error - CommandFormatError - NotNow Additionally, there are statuses to explain the lifecycle of the command before and after the MDM client processes them: - Queued: The command was newly created and not yet sent to the device. - Sent: The command has been sent to the device, but no response has come back yet. - Expired: The command was never acknowledged, or the device was removed. """ # MDM Client Statuses Idle = 'Idle' Acknowledged = 'Acknowledged' Error = 'Error' CommandFormatError = 'CommandFormatError' NotNow = 'NotNow' # Commandment Lifecycle Statuses Queued = 'Queued' Sent = 'Sent' Expired = 'Expired' class SettingsItem(Enum): """A list of possible values for Managed Settings items. See Also: - `Managed Settings `_._ """ VoiceRoaming = 'VoiceRoaming' PersonalHotspot = 'PersonalHotspot' Wallpaper = 'Wallpaper' DataRoaming = 'DataRoaming' ApplicationAttributes = 'ApplicationAttributes' DeviceName = 'DeviceName' HostName = 'HostName' MDMOptions = 'MDMOptions' PasscodeLockGracePeriod = 'PasscodeLockGracePeriod' MaximumResidentUsers = 'MaximumResidentUsers' class WallpaperLocation(IntEnum): """A list of possible values for the Wallpaper `where` setting. Determines where the given wallpaper will be used. """ LockScreen = 1 HomeScreen = 2 Both = 3 ================================================ FILE: commandment/mdm/api.py ================================================ from flask import Blueprint from commandment.mdm.resources import CommandsList, CommandDetail from commandment.api.app_jsonapi import api api_app = Blueprint('inventory_api_app', __name__) # Commands api.route(CommandsList, 'commands_list', '/v1/commands', '/v1/devices//commands') api.route(CommandDetail, 'command_detail', '/v1/commands/') ================================================ FILE: commandment/mdm/app.py ================================================ """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ from flask import Blueprint, make_response, abort, jsonify, g, current_app from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from commandment.mdm import CommandStatus from commandment.mdm.commands import Command from commandment.decorators import parse_plist_input_data from commandment.cms.decorators import verify_mdm_signature from commandment.mdm.util import queue_full_inventory from commandment.models import DeviceUser from commandment.pki.models import DeviceIdentityCertificate from commandment.mdm.routers import CommandRouter, PlistRouter from commandment.utils import plistify import plistlib import ssl from commandment.apns.push import push_to_device from datetime import datetime from commandment.signals import device_enrolled mdm_app = Blueprint('mdm_app', __name__) plr = PlistRouter(mdm_app, '/checkin') command_router = CommandRouter(mdm_app) from .handlers import * @plr.route('MessageType', 'Authenticate') def authenticate(plist_data): """Handle the `Authenticate` message. This will be the first message sent to the MDM upon enrollment, but you cannot consider the device to be enrolled at this stage. """ current_app.logger.debug('Authenticate (UDID %s)', plist_data.get('UDID', None)) # TODO: check to make sure device == UDID == cert, etc. try: device = db.session.query(Device).filter(Device.udid == plist_data['UDID']).one() except NoResultFound: # no device found, let's make a new one! device = Device() db.session.add(device) device.udid = plist_data['UDID'] device.build_version = plist_data.get('BuildVersion') device.device_name = plist_data.get('DeviceName') device.model = plist_data.get('Model') device.model_name = plist_data.get('ModelName') device.os_version = plist_data.get('OSVersion') device.product_name = plist_data.get('ProductName') device.serial_number = plist_data.get('SerialNumber') device.topic = plist_data.get('Topic') # iOS only device.imei = plist_data.get('IMEI', None) device.meid = plist_data.get('MEID', None) device.last_seen = datetime.now() # Authenticate message is not enough to be enrolled device.is_enrolled = False # remove the previous device token (in the case of a re-enrollment) to # tell the difference between a periodic TokenUpdate and the first # post-enrollment TokenUpdate device.token = None # TODO: Check supplied identity against identities we actually issued db.session.commit() return 'OK' @plr.route('MessageType', 'TokenUpdate') @verify_mdm_signature def token_update(plist_data): current_app.logger.debug('TokenUpdate (UDID %s)', plist_data.get('UDID', None)) try: device = db.session.query(Device).filter(Device.udid == plist_data['UDID']).one() except NoResultFound: current_app.logger.debug( 'Device (UDID: %s) will be unenrolled because the database has no record of this device.', plist_data['UDID']) return abort(410) # Ask the device to unenroll itself because we dont seem to have any records. # TODO: a TokenUpdate can either be for a device or a user (per OS X extensions) if 'UserID' in plist_data: device_user = DeviceUser( ) return 'OK' if not device.token: # First contact device.is_enrolled = True if hasattr(g, 'signers'): device_certificate = DeviceIdentityCertificate.from_crypto(g.signers[0]) db.session.add(device_certificate) device.certificate = device_certificate else: pass # TODO: if in debug mode this should not throw an exception to deal with cert troubleshooting device_enrolled.send(device) queue_full_inventory(device) device.tokenupdate_at = datetime.utcnow() device.push_magic = plist_data['PushMagic'] device.topic = plist_data['Topic'] device.token = plist_data['Token'] device.unlock_token = plist_data.get('UnlockToken', None) device.last_seen = datetime.now() db.session.commit() try: response = push_to_device(device) except ssl.SSLError: return abort(jsonify(error=True, message="The push certificate has expired")) current_app.logger.info("[APNS2 Response] Status: %d, Reason: %s, APNS ID: %s, Timestamp", response.status_code, response.reason, response.apns_id.decode('utf-8')) device.last_push_at = datetime.utcnow() if response.status_code == 200: device.last_apns_id = response.apns_id db.session.commit() # TODO: macOS can chain commands from TokenUpdate but iOS will not process any response data from TokenUpdate. # Therefore, it is necessary to issue a Push here instead of simply responding with the next command. return 'OK' @plr.route('MessageType', 'UserAuthenticate') def user_authenticate(plist_data): abort(410, 'per-user authentication not yet supported') @plr.route('MessageType', 'CheckOut') def check_out(plist_data): """Handle the `CheckOut` message. Todo: - Handle CheckOuts for the user channel. """ device_udid = plist_data['UDID'] try: d = db.session.query(Device).filter(Device.udid == device_udid).one() except NoResultFound: current_app.logger.warning('Attempted to unenroll device with UDID: {}, but none was found'.format(device_udid)) return abort(404, 'No matching device found') except MultipleResultsFound: current_app.logger.warning( 'Attempted to unenroll device with UDID: {}, but there were multiple, check your database'.format(device_udid)) return abort(500, 'Too many devices matching') d.last_seen = datetime.utcnow() d.is_enrolled = False # Make sure we cant even accidentally push to an invalid relationship d.token = None d.push_magic = None db.session.commit() current_app.logger.debug('Device has been unenrolled, UDID: {}'.format(device_udid)) return 'OK' @mdm_app.route("/mdm", methods=['PUT']) @verify_mdm_signature @parse_plist_input_data def mdm(): """MDM connection endpoint. Most MDM communication is via this URI. This endpoint delivers and handles incoming command responses. Such as: `Idle`, `NotNow`, `Acknowledged`. :reqheader Content-Type: application/x-apple-aspen-mdm; charset=UTF-8 :reqheader Mdm-Signature: BASE64-encoded CMS Detached Signature of the message. (if `SignMessage` was true) :resheader Content-Type: application/xml; charset=UTF-8 :status 200: With an empty body, no commands remaining, or plist contents of next command. :status 400: Invalid data submitted :status 410: User channel capability not available. """ # TODO: proper identity verification, for now just matching on UDID try: device = db.session.query(Device).filter(Device.udid == g.plist_data['UDID']).one() except NoResultFound: current_app.logger.info("An unmanaged device (UDID %s), tried to check in with us, rejecting.", g.plist_data['UDID']) return abort(410) # Unmanage devices that we dont have a record of if 'UserID' in g.plist_data: # Note that with DEP this is an opportune time to queue up an # application install for the /device/ despite this being a per-user # MDM command. this is becasue DEP appears to only allow apps to be # installed while a user is logged in. note also the undocumented # NotOnConsole key to (possibly) indicate that this is a UI login? current_app.logger.warn('per-user MDM command not yet supported') return '' if 'Status' not in g.plist_data: current_app.logger.error('invalid MDM request (no Status provided) from device id %d' % device.id) return abort(400, 'response does not contain Status') else: status = CommandStatus(g.plist_data['Status']) current_app.logger.info('device id=%d udid=%s processing status=%s', device.id, device.udid, status) device.last_seen = datetime.utcnow() db.session.commit() if current_app.config['DEBUG']: try: print(g.plist_data) except UnicodeEncodeError: print('Cannot DEBUG print plist request, unencodable characters') if status != CommandStatus.Idle: # this device is responding to an earlier command. if 'CommandUUID' not in g.plist_data: current_app.logger.error('missing CommandUUID for non-Idle status') abort(400, 'response does not contain CommandUUID') try: command = DBCommand.find_by_uuid(g.plist_data['CommandUUID']) command.status = status command.acknowledged_at = datetime.utcnow() db.session.commit() # Re-hydrate the command class based on the persisted model containing the request type and the parameters # that were given to generate the command # turns out this is less useful than passing the db model # cmd = Command.new_request_type(command.request_type, command.parameters, command.uuid) # route the response by the handler type corresponding to that command command_router.handle(command, device, g.plist_data) except NoResultFound: current_app.logger.warning('no record of command uuid=%s', g.plist_data['CommandUUID']) if status == CommandStatus.NotNow: current_app.logger.warn('NotNow status received, command will backoff') # TODO: exponential backoff command = DBCommand.next_command(device) if not command: current_app.logger.info('no further MDM commands for device=%d', device.id) return '' # mark this command as being in process right away to (try) to avoid # any race conditions with mutliple MDM commands from the same device # at a time #command.set_processing() #db.session.commit() # Re-hydrate the command class based on the persisted model containing the request type and the parameters # that were given to generate the command cmd = Command.new_request_type(command.request_type, command.parameters, command.uuid) # get command dictionary representation (e.g. the full command to send) output_dict = cmd.to_dict() current_app.logger.info('sending %s MDM command class=%s to device=%d', cmd.request_type, command.request_type, device.id) current_app.logger.debug(output_dict) command.status = CommandStatus.Sent command.sent_at = datetime.utcnow() db.session.commit() return plistify(output_dict) ================================================ FILE: commandment/mdm/commands.py ================================================ from enum import Enum from uuid import uuid4, UUID from typing import Dict, Set, List, Type, ClassVar, Any, Optional, Tuple import semver from base64 import urlsafe_b64encode, urlsafe_b64decode from . import AccessRights, AccessRightsSet, Platform PlatformVersion = str PlatformRequirements = Dict[Platform, PlatformVersion] class CommandRegistry(type): command_classes: Dict[str, Type] = {} def __new__(mcs, name, bases, namespace, **kwds): ns = dict(namespace) klass = type.__new__(mcs, name, bases, ns) if 'request_type' in ns: CommandRegistry.command_classes[ns['request_type']] = klass return klass class Command(metaclass=CommandRegistry): # request_type: ClassVar[str] = None """request_type (str): The MDM RequestType, as specified in the MDM Specification.""" # require_access: ClassVar[AccessRightsSet] = set() """require_access (Set[AccessRights]): Access required for the MDM to execute the command on this device.""" # require_platforms: ClassVar[PlatformRequirements] = dict() """require_platforms (PlatformRequirements): A dict of Platform : version predicate string, to indicate which platforms will accept the command""" # require_supervised: ClassVar[bool] = False """require_supervised (bool): This command requires supervision on iOS/tvOS""" def __init__(self, uuid=None) -> None: """The Command class wraps an MDM Request Command dict to provide validation and convenience methods for accessing command attributes. All commands are serialised to the same table as JSON, so the validation is performed here. Args: uuid (UUID): The command uuid. Defaults to an automatically generated uuid. """ if uuid is None: uuid = uuid4() self._uuid: UUID = uuid self._attrs: Dict[str, Any] = {} # self.request_type: Optional[str] = None # self.require_access: AccessRightsSet = set() # self.require_platforms: PlatformRequirements = dict() # self.require_supervised: bool = False @property def uuid(self) -> UUID: return self._uuid @property def parameters(self) -> Dict[str, Any]: return self._attrs @classmethod def new_request_type(cls, request_type: str, parameters: dict, uuid: str = None) -> 'Command': """Factory method for instantiating a command based on its class attribute ``request_type``. Additionally, the dict given in parameters will be applied to the command instance. Commands that have no parameters are not required to implement to_dict(). Args: request_type (str): The command request type, as defined in the class attribute ``request_type``. parameters (dict): The parameters of this command instance. uuid (str): The command UUID. Optional, will be generated if omitted. Raises: ValueError if there is no command matching the request type given. Returns: Command class that corresponds to the request type given. Inherits from Command. """ if request_type in CommandRegistry.command_classes: klass = CommandRegistry.command_classes[request_type] return klass(uuid, **parameters) else: raise ValueError('No such RequestType registered: {}'.format(request_type)) def to_dict(self) -> dict: """Convert the command into a dict that will be serializable by plistlib. This default implementation will work for command types that have no parameters. """ command = {'RequestType': self.request_type} return { 'CommandUUID': str(self._uuid), 'Command': command, } class DeviceInformation(Command): request_type = 'DeviceInformation' require_access = {AccessRights.QueryDeviceInformation, AccessRights.QueryNetworkInformation} class Queries(Enum): """The Queries enumeration contains all possible Query types for the DeviceInformation command.""" # Table 5 : General Queries UDID = 'UDID' Languages = 'Languages' Locales = 'Locales' DeviceID = 'DeviceID' OrganizationInfo = 'OrganizationInfo' LastCloudBackupDate = 'LastCloudBackupDate' AwaitingConfiguration = 'AwaitingConfiguration' AutoSetupAdminAccounts = 'AutoSetupAdminAccounts' # Table 6 : iTunes Account iTunesStoreAccountIsActive = 'iTunesStoreAccountIsActive' iTunesStoreAccountHash = 'iTunesStoreAccountHash' # Table 7 : Device Queries DeviceName = 'DeviceName' OSVersion = 'OSVersion' BuildVersion = 'BuildVersion' ModelName = 'ModelName' Model = 'Model' ProductName = 'ProductName' SerialNumber = 'SerialNumber' DeviceCapacity = 'DeviceCapacity' AvailableDeviceCapacity = 'AvailableDeviceCapacity' BatteryLevel = 'BatteryLevel' CellularTechnology = 'CellularTechnology' IMEI = 'IMEI' MEID = 'MEID' ModemFirmwareVersion = 'ModemFirmwareVersion' IsSupervised = 'IsSupervised' IsDeviceLocatorServiceEnabled = 'IsDeviceLocatorServiceEnabled' IsActivationLockEnabled = 'IsActivationLockEnabled' IsDoNotDisturbInEffect = 'IsDoNotDisturbInEffect' EASDeviceIdentifier = 'EASDeviceIdentifier' IsCloudBackupEnabled = 'IsCloudBackupEnabled' OSUpdateSettings = 'OSUpdateSettings' LocalHostName = 'LocalHostName' HostName = 'HostName' SystemIntegrityProtectionEnabled = 'SystemIntegrityProtectionEnabled' ActiveManagedUsers = 'ActiveManagedUsers' IsMDMLostModeEnabled = 'IsMDMLostModeEnabled' MaximumResidentUsers = 'MaximumResidentUsers' # Table 9 : Network Information Queries ICCID = 'ICCID' BluetoothMAC = 'BluetoothMAC' WiFiMAC = 'WiFiMAC' EthernetMACs = 'EthernetMACs' CurrentCarrierNetwork = 'CurrentCarrierNetwork' SIMCarrierNetwork = 'SIMCarrierNetwork' SubscriberCarrierNetwork = 'SubscriberCarrierNetwork' CarrierSettingsVersion = 'CarrierSettingsVersion' PhoneNumber = 'PhoneNumber' VoiceRoamingEnabled = 'VoiceRoamingEnabled' DataRoamingEnabled = 'DataRoamingEnabled' IsRoaming = 'IsRoaming' PersonalHotspotEnabled = 'PersonalHotspotEnabled' SubscriberMCC = 'SubscriberMCC' SubscriberMNC = 'SubscriberMNC' CurrentMCC = 'CurrentMCC' CurrentMNC = 'CurrentMNC' # Maybe undocumented CurrentConsoleManagedUser = 'CurrentConsoleManagedUser' Requirements = { 'Languages': [ (Platform.iOS, '>=7'), (Platform.tvOS, '>=6'), (Platform.macOS, '>=10.10'), ], 'Locales': [ (Platform.iOS, '>=7'), (Platform.tvOS, '>=6'), (Platform.macOS, '>=10.10'), ], 'DeviceID': [ (Platform.tvOS, '>=6'), ], 'OrganizationInfo': [ (Platform.iOS, '>=7'), ], 'LastCloudBackupDate': [ (Platform.iOS, '>=8'), (Platform.macOS, '>=10.10') ], 'AwaitingConfiguration': [ (Platform.iOS, '>=9'), ], 'AutoSetupAdminAccounts': [ (Platform.macOS, '>=10.11') ], 'BatteryLevel': [ (Platform.iOS, '>=5') ], 'CellularTechnology': [ (Platform.iOS, '>=4.2.6') ], 'iTunesStoreAccountIsActive': [ (Platform.iOS, '>=7'), (Platform.macOS, '>=10.9') ], 'iTunesStoreAccountHash': [ (Platform.iOS, '>=8'), (Platform.macOS, '>=10.10') ], 'IMEI': [ (Platform.iOS, '*'), ], 'MEID': [ (Platform.iOS, '*'), ], 'ModemFirmwareVersion': [ (Platform.iOS, '*'), ], 'IsSupervised': [ (Platform.iOS, '>=6'), ], 'IsDeviceLocatorServiceEnabled': [ (Platform.iOS, '>=7'), ], 'IsActivationLockEnabled': [ (Platform.iOS, '>=7'), (Platform.macOS, '>=10.9') ], 'IsDoNotDisturbInEffect': [ (Platform.iOS, '>=7'), ], 'EASDeviceIdentifier': [ (Platform.iOS, '>=7'), (Platform.macOS, '>=10.9'), ], 'IsCloudBackupEnabled': [ (Platform.iOS, '>=7.1'), ], 'OSUpdateSettings': [ (Platform.macOS, '>=10.11'), ], 'LocalHostName': [ (Platform.macOS, '>=10.11'), ], 'HostName': [ (Platform.macOS, '>=10.11'), ], 'SystemIntegrityProtectionEnabled': [ (Platform.macOS, '>=10.12'), ], 'ActiveManagedUsers': [ (Platform.macOS, '>=10.11'), ], 'IsMDMLostModeEnabled': [ (Platform.iOS, '>=9.3'), ], 'MaximumResidentUsers': [ (Platform.iOS, '>=9.3'), ] } def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(DeviceInformation, self).__init__(uuid) self._attrs = kwargs @classmethod def for_platform(cls, platform: Platform, min_os_version: str, queries: Set[Queries] = None) -> 'DeviceInformation': """Generate a command that is compatible with the specified platform and OS version. Args: platform (Platform): Desired target platform min_os_version (str): Desired OS version queries (Set[Queries]): Desired Queries, or default to ALL queries. Returns: DeviceInformation instance with supported queries. """ def supported(query) -> bool: if query not in cls.Requirements: return True platforms = cls.Requirements[query] for req_platform, req_min_version in platforms: if req_platform != platform: continue # TODO: version checking return True # semver only takes maj.min.patch #return semver.match(min_os_version, req_min_version) return False if queries is None: supported_queries = filter(supported, [q.value for q in cls.Queries]) else: supported_queries = filter(supported, queries) return cls(Queries=list(supported_queries)) @property def queries(self) -> Set[str]: return self._attrs.get('Queries') def to_dict(self) -> dict: """Convert the command into a dict that will be serializable by plistlib.""" return { 'CommandUUID': str(self._uuid), 'Command': { 'RequestType': type(self).request_type, 'Queries': self._attrs.get('Queries', None), } } class SecurityInfo(Command): request_type = 'SecurityInfo' require_access = {AccessRights.SecurityQueries} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(SecurityInfo, self).__init__(uuid) self._attrs = kwargs class DeviceLock(Command): request_type = 'DeviceLock' require_access = {AccessRights.DeviceLockPasscodeRemoval} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(DeviceLock, self).__init__(uuid) self._attrs = kwargs def to_dict(self) -> dict: command = { 'RequestType': type(self).request_type, 'Message': self._attrs.get('Message', 'Device is locked'), } if 'PIN' in self._attrs: command['PIN'] = self._attrs['PIN'] if 'PhoneNumber' in self._attrs: command['PhoneNumber'] = self._attrs['PhoneNumber'] return { 'CommandUUID': str(self._uuid), 'Command': command, } class ClearPasscode(Command): request_type = 'ClearPasscode' require_access = {AccessRights.DeviceLockPasscodeRemoval} require_platforms = {Platform.iOS: '*'} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(ClearPasscode, self).__init__(uuid) self._attrs = kwargs def to_dict(self) -> dict: return { 'CommandUUID': str(self._uuid), 'Command': { 'RequestType': type(self).request_type, 'UnlockToken': urlsafe_b64decode(self._attrs['UnlockToken']) } } class ProfileList(Command): request_type = 'ProfileList' require_access = {AccessRights.ProfileInspection} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(ProfileList, self).__init__(uuid) self._attrs = kwargs class InstallProfile(Command): request_type = 'InstallProfile' require_access = {AccessRights.ProfileInstallRemove} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(InstallProfile, self).__init__(uuid) self._attrs = kwargs if 'profile' in kwargs: profile_data = kwargs['profile'].data self._attrs['Payload'] = urlsafe_b64encode(profile_data).decode('utf-8') del self._attrs['profile'] def to_dict(self) -> dict: return { 'CommandUUID': str(self._uuid), 'Command': { 'RequestType': type(self).request_type, 'Payload': urlsafe_b64decode(self._attrs['Payload']), } } class RemoveProfile(Command): request_type = 'RemoveProfile' require_access = {AccessRights.ProfileInstallRemove} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(RemoveProfile, self).__init__(uuid) self._attrs = { 'Identifier': kwargs.get('Identifier') } def to_dict(self) -> dict: """Convert the command into a dict that will be serializable by plistlib.""" return { 'CommandUUID': str(self._uuid), 'Command': { 'RequestType': type(self).request_type, 'Identifier': self._attrs.get('Identifier', None), } } class CertificateList(Command): request_type = 'CertificateList' require_access = {AccessRights.ProfileInspection} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(CertificateList, self).__init__(uuid) self._attrs = kwargs class ProvisioningProfileList(Command): request_type = 'ProvisioningProfileList' require_access = {AccessRights.ProfileInspection} def __init__(self, uuid: Optional[UUID]=None, **kwargs): super(ProvisioningProfileList, self).__init__(uuid) self._attrs = kwargs class InstalledApplicationList(Command): request_type = 'InstalledApplicationList' require_access: Set[AccessRights] = set() def __init__(self, uuid: Optional[UUID]=None, **kwargs): super(InstalledApplicationList, self).__init__(uuid) self._attrs = {} self._attrs.update(kwargs) @property def managed_apps_only(self) -> Optional[bool]: return self._attrs.get('ManagedAppsOnly', None) @managed_apps_only.setter def managed_apps_only(self, value: bool) -> None: self._attrs['ManagedAppsOnly'] = value @property def identifiers(self) -> Optional[List[str]]: return self._attrs.get('Identifiers', None) @identifiers.setter def identifiers(self, bundle_ids: List[str]) -> None: """NOTE: setting identifiers for macOS 10.12 causes an exception in mdmclient.""" self._attrs['Identifiers'] = bundle_ids def to_dict(self) -> dict: """Convert the command into a dict that will be serializable by plistlib.""" command = self._attrs command.update({'RequestType': type(self).request_type}) return { 'CommandUUID': str(self._uuid), 'Command': command, } class InstallApplication(Command): request_type = 'InstallApplication' require_access = {AccessRights.ManageApps} def __init__(self, uuid: Optional[UUID]=None, **kwargs) -> None: super(InstallApplication, self).__init__(uuid) self._attrs = {} if 'application' in kwargs: app = kwargs['application'] self._attrs['iTunesStoreID'] = app.itunes_store_id self._attrs['ManagementFlags'] = 1 self._attrs['ChangeManagementState'] = 'Managed' else: self._attrs.update(kwargs) @property def itunes_store_id(self) -> Optional[int]: return self._attrs.get('iTunesStoreID', None) @itunes_store_id.setter def itunes_store_id(self, id: int): self._attrs['iTunesStoreID'] = id if 'Options' not in self._attrs: self._attrs['Options'] = {} if 'PurchaseMethod' not in self._attrs['Options']: self._attrs['Options']['PurchaseMethod'] = 1 def to_dict(self) -> dict: cmd = super(InstallApplication, self).to_dict() cmd['Command'].update(self._attrs) print(cmd) return cmd class ManagedApplicationList(Command): request_type = 'ManagedApplicationList' require_access = {AccessRights.ManageApps} class RestartDevice(Command): request_type = 'RestartDevice' require_access = {AccessRights.DeviceLockPasscodeRemoval} require_platforms = {Platform.iOS: '>=10.3'} class ShutDownDevice(Command): request_type = 'ShutDownDevice' require_access = {AccessRights.DeviceLockPasscodeRemoval} require_platforms = {Platform.iOS: '>=10.3', Platform.macOS: '>=10.13'} class EraseDevice(Command): request_type = 'EraseDevice' require_access = {AccessRights.DeviceErase} require_platforms = {Platform.iOS: '*', Platform.macOS: '>=10.8'} class RequestMirroring(Command): request_type = 'RequestMirroring' require_platforms = {Platform.iOS: '>=7', Platform.macOS: '>=10.10'} class StopMirroring(Command): request_type = 'StopMirroring' require_platforms = {Platform.iOS: '>=7', Platform.macOS: '>=10.10'} require_supervised = True class Restrictions(Command): request_type = 'Restrictions' require_access = {AccessRights.RestrictionQueries, AccessRights.ProfileInspection} class UsersList(Command): request_type = 'UsersList' require_platforms = {Platform.iOS: '>=9.3'} class LogOutUser(Command): request_type = 'LogOutUser' require_platforms = {Platform.iOS: '>=9.3'} class DeleteUser(Command): request_type = 'DeleteUser' require_platforms = {Platform.iOS: '>=9.3'} class EnableLostMode(Command): request_type = 'EnableLostMode' require_platforms = {Platform.iOS: '>=9.3'} require_supervised = True class DisableLostMode(Command): request_type = 'DisableLostMode' require_platforms = {Platform.iOS: '>=9.3'} require_supervised = True class DeviceLocation(Command): request_type = 'DeviceLocation' require_platforms = {Platform.iOS: '>=9.3'} require_supervised = True class PlayLostModeSound(Command): request_type = 'PlayLostModeSound' require_platforms = {Platform.iOS: '>=10.3'} require_supervised = True class AvailableOSUpdates(Command): request_type = 'AvailableOSUpdates' require_platforms = {Platform.macOS: '>=10.11', Platform.iOS: '>=4'} class Settings(Command): request_type = 'Settings' require_platforms = {Platform.macOS: '>=10.9', Platform.iOS: '>=5.0'} require_access = {AccessRights.ChangeSettings} def __init__(self, uuid: Optional[UUID]=None, device_name: Optional[str]=None, hostname: Optional[str]=None, voice_roaming: Optional[bool]=None, personal_hotspot: Optional[bool]=None, wallpaper=None, data_roaming: Optional[bool]=None, bluetooth: Optional[bool]=None, **kwargs) -> None: super(Settings, self).__init__(uuid) if 'settings' in kwargs: self._attrs['settings'] = kwargs['settings'] else: self._attrs['settings']: List[Dict[str, Any]] = [] if device_name is not None: self._attrs['settings'].append({ 'Item': 'DeviceName', 'DeviceName': device_name, }) if hostname is not None: self._attrs['settings'].append({ 'Item': 'HostName', 'HostName': hostname, }) if voice_roaming is not None: self._attrs['settings'].append({ 'Item': 'VoiceRoaming', 'Enabled': voice_roaming, }) if personal_hotspot is not None: self._attrs['settings'].append({ 'Item': 'PersonalHotspot', 'Enabled': personal_hotspot, }) if data_roaming is not None: self._attrs['settings'].append({ 'Item': 'DataRoaming', 'Enabled': data_roaming, }) if bluetooth is not None: self._attrs['settings'].append({ 'Item': 'Bluetooth', 'Enabled': bluetooth, }) def to_dict(self) -> dict: return { 'CommandUUID': str(self._uuid), 'Command': { 'RequestType': type(self).request_type, 'Settings': self._attrs['settings'], } } ================================================ FILE: commandment/mdm/decorators.py ================================================ from functools import wraps def handle_error_status(func): """This decorator looks at the request for an Error status, then handles the error accordingly: """ @wraps(func) def handler(*args, **kwargs): return func(*args, **kwargs) return handler ================================================ FILE: commandment/mdm/handlers.py ================================================ from binascii import hexlify from cryptography import x509 from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from flask import current_app from sqlalchemy.orm.exc import NoResultFound, MultipleResultsFound from commandment.apps import ManagedAppStatus from commandment.apps.models import ManagedApplication from commandment.mdm import commands from commandment.mdm.app import command_router from .commands import ProfileList, DeviceInformation, SecurityInfo, InstalledApplicationList, CertificateList, \ InstallProfile, AvailableOSUpdates, InstallApplication, RemoveProfile, ManagedApplicationList from .response_schema import InstalledApplicationListResponse, DeviceInformationResponse, AvailableOSUpdateListResponse, \ ProfileListResponse, SecurityInfoResponse from ..models import db, Device, Command as DBCommand from commandment.inventory.models import InstalledCertificate, InstalledProfile, InstalledApplication Queries = DeviceInformation.Queries @command_router.route('DeviceInformation') def ack_device_information(request: DBCommand, device: Device, response: dict): """Acknowledge a response to the ``DeviceInformation`` command. Args: request (Command): An instance of the command that prompted the device to come back with this request. device (Device): The device responding to the command. response (dict): The raw response dictionary, de-serialized from plist. Returns: void: Reserved for future use See Also: - `DeviceInformation Command `_. """ schema = DeviceInformationResponse() result = schema.load(response) for k, v in result.data['QueryResponses'].items(): setattr(device, k, v) db.session.commit() @command_router.route('SecurityInfo') def ack_security_info(request: DBCommand, device: Device, response: dict): schema = SecurityInfoResponse() result = schema.load(response) db.session.commit() @command_router.route('ProfileList') def ack_profile_list(request: DBCommand, device: Device, response: dict): """Acknowledge a ``ProfileList`` response. This is used as the trigger to perform InstallProfile/RemoveProfiles as we have the most current data about what exists on the device. The set of profiles to install is a result of: set(desired) - set(installed) = set(install) The set of profiles to remove is a result of: set(installed) - set(desired) = set(remove) EXCEPT THAT: - You never want to remove the enrollment profile unless you are "unmanaging" the device. - You can't remove profiles not installed by this MDM. Args: request (ProfileList): The command instance that generated this response. device (Device): The device responding to the command. response (dict): The raw response dictionary, de-serialized from plist. Returns: void: Reserved for future use """ schema = ProfileListResponse() profile_list = schema.load(response) for pl in device.installed_payloads: db.session.delete(pl) # Impossible to calculate delta, so all profiles get wiped for p in device.installed_profiles: db.session.delete(p) desired_profiles = {} for tag in device.tags: for p in tag.profiles: desired_profiles[p.uuid] = p remove_profiles = [] for profile in profile_list.data['ProfileList']: profile.device = device # device.udid may have dashes (macOS) or not (iOS) profile.device_udid = device.udid for payload in profile.payload_content: payload.device = device payload.profile_id = profile.id db.session.add(profile) # Reconcile profiles which should be installed if profile.payload_uuid in desired_profiles: del desired_profiles[profile.payload_uuid] else: if not profile.is_managed: current_app.logger.debug("Skipping removal of unmanaged profile: %s", profile.payload_display_name) else: current_app.logger.debug("Going to remove: %s", profile.payload_display_name) remove_profiles.append(profile) # Queue up some desired profiles for puuid, p in desired_profiles.items(): c = commands.InstallProfile(None, profile=p) dbc = DBCommand.from_model(c) dbc.device = device db.session.add(dbc) for remove_profile in remove_profiles: c = commands.RemoveProfile(None, Identifier=remove_profile.payload_identifier) dbc = DBCommand.from_model(c) dbc.device = device db.session.add(dbc) db.session.commit() @command_router.route('CertificateList') def ack_certificate_list(request: DBCommand, device: Device, response: dict): """Acknowledge a response to the ``CertificateList`` command. Args: request (Command): An instance of the command that prompted the device to come back with this request. device (Device): The database model of the device responding. response (dict): The response plist data, as a dictionary. Returns: void: Nothing is returned but this behaviour is subject to change. """ for c in device.installed_certificates: db.session.delete(c) certificates = response['CertificateList'] current_app.logger.debug( 'Received CertificatesList response containing {} certificate(s)'.format(len(certificates))) for cert in certificates: ic = InstalledCertificate() ic.device = device ic.device_udid = device.udid ic.x509_cn = cert.get('CommonName', None) ic.is_identity = cert.get('IsIdentity', None) der_data = cert['Data'] certificate = x509.load_der_x509_certificate(der_data, default_backend()) ic.fingerprint_sha256 = hexlify(certificate.fingerprint(hashes.SHA256())) ic.der_data = der_data db.session.add(ic) db.session.commit() @command_router.route('InstalledApplicationList') def ack_installed_app_list(request: DBCommand, device: Device, response: dict): """Acknowledge a response to the ``InstalledApplicationList`` command. .. note:: There is no composite key which can uniquely identify an item in the installed applications list. Some applications may not contain any version information at all. For this reason, the entire list of installed applications is cleared before inserting a new list. Args: request (InstalledApplicationList): An instance of the command that generated this response from the managed device. device (Device): The device responding response (dict): The dictionary containing the parsed plist response from the device. Returns: void: Nothing is returned but this behaviour is subject to change. """ for a in device.installed_applications: db.session.delete(a) applications = response['InstalledApplicationList'] current_app.logger.debug( 'Received InstalledApplicationList response containing {} application(s)'.format(len(applications)) ) schema = InstalledApplicationListResponse() result, errors = schema.load(response) current_app.logger.debug(errors) # current_app.logger.info(result) ignored_app_bundle_ids = current_app.config['IGNORED_APPLICATION_BUNDLE_IDS'] for ia in result['InstalledApplicationList']: if isinstance(ia, db.Model): if ia.bundle_identifier in ignored_app_bundle_ids: current_app.logger.debug('Ignoring app with bundle id: %s', ia.bundle_identifier) continue ia.device = device ia.device_udid = device.udid db.session.add(ia) else: current_app.logger.debug('Not a model: %s', ia) db.session.commit() @command_router.route('InstallProfile') def ack_install_profile(request: DBCommand, device: Device, response: dict): """Acknowledge a response to ``InstallProfile``.""" if response.get('Status', None) == 'Error': pass @command_router.route('RemoveProfile') def ack_install_profile(request: DBCommand, device: Device, response: dict): """Acknowledge a response to ``RemoveProfile``.""" if response.get('Status', None) == 'Error': pass @command_router.route('AvailableOSUpdates') def ack_available_os_updates(request: DBCommand, device: Device, response: dict): """Acknowledge a response to AvailableOSUpdates""" if response.get('Status', None) == 'Error': pass else: for au in device.available_os_updates: db.session.delete(au) schema = AvailableOSUpdateListResponse() result = schema.load(response) for upd in result.data['AvailableOSUpdates']: upd.device = device db.session.add(upd) db.session.commit() @command_router.route('InstallApplication') def ack_install_application(request: DBCommand, device: Device, response: dict): """Acknowledge a response to InstallApplication. We will insert this into `managed_applications` to show that there is a pending application install. `managed_applications` will be the source of truth for installation status. If the result of `InstallApplication` is a user prompt, we cannot send further IA commands until the prompt has been resolved(?) as seen on iOS 11.3.1 TODO: Also create a pending status when the command is queued but not acked """ if response.get('Status', None) == 'Error': pass else: try: # It is possible to send `InstallApplication` and receive Acknowledged multiple times for the same app, # so we want to avoid multiple rows in that scenario ma = db.session.query(ManagedApplication).filter( Device.id == device.id, ManagedApplication.bundle_id == response['Identifier'] ).one() ma.ia_command = request db.session.commit() except NoResultFound: ma = ManagedApplication() ma.device = device ma.bundle_id = response['Identifier'] ma.status = ManagedAppStatus(response['State']) ma.ia_command = request db.session.add(ma) db.session.commit() @command_router.route('ManagedApplicationList') def ack_managed_application_list(request: DBCommand, device: Device, response: dict): """Acknowledge a response to `ManagedApplicationList`.""" if response.get('Status', None) == 'Error': pass else: for bundle_id, status in response['ManagedApplicationList'].items(): try: ma = db.session.query(ManagedApplication).filter( Device.id == device.id, ManagedApplication.bundle_id == bundle_id ).one() except NoResultFound: ma = ManagedApplication(bundle_id=bundle_id, device=device) ma.status = ManagedAppStatus(status['Status']) ma.external_version_id = status.get('ExternalVersionIdentifier', None) # Does not exist in iOS 11.3.1 ma.has_configuration = status['HasConfiguration'] ma.has_feedback = status['HasFeedback'] ma.is_validated = status['IsValidated'] ma.management_flags = status['ManagementFlags'] db.session.add(ma) db.session.commit() for tag in device.tags: for app in tag.applications: # TODO: need to check with new versions being available. This is very primitive. if app.bundle_id in response['ManagedApplicationList'].keys(): continue c = commands.InstallApplication(application=app) dbc = DBCommand.from_model(c) dbc.device = device db.session.add(dbc) ma = ManagedApplication(device=device, application=app, ia_command=dbc, status=ManagedAppStatus.Queued) db.session.add(ma) db.session.commit() @command_router.route('RestartDevice') def ack_restart_device(request: DBCommand, device: Device, response: dict): """Acknowledge a response to `RestartDevice`. On macOS 10.13.6, the MDM client comes back with an `Idle` check in upon restart as part of launchd starting up. This happens BEFORE the loginwindow (at about 40% of the progress bar at startup). This is the same Power-on behaviour. """ pass @command_router.route('ShutDownDevice') def ack_restart_device(request: DBCommand, device: Device, response: dict): """Acknowledge a response to `ShutDownDevice`. On macOS 10.13.6, the MDM client comes back with an `Idle` check in upon restart as part of launchd starting up. This happens BEFORE the loginwindow (at about 40% of the progress bar at startup). This is the same Power-on behaviour. """ pass ================================================ FILE: commandment/mdm/models.py ================================================ ================================================ FILE: commandment/mdm/resources.py ================================================ from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship from flask_rest_jsonapi.exceptions import ObjectNotFound from sqlalchemy.orm.exc import NoResultFound from commandment.mdm.schema import CommandSchema from commandment.models import db, Command, Device class CommandsList(ResourceList): def query(self, view_kwargs): query_ = self.session.query(Command) if view_kwargs.get('device_id') is not None: try: self.session.query(Device).filter_by(id=view_kwargs['device_id']).one() except NoResultFound: raise ObjectNotFound({'parameter': 'device_id'}, "Device: {} not found".format(view_kwargs['device_id'])) else: query_ = query_.join(Device).filter(Device.id == view_kwargs['device_id']) return query_ schema = CommandSchema view_kwargs = True data_layer = { 'session': db.session, 'model': Command, 'methods': {'query': query} } class CommandDetail(ResourceDetail): schema = CommandSchema data_layer = { 'session': db.session, 'model': Command, 'url_field': 'command_id' } class CommandRelationship(ResourceRelationship): schema = CommandSchema data_layer = {'session': db.session, 'model': Command} ================================================ FILE: commandment/mdm/response_schema.py ================================================ from typing import Optional from marshmallow import Schema, fields, post_load, ValidationError from marshmallow_enum import EnumField from enum import IntFlag import commandment.inventory.models from .. import models from commandment.inventory import models as inventory_models class ErrorChainItem(Schema): """ErrorChainItem describes an item of the ErrorChain array, which appears when an error occurs with an MDM request.""" LocalizedDescription = fields.String() USEnglishDescription = fields.String() ErrorDomain = fields.String() ErrorCode = fields.Number() class CommandResponse(Schema): """CommandResponse is the base class for all MDM Response Schemas.""" Status = fields.String() UDID = fields.String() CommandUUID = fields.UUID() ErrorChain = fields.Nested(ErrorChainItem, many=True) class OrganizationInfo(Schema): pass class AutoSetupAdminAccount(Schema): GUID = fields.UUID() shortName = fields.String() class OSUpdateSettings(Schema): """OSUpdateSettings is returned as a nested part of the ``DeviceInformation`` response.""" CatalogURL = fields.String(attribute='osu_catalog_url') IsDefaultCatalog = fields.Boolean(attribute='osu_is_default_catalog') PreviousScanDate = fields.Date(attribute='osu_previous_scan_date') PreviousScanResult = fields.String(attribute='osu_previous_scan_result') PerformPeriodicCheck = fields.Boolean(attribute='osu_perform_periodic_check') AutomaticCheckEnabled = fields.Boolean(attribute='osu_automatic_check_enabled') BackgroundDownloadEnabled = fields.Boolean(attribute='osu_background_download_enabled') AutomaticAppInstallationEnabled = fields.Boolean(attribute='osu_automatic_app_installation_enabled') AutomaticOSInstallationEnabled = fields.Boolean(attribute='osu_automatic_os_installation_enabled') AutomaticSecurityUpdatesEnabled = fields.Boolean(attribute='osu_automatic_security_updates_enabled') class DeviceInformation(Schema): # Table 5 UDID = fields.String(attribute='udid') # Languages DeviceID = fields.String(attribute='device_id') OrganizationInfo = fields.Nested(OrganizationInfo) LastCloudBackupDate = fields.Date(attribute='last_cloud_backup_date') AwaitingConfiguration = fields.Boolean(attribute='awaiting_configuration') AutoSetupAdminAccounts = fields.Nested(AutoSetupAdminAccount, many=True) # Table 6 iTunesStoreAccountIsActive = fields.Boolean(attribute='itunes_store_account_is_active') iTunesStoreAccountHash = fields.String(attribute='itunes_store_account_hash') # Table 7 DeviceName = fields.String(attribute='device_name') OSVersion = fields.String(attribute='os_version') BuildVersion = fields.String(attribute='build_version') ModelName = fields.String(attribute='model_name') Model = fields.String(attribute='model') ProductName = fields.String(attribute='product_name') SerialNumber = fields.String(attribute='serial_number') DeviceCapacity = fields.Float(attribute='device_capacity') AvailableDeviceCapacity = fields.Float(attribute='available_device_capacity') BatteryLevel = fields.Float(attribute='battery_level') CellularTechnology = fields.Integer(attribute='cellular_technology') IMEI = fields.String(attribute='imei') MEID = fields.String(attribute='meid') ModemFirmwareVersion = fields.String(attribute='modem_firmware_version') IsSupervised = fields.Boolean(attribute='is_supervised') IsDeviceLocatorServiceEnabled = fields.Boolean(attribute='is_device_locator_service_enabled') IsActivationLockEnabled = fields.Boolean(attribute='is_activation_lock_enabled') IsDoNotDisturbInEffect = fields.Boolean(attribute='is_do_not_disturb_in_effect') EASDeviceIdentifier = fields.String(attribute='eas_device_identifier') IsCloudBackupEnabled = fields.Boolean(attribute='is_cloud_backup_enabled') OSUpdateSettings = fields.Nested(OSUpdateSettings, attribute='os_update_settings') # T8 LocalHostName = fields.String(attribute='local_hostname') HostName = fields.String(attribute='hostname') SystemIntegrityProtectionEnabled = fields.Boolean(attribute='sip_enabled') # Array of str #ActiveManagedUsers = fields.Nested(ActiveManagedUser) IsMDMLostModeEnabled = fields.Boolean(attribute='is_mdm_lost_mode_enabled') MaximumResidentUsers = fields.Integer(attribute='maximum_resident_users') # Table 9 ICCID = fields.String(attribute='iccid') BluetoothMAC = fields.String(attribute='bluetooth_mac') WiFiMAC = fields.String(attribute='wifi_mac') EthernetMACs = fields.String(attribute='ethernet_macs', many=True) CurrentCarrierNetwork = fields.String(attribute='current_carrier_network') SIMCarrierNetwork = fields.String(attribute='sim_carrier_network') SubscriberCarrierNetwork = fields.String(attribute='subscriber_carrier_network') CarrierSettingsVersion = fields.String(attribute='carrier_settings_version') PhoneNumber = fields.String(attribute='phone_number') VoiceRoamingEnabled = fields.Boolean(attribute='voice_roaming_enabled') DataRoamingEnabled = fields.Boolean(attribute='data_roaming_enabled') IsRoaming = fields.Boolean(attribute='is_roaming') PersonalHotspotEnabled = fields.Boolean(attribute='personal_hotspot_enabled') SubscriberMCC = fields.String(attribute='subscriber_mcc') SubscriberMNC = fields.String(attribute='subscriber_mnc') CurrentMCC = fields.String(attribute='current_mcc') CurrentMNC = fields.String(attribute='current_mnc') @post_load def normalize_osu(self, data): print(data) for k, v in data.get('os_update_settings', {}).items(): setattr(data, k, v) return data class DeviceInformationResponse(CommandResponse): QueryResponses = fields.Nested(DeviceInformation) class InstallApplicationResponse(CommandResponse): Identifier = fields.String() State = fields.String() class HardwareEncryptionCaps(IntFlag): Nothing = 0 BlockLevelEncryption = 1 FileLevelEncryption = 2 All = BlockLevelEncryption | FileLevelEncryption class FirewallApplicationItem(Schema): BundleID = fields.String() Allowed = fields.Boolean() Name = fields.String() class FirewallSettings(Schema): FirewallEnabled = fields.Boolean() BlockAllIncoming = fields.Boolean() StealthMode = fields.Boolean() Applications = fields.Nested(FirewallApplicationItem, many=True) class FirmwarePasswordStatus(Schema): PasswordExists = fields.Boolean() ChangePending = fields.Boolean() AllowOroms = fields.Boolean() class ManagementStatus(Schema): EnrolledViaDEP = fields.Boolean() UserApprovedEnrollment = fields.Boolean() class SecurityInfoResponse(CommandResponse): HardwareEncryptionCaps = EnumField(HardwareEncryptionCaps) PasscodePresent = fields.Boolean() PasscodeCompliant = fields.Boolean() PasscodeCompliantWithProfiles = fields.Boolean() PasscodeLockGracePeriodEnforced = fields.Integer() FDE_Enabled = fields.Boolean() FDE_HasPersonalRecoveryKey = fields.Boolean() FDE_HasInstitutionalRecoveryKey = fields.Boolean() FDE_PersonalRecoveryKeyCMS = fields.String() FDE_PersonalRecoveryKeyDeviceKey = fields.String() FirewallSettings = fields.Nested(FirewallSettings) SystemIntegrityProtectionEnabled = fields.Boolean() FirmwarePasswordStatus = fields.Nested(FirmwarePasswordStatus) ManagementStatus = fields.Nested(ManagementStatus) class InstalledApplicationItem(Schema): AdHocCodeSigned = fields.Boolean(attribute='adhoc_codesigned') AppStoreVendable = fields.Boolean(attribute='appstore_vendable') BetaApp = fields.Boolean(attribute='beta_app') DeviceBasedVPP = fields.Boolean(attribute='device_based_vpp') HasUpdateAvailable = fields.Boolean(attribute='has_update_available') Installing = fields.Boolean(attribute='installing') Identifier = fields.String(attribute='bundle_identifier') Version = fields.String(attribute='version') ShortVersion = fields.String(attribute='short_version') Name = fields.String(attribute='name') BundleSize = fields.Integer(attribute='bundle_size') DynamicSize = fields.Integer(attribute='dynamic_size') IsValidated = fields.Boolean(attribute='is_validated') ExternalVersionIdentifier = fields.Integer(attribute='external_version_identifier') # iOS 11 @post_load(pass_many=False) def make_installed_application(self, data: Optional[dict]) -> Optional[inventory_models.InstalledApplication]: return inventory_models.InstalledApplication(**data) class InstalledApplicationListResponse(CommandResponse): InstalledApplicationList = fields.Nested(InstalledApplicationItem, many=True) class CertificateListItem(Schema): CommonName = fields.String() IsIdentity = fields.Boolean() Data = fields.String() @post_load def make_installed_certificate(self, data: dict) -> inventory_models.InstalledCertificate: return inventory_models.InstalledCertificate(**data) class CertificateListResponse(CommandResponse): CertificateList = fields.Nested(CertificateListItem, many=True) class AvailableOSUpdate(Schema): AllowsInstallLater = fields.Boolean(attribute='allows_install_later') Build = fields.String(attribute='build') DownloadSize = fields.Number(attribute='download_size') AppIdentifiersToClose = fields.List(fields.String, attribute='app_identifiers_to_close', many=True) HumanReadableName = fields.String(attribute='human_readable_name') HumanReadableNameLocale = fields.String(attribute='human_readable_name_locale') InstallSize = fields.Number(attribute='install_size') IsConfigDataUpdate = fields.Boolean(attribute='is_config_data_update') IsCritical = fields.Boolean(attribute='is_critical') IsFirmwareUpdate = fields.Boolean(attribute='is_firmware_update') MetadataURL = fields.String(attribute='metadata_url') ProductKey = fields.String(attribute='product_key') ProductName = fields.String(attribute='product_name') RestartRequired = fields.Boolean(attribute='restart_required') Version = fields.String(attribute='version') @post_load def make_available_os_update(self, data: dict) -> commandment.inventory.models.AvailableOSUpdate: return commandment.inventory.models.AvailableOSUpdate(**data) class AvailableOSUpdateListResponse(CommandResponse): AvailableOSUpdates = fields.Nested(AvailableOSUpdate, many=True) class ProfileListPayloadItem(Schema): PayloadDescription = fields.String(attribute='description') PayloadDisplayName = fields.String(attribute='display_name') PayloadIdentifier = fields.String(attribute='identifier') PayloadOrganization = fields.String(attribute='organization') PayloadType = fields.String(attribute='payload_type') PayloadUUID = fields.UUID(attribute='uuid') # PayloadVersion = fields.Integer(attribute='payload_version') @post_load def make_installed_payload(self, data: dict) -> inventory_models.InstalledPayload: return inventory_models.InstalledPayload(**data) class ProfileListItem(Schema): HasRemovalPasscode = fields.Boolean(attribute='has_removal_password') IsEncrypted = fields.Boolean(attribute='is_encrypted') IsManaged = fields.Boolean(attribute='is_managed') PayloadDescription = fields.String(attribute='payload_description') PayloadDisplayName = fields.String(attribute='payload_display_name') PayloadIdentifier = fields.String(attribute='payload_identifier') PayloadOrganization = fields.String(attribute='payload_organization') PayloadRemovalDisallowed = fields.Boolean(attribute='payload_removal_disallowed') PayloadUUID = fields.UUID(attribute='payload_uuid') # PayloadVersion = fields.Integer(attribute='payload_version') #SignerCertificates = fields.Nested(attribute='signer_certificates', many=True) PayloadContent = fields.Nested(ProfileListPayloadItem, attribute='payload_content', many=True) @post_load def make_installed_profile(self, data: dict) -> inventory_models.InstalledProfile: return inventory_models.InstalledProfile(**data) class ProfileListResponse(CommandResponse): ProfileList = fields.Nested(ProfileListItem, many=True) ================================================ FILE: commandment/mdm/routers.py ================================================ """This module contains routers which direct the request towards a certain module or function based upon the CONTENT of the request, rather than the URL.""" from typing import Union, Any, Type, Callable, Dict, List from flask import Flask, app, Blueprint, request, abort, current_app from functools import wraps import biplist from commandment.models import db, Device, Command from commandment.mdm import commands CommandHandler = Callable[[Command, Device, dict], None] CommandHandlers = Dict[str, CommandHandler] class CommandRouter(object): """The command router passes off commands to handlers which are registered by RequestType. When a reply is received from a device in relation to a specific CommandUUID, the router attempts to find a handler that was registered for the RequestType associated with that command. The handler is then called with the specific instance of the command that generated the response, and an instance of the device that is making the request to the MDM endpoint. Not handling the error status here allows handlers to freely interpret the error conditions of each response, which is generally a better approach as some errors are command specific. Args: app (app): The flask application or blueprint instance """ def __init__(self, app: Union[Flask, Blueprint]) -> None: self._app = app self._handlers: CommandHandlers = {} def handle(self, command: Command, device: Device, response: dict): current_app.logger.debug('Looking for handler using command: {}'.format(command.request_type)) if command.request_type in self._handlers: return self._handlers[command.request_type](command, device, response) else: current_app.logger.warning('No handler found to process command response: {}'.format(command.request_type)) return None def route(self, request_type: str): """ Route a plist request by its RequestType key value. The wrapped function must accept (command, plist_data) :param request_type: :return: """ handlers = self._handlers # current_app.logger.debug('Registering command handler for request type: {}'.format(request_type)) def decorator(f): handlers[request_type] = f @wraps(f) def wrapped(*args, **kwargs): return f(*args, **kwargs) return wrapped return decorator class PlistRouter(object): """PlistRouter routes requests to view functions based on matching values to top level keys. """ def __init__(self, app: app, url: str) -> None: self._app = app app.add_url_rule(url, view_func=self.view, methods=['PUT']) self.kv_routes: List[Dict[str, Any]] = [] def view(self): current_app.logger.debug(request.data) try: plist_data = biplist.readPlistFromString(request.data) except biplist.NotBinaryPlistException: abort(400, 'The request body does not contain a plist as expected') except biplist.InvalidPlistException: abort(400, 'The request body does not contain a valid plist') for kvr in self.kv_routes: if kvr['key'] not in plist_data: continue if plist_data[kvr['key']] == kvr['value']: return kvr['handler'](plist_data) abort(404, 'No matching plist route') def route(self, key: str, value: Any): """ Route a plist request if the content satisfies the key value test The wrapped function must accept (plist_data) """ def decorator(f): self.kv_routes.append(dict( key=key, value=value, handler=f )) @wraps(f) def wrapped(*args, **kwargs): return f(*args, **kwargs) return wrapped return decorator ================================================ FILE: commandment/mdm/schema.py ================================================ from marshmallow_jsonapi import fields from marshmallow_jsonapi.flask import Relationship, Schema class CommandSchema(Schema): class Meta: type_ = 'commands' self_view = 'api_app.command_detail' self_view_kwargs = {'command_id': ''} self_view_many = 'api_app.commands_list' strict = True id = fields.Int(dump_only=True) uuid = fields.Str(dump_only=True) request_type = fields.Str() status = fields.Str() queued_at = fields.DateTime() sent_at = fields.DateTime() acknowledged_at = fields.DateTime() after = fields.DateTime() ttl = fields.Int() device = Relationship( related_view='api_app.device_detail', related_view_kwargs={'device_id': ''}, type_='devices' ) ================================================ FILE: commandment/mdm/util.py ================================================ from commandment.mdm import commands from commandment.models import db, Device, Command def queryresponses_to_query_set(responses: dict): return {commands.DeviceInformation.Queries(k): v for k, v in responses.items()} def queue_full_inventory(device: Device): """Enqueue all inventory commands for a device. Typically run at first check-in Args: device (Device): The device """ # DeviceInformation di = commands.DeviceInformation.for_platform(device.platform, device.os_version) db_command = Command.from_model(di) db_command.device = device db.session.add(db_command) # InstalledApplicationList - Pretty taxing so don't run often ial = commands.InstalledApplicationList() db_command_ial = Command.from_model(ial) db_command_ial.device = device db.session.add(db_command_ial) # CertificateList cl = commands.CertificateList() dbc = Command.from_model(cl) dbc.device = device db.session.add(dbc) # SecurityInfo si = commands.SecurityInfo() dbsi = Command.from_model(si) dbsi.device = device db.session.add(dbsi) # ProfileList pl = commands.ProfileList() db_pl = Command.from_model(pl) db_pl.device = device db.session.add(db_pl) # AvailableOSUpdates au = commands.AvailableOSUpdates() au_pl = Command.from_model(au) au_pl.device = device db.session.add(au_pl) db.session.commit() ================================================ FILE: commandment/models.py ================================================ # -*- coding: utf-8 -*- """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. Attributes: db (SQLAlchemy): A reference to flask SQLAlchemy extensions db instance. """ from typing import Optional, Type from flask_sqlalchemy import SQLAlchemy import datetime from enum import Enum, IntEnum from sqlalchemy.ext.mutable import MutableDict from sqlalchemy.ext.hybrid import hybrid_property from .dbtypes import GUID, JSONEncodedDict from .mdm import CommandStatus, Platform, commands import base64 from binascii import hexlify from biplist import Data as NSData from .profiles.certificates import KeyUsage db = SQLAlchemy() class CellularTechnology(IntEnum): Nothing = 0 GSM = 1 CDMA = 2 Both = 3 device_tags = db.Table( 'device_tags', db.metadata, db.Column('device_id', db.Integer, db.ForeignKey('devices.id')), db.Column('tag_id', db.Integer, db.ForeignKey('tags.id')), ) class Device(db.Model): """An enrolled device. :table: devices """ __tablename__ = 'devices' # Common attributes id = db.Column(db.Integer, primary_key=True) """id (int):""" udid = db.Column(db.String(40), index=True, nullable=True) """udid (str): Unique Device Identifier""" last_seen = db.Column(db.DateTime, nullable=True) """last_seen (datetime.datetime): When the device last contacted the MDM.""" is_enrolled = db.Column(db.Boolean, default=False) """is_enrolled (bool): Whether the MDM should consider this device enrolled.""" # APNS / Push topic = db.Column(db.String, nullable=True) """topic (str): The APNS topic the device is listening on.""" push_magic = db.Column(db.String, nullable=True) """push_magic (str): The UUID that establishes a unique relationship between the device and the MDM.""" # The APNS device token is stored in base64 format. Descriptors are added to handle this encoding and decoding # to bytes automatically. _token = db.Column(db.String, nullable=True) tokenupdate_at = db.Column(db.DateTime) # if null there are no outstanding push notifications. If this contains anything then dont attempt to deliver # another APNS push. last_push_at = db.Column(db.DateTime, nullable=True) """last_push_at (datetime.datetime): The datetime when the last push was sent to APNS for this device.""" last_apns_id = db.Column(db.Integer, nullable=True) """last_apns_id (str): The UUID of the last apns command sent.""" # if the time delta between last_push_at and last_seen is >= several days to a week, # this should count as a failed push, and potentially declare the device as dead. failed_push_count = db.Column(db.Integer, default=0, nullable=False) # Table 5 last_cloud_backup_date = db.Column(db.DateTime) """last_cloud_backup_date (datetime): The date of the last iCloud backup.""" awaiting_configuration = db.Column(db.Boolean) """awaiting_configuration (bool): True if device is waiting at Setup Assistant""" # Table 6 itunes_store_account_is_active = db.Column(db.Boolean) """itunes_store_account_is_active (bool): the user is currently logged into an active iTunes Store account.""" itunes_store_account_hash = db.Column(db.String) """itunes_store_account_hash (str): a hash of the iTunes Store account currently logged in.""" # DeviceInformation : Table 7 device_name = db.Column(db.String) # Authenticate """device_name (str): Name of the device""" os_version = db.Column(db.String) # Authenticate """os_version (str): The operating system version number.""" build_version = db.Column(db.String) # Authenticate """build_version (str): DeviceInformation BuildVersion""" model_name = db.Column(db.String) # Authenticate """model_name (str): Longer name of the hardware model""" model = db.Column(db.String) # Authenticate """model (str): Name of the hardware model""" product_name = db.Column(db.String) # Authenticate """product_name (str): The base product name of the hardware""" serial_number = db.Column(db.String(64), index=True, nullable=True) # Authenticate """serial_number (str): The hardware serial number""" device_capacity = db.Column(db.Float, nullable=True) """device_capacity (float): total capacity (base 1024 gigabytes)""" available_device_capacity = db.Column(db.Float, nullable=True) """device_available_capacity (float): available capacity (base 1024 gigabytes)""" battery_level = db.Column(db.Float, default=-1.0) """battery_level (float): battery level, between 0.0 and 1.0. -1.0 if information is not available.""" cellular_technology = db.Column(db.Enum(CellularTechnology)) """cellular_technology (CellularTechnology): cellular technology.""" imei = db.Column(db.String) """imei (str): IMEI number (if device is GSM).""" meid = db.Column(db.String) """meid (str): MEID number (if device is CSMA).""" modem_firmware_version = db.Column(db.String) """modem_firmware_version (str): The baseband firmware version.""" is_supervised = db.Column(db.Boolean) """is_supervised (bool): Device is supervised""" is_device_locator_service_enabled = db.Column(db.Boolean) """is_device_locator_service_enabled (bool): Find My iPhone/Mac enabled.""" is_activation_lock_enabled = db.Column(db.Boolean) """is_activation_lock_enabled (bool): Device has Activation Lock enabled.""" is_do_not_disturb_in_effect = db.Column(db.Boolean) """is_do_not_disturb_in_effect (bool): Device has DND enabled.""" device_id = db.Column(db.String) # ATV """device_id (str): Device ID (ATV)""" eas_device_identifier = db.Column(db.String) """eas_device_identifier (str): Exchange ActiveSync Identifier""" is_cloud_backup_enabled = db.Column(db.Boolean) """is_cloud_backup_enabled (bool): iCloud backup is enabled.""" local_hostname = db.Column(db.String) """local_hostname (str): """ hostname = db.Column(db.String) """hostname (str): """ sip_enabled = db.Column(db.Boolean) """sip_enabled (bool): System Integrity Protection is enabled.""" # TODO: ActiveManagedUsers is_mdm_lost_mode_enabled = db.Column(db.Boolean) """is_mdm_lost_mode_enabled (bool): MDM Lost mode is enabled.""" maximum_resident_users = db.Column(db.Integer) """maximum_resident_users (int): Maximum number of users that can use Shared iPad.""" # OSUpdateSettings : Table 8 # OSUpdateSettings is flattened osu_catalog_url = db.Column(db.String) """osu_catalog_url (str): Software Update Catalog URL.""" osu_is_default_catalog = db.Column(db.Boolean) osu_previous_scan_date = db.Column(db.DateTime) osu_previous_scan_result = db.Column(db.String) osu_perform_periodic_check = db.Column(db.Boolean) osu_automatic_check_enabled = db.Column(db.Boolean) osu_background_download_enabled = db.Column(db.Boolean) osu_automatic_app_installation_enabled = db.Column(db.Boolean) osu_automatic_os_installation_enabled = db.Column(db.Boolean) osu_automatic_security_updates_enabled = db.Column(db.Boolean) # NetworkInfo : Table 9 iccid = db.Column(db.String) """iccid (str): The ICC identifier for the SIM card.""" bluetooth_mac = db.Column(db.String) """bluetooth_mac (str): The bluetooth MAC address""" wifi_mac = db.Column(db.String) """wifi_mac (str): The WiFi MAC address""" # TODO: EthernetMACs current_carrier_network = db.Column(db.String) """current_carrier_network (str): Name of the current carrier network.""" sim_carrier_network = db.Column(db.String) """sim_carrier_network (str): Name of the home carrier network.""" subscriber_carrier_network = db.Column(db.String) """subscriber_carrier_network (str): Name of the home carrier network (replaces sim_carrier_network).""" carrier_settings_version = db.Column(db.String) """carrier_settings_version (str): Version of the current carrier settings file.""" phone_number = db.Column(db.String) """phone_number (str): Raw phone number without punctuation.""" voice_roaming_enabled = db.Column(db.Boolean) """voice_roaming_enabled (bool): Voice Roaming is enabled in settings.""" data_roaming_enabled = db.Column(db.Boolean) """data_roaming_enabled (bool): Data Roaming is enabled in settings.""" is_roaming = db.Column(db.Boolean) """is_roaming (bool): The device is currently roaming.""" personal_hotspot_enabled = db.Column(db.Boolean) """personal_hotspot_enabled (bool): Personal HotSpot is currently turned on.""" subscriber_mcc = db.Column(db.String) """subscriber_mcc (str): Home Mobile Country Code (numeric)""" subscriber_mnc = db.Column(db.String) """subscriber_mnc (str): Home Mobile Network Code (numeric)""" current_mcc = db.Column(db.String) """current_mcc (str): Current Mobile Country Code (numeric)""" current_mnc = db.Column(db.String) """current_mnc (str): Current Mobile Network Code (numeric)""" # SecurityInfo # hardware_encryption_caps = db.Column(DBEnum(HardwareEncryptionCaps)) passcode_present = db.Column(db.Boolean) """passcode_present (bool): Device has a passcode.""" passcode_compliant = db.Column(db.Boolean) """passcode_compliant (bool): The passcode is compliant with all requirements (incl Exchange accounts).""" passcode_compliant_with_profiles = db.Column(db.Boolean) """passcode_compliant_with_profiles (bool): The passcode is compliant with profile requirements.""" passcode_lock_grace_period_enforced = db.Column(db.Integer) """passcode_lock_grace_period_enforced (int): The current enforced time in seconds before unlock passcode will be required.""" fde_enabled = db.Column(db.Boolean) """fde_enabled (bool): Whether full disk encryption is enabled or not.""" fde_has_prk = db.Column(db.Boolean) """fde_has_prk (bool): Whether FDE has a personal recovery key set.""" fde_has_irk = db.Column(db.Boolean) """fde_has_irk (bool): Whether FDE has an institutional recovery key set.""" fde_personal_recovery_key_cms = db.Column(db.LargeBinary) # 10.13 """fde_personal_recovery_key_cms (bytes): If Escrow is enabled, contains the encrypted PRK""" fde_personal_recovery_key_device_key = db.Column(db.String) # 10.13 """fde_personal_recovery_key_device_key (str):""" firewall_enabled = db.Column(db.Boolean) """firewall_enabled (bool): Application firewall is enabled.""" block_all_incoming = db.Column(db.Boolean) """block_all_incoming (bool): All incoming connections are blocked.""" stealth_mode_enabled = db.Column(db.Boolean) """stealth_mode_enabled (bool): Stealth mode is enabled.""" # ActivationLockBypassCode activation_lock_escrow_key = db.Column(db.String) """activation_lock_escrow_key (str): The activation lock bypass code generated by the device""" # DEP Fetch/Sync Fields is_dep = db.Column(db.Boolean) """is_dep (bool): This device has been synced from DEP. False indicates a manual or AC2 enrolment""" description = db.Column(db.String) """description (str): The DEP description which is often identical to the SKU description on the invoice.""" color = db.Column(db.String) """color: (str): The device color indicated by DEP""" asset_tag = db.Column(db.String) """asset_tag (str): The device asset tag, if provided by Apple.""" profile_status = db.Column(db.String) """profile_status (str): The status of profile installation: empty, assigned, pushed or removed.""" profile_uuid = db.Column(db.String) """profile_uuid (str): The UUID of the assigned DEP profile""" profile_assign_time = db.Column(db.DateTime) """profile_assign_time (datetime): The date and time indicating when the DEP profile was assigned""" profile_push_time = db.Column(db.DateTime) """profile_push_time (datetime): The date and time indicating when the DEP profile was pushed.""" device_assigned_date = db.Column(db.DateTime) """device_assigned_date (datetime): The date and time the device was recorded into DEP.""" device_assigned_by = db.Column(db.String) """device_assigned_by (str): The email of the person who assigned the device.""" os = db.Column(db.String) """os (str): The device operating system returned by DEP: iOS, OSX or tvOS""" device_family = db.Column(db.String) """device_family (str): The device's Apple product family returned by DEP.""" # TODO: Blocked Applications @hybrid_property def token(self): return self._token if self._token is None else base64.b64decode(self._token) @token.setter def token(self, value): self._token = base64.b64encode(value) if value is not None else None @property def hex_token(self): """Retrieve the device token in hex encoding, necessary for the APNS2 client.""" if self._token is None: return self._token else: return hexlify(self.token).decode('utf8') certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id')) certificate = db.relationship('Certificate', backref='devices') dep_profile_id = db.Column(db.Integer, db.ForeignKey('dep_profiles.id')) dep_profile = db.relationship('DEPProfile', backref='devices') tags = db.relationship( 'Tag', secondary=device_tags, back_populates='devices' ) _unlock_token = db.Column(db.String(), name='unlock_token', nullable=True) @property def unlock_token(self): return self._unlock_token @unlock_token.setter def unlock_token(self, value): if isinstance(value, NSData): self._unlock_token = NSData.encode('base64') else: self._unlock_token = value @property def platform(self) -> Platform: if self.model_name in ['iMac', 'MacBook Pro', 'MacBook Air', 'Mac Pro']: # TODO: obviously not sufficient return Platform.macOS elif self.model_name in ['iPhone', 'iPad']: return Platform.iOS else: return Platform.Unknown def __repr__(self): return '' % (self.id, self.udid, self.serial_number) class CommandSequence(db.Model): """A command sequence represents a series of commands where all members must succeed in order for the sequence to succeed. I.E a single failure or timeout in the sequence stops the delivery of every other member. :table: command_sequences """ __tablename__ = 'command_sequences' id = db.Column(db.Integer, primary_key=True) class Command(db.Model): """The command model represents a single MDM command that should be, has been, or has failed to be delivered to a single enrolled device. :table: commands """ __tablename__ = 'commands' id = db.Column(db.Integer, primary_key=True) """id (int): ID""" request_type = db.Column(db.String, nullable=False) # string representation of our local command handler """request_type (str): The command RequestType attribute""" uuid = db.Column(GUID, index=True, unique=True, nullable=False) """uuid (GUID): Globally unique command UUID""" parameters = db.Column(MutableDict.as_mutable(JSONEncodedDict), nullable=True) # JSON add'l data as input to command builder """parameters (str): The parameters that were used when generating the command, serialized into JSON. Omitting the RequestType and CommandUUID attributes.""" status = db.Column(db.Enum(CommandStatus), index=True, nullable=False, default=CommandStatus.Queued) """status (CommandStatus): The status of the command.""" queued_at = db.Column(db.DateTime, default=datetime.datetime.utcnow(), server_default=db.text('CURRENT_TIMESTAMP')) """queued_at (datetime.datetime): The datetime (utc) of when the command was created. Defaults to UTC now""" sent_at = db.Column(db.DateTime, nullable=True) """sent_at (datetime.datetime): The datetime (utc) of when the command was delivered to the client.""" acknowledged_at = db.Column(db.DateTime, nullable=True) """acknowledged_at (datetime.datetime): The datetime (utc) of when the Acknowledged, Error or NotNow response was returned.""" # command must only be sent after this date after = db.Column(db.DateTime, nullable=True) """after (datetime.datetime): If not null, the command must not be sent until this datetime is in the past.""" # number of retries remaining until dead ttl = db.Column(db.Integer, nullable=False, default=5) """ttl (int): The number of retries remaining until the command will be dead/expired.""" device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """device_id (int): The device ID on the devices table.""" device = db.relationship('Device', backref='commands') """device (Device): The instance of the related device.""" # device_user_id = db.Column(ForeignKey('device_users.id'), nullable=True) # device_user = relationship('DeviceUser', backref='commands') @classmethod def from_model(cls, cmd: commands.Command): """This method turns a subclass of commands.Command into an SQLAlchemy model. The parameters of the command are encoded as a JSON dictionary inside the parameters column. Args: cmd (commands.Command): The command to be turned into a database model. Returns: Command: The database model, ready to be committed. """ c = cls() assert cmd.request_type is not None c.request_type = cmd.request_type c.uuid = cmd.uuid c.parameters = cmd.parameters return c @classmethod def find_by_uuid(cls, uuid: str): """Find and return an instance of the Command model matching the given UUID string. Args: uuid (str): The command UUID Returns: Command: Instance of the command, if any """ return cls.query.filter(cls.uuid == uuid).one() @classmethod def next_command(cls, device: Device): """Get the next available command in the queue for the specified device. The next available command must match these predicates: - Assigned to this device. - The status is "Queued". - The `after` field is in the past, or empty. Args: device (Device): The database model matching the device checking in. Returns: Command: The next command model to be processed. """ # d == d AND (q_status == Q OR (q_status == R AND result == 'NotNow')) return cls.query.filter(db.and_( cls.device == device, cls.status == CommandStatus.Queued.value)).order_by(cls.id).first() @classmethod def next(cls, device: Device): # type: (Type[Command], Device) -> Optional[Command] model = cls.query.filter(db.and_( cls.device == device, cls.status == CommandStatus.Queued.value)).order_by(cls.id).first() def __repr__(self): return '' % (self.id, self.uuid, self.status) class DeviceUser(db.Model): """ This model represents a managed user from the standpoint of the MDM. It exists to support the macOS user channel extension. :table: device_users """ __tablename__ = 'device_users' id = db.Column(db.Integer, primary_key=True) device_id = db.Column(db.ForeignKey('devices.id'), nullable=True) """(int): Device foreign key ID.""" device = db.relationship('Device', backref='device_users') """(db.relationship): Device relationship""" device_udid = db.Column(db.String(40), nullable=False) """(GUID): Device UDID""" user_id = db.Column(GUID, nullable=False) """user_id (GUID): Local user's GUID, or network user's GUID from Directory Record.""" long_name = db.Column(db.String) """long_name (str): The full name of the user""" short_name = db.Column(db.String) """short_name (str): The short (username) of the user""" need_sync_response = db.Column(db.Boolean) # This is kind of transitive but added anyway. user_configuration = db.Column(db.Boolean) digest_challenge = db.Column(db.String) auth_token = db.Column(db.String) class Organization(db.Model): """The MDM home organization configuration. These attributes are used as the defaults for several other services where an org name is required. Such as Certificate requests and Profile detail. :table: organizations """ __tablename__ = 'organizations' id = db.Column(db.Integer, primary_key=True) """id (int): ID""" name = db.Column(db.String) """name (string): Name""" payload_prefix = db.Column(db.String) """payload_prefix (string): The reverse-dns style prefix to use for all generated profiles.""" # http://www.ietf.org/rfc/rfc5280.txt # maximum string lengths are well defined by this RFC and this schema follows those recommendations # this x.509 name is used in the subject of the internal CA and issued certificates x509_ou = db.Column(db.String(32)) """x509_ou (string): The x.509 Organizational Unit for generating certificates.""" x509_o = db.Column(db.String(64)) """x509_o (string): The x.509 Organization for generating certificates.""" x509_st = db.Column(db.String(128)) """x509_st (string): The x.509 State for generating certificates.""" x509_c = db.Column(db.String(2)) """x509_c (string): The 2 letter x.509 country code for generating certificates. """ class DeviceIdentitySources(Enum): """A list of sources for Device Identity.""" InternalPKCS12 = 'internal_pkcs12' InternalSCEP = 'internal_scep' ExternalSCEP = 'external_scep' class SCEPConfig(db.Model): """This table holds a single row containing information used to generate the SCEP enrollment profile. :table: scep_config See Also: - `https://tools.ietf.org/html/rfc3280.html`_. """ __tablename__ = 'scep_config' id = db.Column(db.Integer, primary_key=True) source_type = db.Column(db.Enum(DeviceIdentitySources), default=DeviceIdentitySources.InternalSCEP) """source_type (DeviceIdentitySources): Specify the source used for device certificates.""" url = db.Column(db.String, nullable=False) challenge_enabled = db.Column(db.Boolean, default=False) challenge = db.Column(db.String) ca_fingerprint = db.Column(db.String) subject = db.Column(db.String, nullable=False) # eg. O=x/OU=y/CN=z key_size = db.Column(db.Integer, default=2048, nullable=False) key_type = db.Column(db.String, default='RSA', nullable=False) key_usage = db.Column(db.Enum(KeyUsage), default=KeyUsage.All) retries = db.Column(db.Integer, default=3, nullable=False) retry_delay = db.Column(db.Integer, default=10, nullable=False) certificate_renewal_time_interval = db.Column(db.Integer, default=14, nullable=False) class SubjectAlternativeNameType(Enum): """Types of SubjectAlternativeNames that can be added using cryptography SAN extension. See Also: - `https://tools.ietf.org/html/rfc3280.html`_. """ RFC822Name = 'RFC822Name' """E-mail address, see: https://tools.ietf.org/html/rfc822""" DNSName = 'DNSName' UniformResourceIdentifier = 'UniformResourceIdentifier' DirectoryName = 'DirectoryName' RegisteredID = 'RegisteredID' IPAddress = 'IPAddress' OtherName = 'OtherName' # TODO: ntPrincipal class SubjectAlternativeName(db.Model): """This table holds SANs included in the SCEP enrollment request. :table: subject_alternative_names """ __tablename__ = 'subject_alternative_names' id = db.Column(db.Integer, primary_key=True) discriminator = db.Column(db.Enum(SubjectAlternativeNameType), nullable=False) str_value = db.Column(db.String) octet_value = db.Column(db.LargeBinary) # For IPAddress class Tag(db.Model): """This table holds tags, which are categories that are many-to-many and polymorphic to different types of objects.""" __tablename__ = 'tags' id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String, nullable=False) color = db.Column(db.String(6), default='888888') # applications = db.relationship( # "Application", # secondary=application_tags, # back_populates="tags", # ) devices = db.relationship( "Device", secondary=device_tags, back_populates="tags", ) # profiles = db.relationship( # "Profiles", # secondary=profile_tags, # back_populates="tags", # ) ================================================ FILE: commandment/mutablelist.py ================================================ """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ try: from sqlalchemy.ext.mutable import MutableList except ImportError: # MutableList didn't make it into SQLAlchemy 1.0.12 # This function copied directly from SQLAlchemy source from sqlalchemy.ext.mutable import Mutable class MutableList(Mutable, list): """A list type that implements :class:`.Mutable`. The :class:`.MutableList` object implements a list that will emit change events to the underlying mapping when the contents of the list are altered, including when values are added or removed. Note that :class:`.MutableList` does **not** apply mutable tracking to the *values themselves* inside the list. Therefore it is not a sufficient solution for the use case of tracking deep changes to a *recursive* mutable structure, such as a JSON structure. To support this use case, build a subclass of :class:`.MutableList` that provides appropriate coersion to the values placed in the dictionary so that they too are "mutable", and emit events up to their parent structure. .. versionadded:: 1.1 .. seealso:: :class:`.MutableDict` :class:`.MutableSet` """ def __setitem__(self, index, value): """Detect list set events and emit change events.""" list.__setitem__(self, index, value) self.changed() def __setslice__(self, start, end, value): """Detect list set events and emit change events.""" list.__setslice__(self, start, end, value) self.changed() def __delitem__(self, index): """Detect list del events and emit change events.""" list.__delitem__(self, index) self.changed() def __delslice__(self, start, end): """Detect list del events and emit change events.""" list.__delslice__(self, start, end) self.changed() def pop(self, *arg): result = list.pop(self, *arg) self.changed() return result def append(self, x): list.append(self, x) self.changed() def extend(self, x): list.extend(self, x) self.changed() def insert(self, i, x): list.insert(self, i, x) self.changed() def remove(self, i): list.remove(self, i) self.changed() def clear(self): list.clear(self) self.changed() def sort(self): list.sort(self) self.changed() def reverse(self): list.reverse(self) self.changed() @classmethod def coerce(cls, index, value): """Convert plain list to instance of this class.""" if not isinstance(value, cls): if isinstance(value, list): return cls(value) return Mutable.coerce(index, value) else: return value def __getstate__(self): return list(self) def __setstate__(self, state): self[:] = state ================================================ FILE: commandment/omdm/__init__.py ================================================ from flask import Blueprint, current_app from uuid import uuid4 import plistlib omdm_app = Blueprint('omdm_app', __name__) @omdm_app.route('/') def omdm(): faux_command = { 'CommandUUID': str(uuid4()), 'RequestType': 'OMAlert', 'Message': 'Hello World!' } return plistlib.dumps(faux_command), {'Content-Type': 'text/xml'} ================================================ FILE: commandment/omdm/models.py ================================================ from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy() from sqlalchemy import Integer, String, ForeignKey, Table, Text, Boolean, DateTime, Enum as DBEnum, text, \ BigInteger, and_, or_, LargeBinary, Float ================================================ FILE: commandment/pkg/__init__.py ================================================ from enum import Enum class ManifestAssetKind(Enum): SoftwarePackage = 'software-package' FullSizeImage = 'full-size-image' DisplayImage = 'display-image' ================================================ FILE: commandment/pkg/appmanifest.py ================================================ import argparse from typing import List, Tuple, Optional from bixar.archive import XarFile from xml.etree import ElementTree import plistlib import hashlib import os.path Packages = List[Tuple[str, str]] Bundles = List[Tuple[str, str]] MD5_CHUNK_SIZE = 10 << 20 def blow_chunks(fileobj) -> Tuple[str, List[str]]: fileobj.seek(0) chunks = [] total_hash = hashlib.md5() for chunk in iter(lambda: fileobj.read(MD5_CHUNK_SIZE), b''): new_hash = hashlib.md5() new_hash.update(chunk) total_hash.update(chunk) chunks.append(new_hash.hexdigest()) return total_hash.hexdigest(), chunks def url_from_metadata(path: str) -> Optional[str]: """Try to determine the download URL from the spotlight attributes if the local machine is a mac.""" try: from Foundation import NSFileManager, NSPropertyListSerialization except: return None fm = NSFileManager.defaultManager() attrs, err = fm.attributesOfItemAtPath_error_(path, None) if err: return None if 'NSFileExtendedAttributes' not in attrs: return None extd_attrs = attrs['NSFileExtendedAttributes'] if 'com.apple.metadata:kMDItemWhereFroms' not in extd_attrs: return None else: plist_data: bytes = extd_attrs['com.apple.metadata:kMDItemWhereFroms'] value: List[str] = plistlib.loads(plist_data) if len(value) > 0: return value.pop(0) else: return None def main(): parser = argparse.ArgumentParser(description='Create an application manifest') parser.add_argument('source', help='Source pkg [REQUIRED!]', metavar='filename') args = parser.parse_args() archive = XarFile(path=args.source) distribution = archive.extract_bytes('Distribution') package_info = archive.extract_bytes('PackageInfo') packages: Packages = [] bundles: Bundles = [] file_size = os.path.getsize(args.source) title = os.path.basename(args.source) if distribution: el = ElementTree.fromstring(distribution) title = el.findtext('.//title') for pkgRef in el.iter('pkg-ref'): if 'version' in pkgRef.attrib: packages.append((pkgRef.attrib['id'], pkgRef.attrib['version'])) bundles = [(b.attrib['id'], b.attrib['CFBundleVersion']) for b in el.iter('bundle')] if package_info: el = ElementTree.fromstring(package_info) for pkgInfo in el.iter('pkg-info'): packages.append((pkgInfo.attrib['identifier'], pkgInfo.attrib['version'])) with open(args.source, 'rb') as fd: total_hash, chunks = blow_chunks(fd) url = url_from_metadata(args.source) manifest = { 'items': [{ 'assets': [{ 'kind': 'software-package', 'md5-size': MD5_CHUNK_SIZE, 'md5s': chunks, 'url': '{}'.format(url) if url else 'https://package/url/here.pkg' }], 'metadata': { 'kind': 'software', 'title': title, 'sizeInBytes': file_size, 'bundle-identifier': '', 'bundle-version': '' } }] } pkgs_bundles = [{'bundle-identifier': i[0], 'bundle-version': i[1]} for i in packages] manifest['items'][0]['metadata'].update(pkgs_bundles[0]) if len(bundles) > 1: manifest['items'][0]['metadata']['items'] = [{'bundle-identifier': i[0], 'bundle-version': i[1]} for i in bundles] print(plistlib.dumps(manifest).decode('utf8')) ================================================ FILE: commandment/pkg/manifest.py ================================================ from typing import List, Union import hashlib import io # Required for InstallApplication to work. DEFAULT_MD5_CHUNK_SIZE = 10485760 def chunked_hash(stream: Union[io.RawIOBase, io.BufferedIOBase], chunk_size: int = DEFAULT_MD5_CHUNK_SIZE) -> List[bytes]: """Create a list of hashes of chunk_size size in bytes. Args: stream (Union[io.RawIOBase, io.BufferedIOBase]): The steam containing the bytes to be hashed. chunk_size (int): The md5 chunk size. Default is 10485760 (which is required for InstallApplication). Returns: List[str]: A list of md5 hashes calculated for each chunk """ chunk = stream.read(chunk_size) hashes = [] while chunk is not None: h = hashlib.md5() h.update(chunk) md5 = h.digest() hashes.append(md5) chunk = stream.read(chunk_size) return hashes ================================================ FILE: commandment/pkg/old_app_manifest.py ================================================ """ Copyright (c) 2015 Jesse Peterson Licensed under the MIT license. See the included LICENSE.txt file for details. """ import subprocess from tempfile import mkdtemp import os from xml.dom.minidom import parse, parseString from hashlib import md5 import plistlib # use system PATH XAR_PATH = 'xar' MD5_CHUNK_SIZE = 1024 * 1024 * 10 # 10 MiB def pkg_signed(filename): xar_args = [XAR_PATH, '-t', # only test archive '--dump-toc=-', '-f', filename] p = subprocess.Popen(xar_args, stdout=subprocess.PIPE) toc, _ = p.communicate() if p.returncode != 0: return False toc_md = parseString(toc) # for purposes of checking just see if the xar TOC has an X509Certificate element return len(toc_md.getElementsByTagName('X509Certificate')) > 0 def get_pkg_bundle_ids(filename): '''Get metadata from Distribution or PackageInfo inside of pkg''' tmp_dir = mkdtemp() print('Extracting Distribution/PackageInfo file to', tmp_dir) xar_args = [XAR_PATH, '-x', # extract switch '--exclude', '/', # exclude any files in subdirectories '-C', tmp_dir, # extract to our temporary directory '-f', filename, # extract this specific file 'Distribution', 'PackageInfo'] # files to extract rtn = subprocess.call(xar_args) tmp_dist_file = os.path.join(tmp_dir, 'Distribution') tmp_pinf_file = os.path.join(tmp_dir, 'PackageInfo') pkgs = [] bundles = [] # for non-PackageInfo packages (use PackageInfo) if os.path.exists(tmp_dist_file): # use XML minidom to parse a Distribution file dist_md = parse(tmp_dist_file) # capture the pkg IDs and versions by searching for 'pkg-ref' elements # which include a 'version' attribute on them. append them to our list for i in dist_md.getElementsByTagName('pkg-ref'): if i.hasAttribute('version'): pkgs.append((i.getAttribute('id'), i.getAttribute('version'))) # capture the bundle IDs and versions by searching for 'bundle' # elements which we're searching for a 'CFBundleVersion' attribute on # them. append them to our list for i in dist_md.getElementsByTagName('bundle'): bundles.append((i.getAttribute('id'), i.getAttribute('CFBundleVersion'))) print('Removing Distribution file') os.unlink(tmp_dist_file) # for non-Distribution packages (use the PackageInfo) if os.path.exists(tmp_pinf_file): pinf_md = parse(tmp_pinf_file) # capture the pkg ID and version by searching for a pkg-info element # and using the identifier and version attributes for i in pinf_md.getElementsByTagName('pkg-info'): pkgs.append((i.getAttribute('identifier'), i.getAttribute('version'))) print('Removing PackageInfo file') os.unlink(tmp_pinf_file) print('Removing temp directory') os.rmdir(tmp_dir) return (pkgs, bundles) def get_chunked_md5(filename, chunksize=MD5_CHUNK_SIZE): h = md5() md5s = [] total_hash = md5() with open(filename, 'rb') as f: for chunk in iter(lambda: f.read(chunksize), b''): new_hash = md5() new_hash.update(chunk) total_hash.update(chunk) md5s.append(new_hash.hexdigest()) return (total_hash.hexdigest(), md5s) ================================================ FILE: commandment/pkg/schema.py ================================================ from marshmallow import Schema, fields class Asset(Schema): kind = fields.String(default='software-package') md5_size = fields.Integer(default=10485760) md5s = fields.List(fields.String()) url = fields.URL() needs_shine = fields.Boolean() class BundleItem(Schema): bundle_identifier = fields.String() bundle_version = fields.String() class Metadata(Schema): bundle_identifier = fields.String() bundle_version = fields.String() items = fields.Nested(BundleItem, many=True) kind = fields.String() sizeInBytes = fields.String() subtitle = fields.String() title = fields.String() class ManifestItem(Schema): assets = fields.Nested(Asset, many=True) metadata = fields.Nested(Metadata) class Manifest(Schema): items = fields.Nested(ManifestItem, many=True) ================================================ FILE: commandment/pki/ca.py ================================================ from flask import g, current_app import sqlalchemy.orm.exc from .models import CertificateAuthority from commandment.models import db, Device from commandment.pki.models import CertificateType, Certificate def get_ca() -> CertificateAuthority: if 'ca' not in g: try: ca = db.session.query(CertificateAuthority).filter_by(common_name='COMMANDMENT-CA').one() except sqlalchemy.orm.exc.NoResultFound: ca = CertificateAuthority.create() g.ca = ca return g.ca # # @current_app.teardown_appcontext # def teardown_ca(): # ca = g.pop('ca', None) ================================================ FILE: commandment/pki/models.py ================================================ """ This module contains the SQLAlchemy models for PKI related functionality. """ from enum import Enum from commandment.models import db from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from cryptography.x509 import NameOID, DNSName import datetime class CertificateAuthority(db.Model): """Certificate authority storage: database implementation. I'm loathe to create a model tied to the storage implementation but this was the easiest option at the time. """ __tablename__ = 'certificate_authority' id = db.Column(db.Integer, primary_key=True) common_name = db.Column(db.String, unique=True) serial = db.Column(db.Integer, default=0) validity_period = db.Column(db.Integer, default=365) certificate_id = db.Column(db.Integer, db.ForeignKey('certificates.id')) certificate = db.relationship('CACertificate', backref='certificate_authority') rsa_private_key_id = db.Column(db.Integer, db.ForeignKey('rsa_private_keys.id')) rsa_private_key = db.relationship('RSAPrivateKey', backref='certificate_authority') @classmethod def create(cls, common_name: str = 'COMMANDMENT-CA', key_size=2048): ca = cls() ca.common_name = common_name name = x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, common_name), x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'commandment') ]) private_key = rsa.generate_private_key( public_exponent=65537, key_size=key_size, backend=default_backend(), ) ca.rsa_private_key = RSAPrivateKey.from_crypto(private_key) db.session.add(ca.rsa_private_key) certificate = x509.CertificateBuilder().subject_name( name ).issuer_name( name ).public_key( private_key.public_key() ).serial_number( x509.random_serial_number() ).not_valid_before( datetime.datetime.utcnow() ).not_valid_after( datetime.datetime.utcnow() + datetime.timedelta(days=365) ).add_extension( x509.BasicConstraints(ca=True, path_length=None), True ).sign(private_key, hashes.SHA256(), default_backend()) ca_certificate_model = CACertificate.from_crypto(certificate) ca_certificate_model.rsa_private_key = ca.rsa_private_key ca.certificate = ca_certificate_model db.session.add(ca) db.session.commit() return ca def create_device_csr(self, common_name: str) -> (rsa.RSAPrivateKeyWithSerialization, x509.CertificateSigningRequest): """ Create a Certificate Signing Request with the specified Common Name. The private key model is automatically committed to the database. This is also true for the certificate signing request. Args: common_name (str): The certificate Common Name attribute Returns: Tuple[rsa.RSAPrivateKeyWithSerialization, x509.CertificateSigningRequest] - A tuple containing the RSA Private key that was generated, along with the CSR. """ private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) private_key_model = RSAPrivateKey.from_crypto(private_key) db.session.add(private_key_model) name = x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, common_name), x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'commandment') ]) builder = x509.CertificateSigningRequestBuilder() builder = builder.subject_name(name) builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) request = builder.sign( private_key, hashes.SHA256(), default_backend() ) csr_model = CertificateSigningRequest().from_crypto(request) csr_model.rsa_private_key = private_key_model db.session.add(csr_model) db.session.commit() return private_key, request def sign(self, request: x509.CertificateSigningRequest) -> x509.Certificate: """ Sign a Certificate Signing Request. The issued certificate is automatically persisted to the database. Args: request (x509.CertificateSigningRequest): The CSR object (cryptography) not the SQLAlchemy model. Returns: x509.Certificate: A signed certificate """ b = x509.CertificateBuilder() self.serial += 1 private_key_model = self.rsa_private_key private_key = private_key_model.to_crypto() # ca_certificate_model = self.certificate # ca_certificate = ca_certificate_model.to_crypto() name = x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, self.common_name), x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'commandment') ]) cert = b.not_valid_before( datetime.datetime.utcnow() ).not_valid_after( datetime.datetime.utcnow() + datetime.timedelta(days=self.validity_period) ).serial_number( self.serial ).issuer_name( name ).subject_name( request.subject ).public_key( request.public_key() ).sign(private_key, hashes.SHA256(), default_backend()) # cert_model = DeviceIdentityCertificate().from_crypto(cert) # db.session.add(cert_model) # db.session.commit() return cert class CertificateType(Enum): """A list of the polymorphic identities available for subclasses of Certificate. The enumerated type hints what the certificate is intended to be used for. """ CSR = 'csr' PUSH = 'mdm.pushcert' ENCRYPT = 'mdm.encrypt' WEB = 'mdm.webcrt' CA = 'mdm.cacert' DEVICE = 'mdm.device' STOKEN = 'dep.stoken' ANCHOR = 'dep.anchor' SUPERVISION = 'dep.supervision' class Certificate(db.Model): """Polymorphic base for certificate types. These certificate classes are only intended to be used for storing certificates related to running the MDM or certificates issued by the MDM internal CA or SCEP service. Note that X.509 name attributes have fixed lengths as defined in `RFC5280`_. :table: certificates .. _RFC5280: http://www.ietf.org/rfc/rfc5280.txt """ __tablename__ = 'certificates' id = db.Column(db.Integer, primary_key=True) """id (int): Primary Key""" pem_data = db.Column(db.Text, nullable=False) """pem_data (str): PEM Encoded Certificate Data""" rsa_private_key_id = db.Column(db.Integer, db.ForeignKey('rsa_private_keys.id')) """rsa_private_key_id (int): Foreign key reference to an RSAPrivateKey IF the private key was generated by us.""" rsa_private_key = db.relationship( 'RSAPrivateKey', backref='certificates', ) x509_cn = db.Column(db.String(64), nullable=True) """x509_cn (str): X.509 Common Name""" x509_ou = db.Column(db.String(32)) """x509_ou (str): X.509 Organizational Unit""" x509_o = db.Column(db.String(64)) """x509_o (str): X.509 Organization""" x509_c = db.Column(db.String(2)) """x509_c (str): X.509 2 letter Country Code""" x509_st = db.Column(db.String(128)) """x509_st (str): X.509 State or Location""" not_before = db.Column(db.DateTime(timezone=False), nullable=False) """not_before (datetime): Certificate validity - not before""" not_after = db.Column(db.DateTime(timezone=False), nullable=False) """not_after (datetime): Certificate validity - not after""" serial = db.Column(db.BigInteger) """serial (int): Serial Number""" # SHA-256 hash of DER-encoded certificate fingerprint = db.Column(db.String(64), nullable=False, index=True, unique=True) # Unique """fingerprint (str): SHA-256 hash of certificate""" push_topic = db.Column(db.String, nullable=True) # Only required for push certificate """push_topic (str): Only present for Push Certificates, the x.509 User ID field value""" discriminator = db.Column(db.String(20)) """discriminator (str): The type of certificate""" __mapper_args__ = { 'polymorphic_on': discriminator, 'polymorphic_identity': 'certificates', } @classmethod def from_crypto_type(cls, certificate: x509.Certificate, certtype: CertificateType): # type: (certtype, x509.Certificate, CertificateType) -> Certificate m = cls() m.serial = certificate.serial_number m.pem_data = certificate.public_bytes(serialization.Encoding.PEM) m.not_after = certificate.not_valid_after m.not_before = certificate.not_valid_before m.fingerprint = certificate.fingerprint(hashes.SHA256()) m.discriminator = certtype.value m.serial = str(certificate.serial_number) subject: x509.Name = certificate.subject cns = subject.get_attributes_for_oid(NameOID.COMMON_NAME) if cns is not None: m.x509_cn = cns[0].value return m class RSAPrivateKey(db.Model): """RSA Private Key Model""" __tablename__ = 'rsa_private_keys' #: id db.Column id = db.Column(db.Integer, primary_key=True) pem_data = db.Column(db.Text, nullable=False) @classmethod def from_crypto(cls, private_key: rsa.RSAPrivateKeyWithSerialization): """Convert a cryptography RSAPrivateKey object to an SQLAlchemy model.""" # type: (type, rsa.RSAPrivateKeyWithSerialization) -> RSAPrivateKey m = cls() m.pem_data = private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption(), ) return m def to_crypto(self) -> rsa.RSAPrivateKey: """Convert an SQLAlchemy RSAPrivateKey model to a cryptography RSA Private Key.""" pk = serialization.load_pem_private_key( self.pem_data, backend=default_backend(), password=None, ) return pk class CertificateSigningRequest(Certificate): """Polymorphic single table inheritance specifically for Certificate Signing Requests.""" __mapper_args__ = { 'polymorphic_identity': CertificateType.CSR.value } @classmethod def from_crypto(cls, csr: x509.CertificateSigningRequest): # type: (type, x509.CertificateSigningRequest, CertificateType) -> Certificate m = cls() m.pem_data = csr.public_bytes(serialization.Encoding.PEM) m.not_before = datetime.datetime.utcnow() m.not_after = datetime.datetime.utcnow() + datetime.timedelta(days=700) h = hashes.Hash(hashes.SHA256(), default_backend()) h.update(m.pem_data) m.fingerprint = h.finalize() m.discriminator = CertificateType.CSR.value subject: x509.Name = csr.subject cns = subject.get_attributes_for_oid(NameOID.COMMON_NAME) if cns is not None: m.x509_cn = cns[0].value return m class SSLCertificate(Certificate): """Polymorphic single table inheritance specifically for SSL certificates assigned to the MDM for HTTPS traffic.""" __mapper_args__ = { 'polymorphic_identity': CertificateType.WEB.value } class PushCertificate(Certificate): """Polymorphic single table inheritance specifically for APNS MDM Push Certificates assigned to the MDM.""" __mapper_args__ = { 'polymorphic_identity': CertificateType.PUSH.value } @classmethod def from_crypto(cls, certificate: x509.Certificate): m = Certificate.from_crypto_type(certificate, CertificateType.PUSH) return m class CACertificate(Certificate): """Polymorphic single table inheritance specifically for Certificate Authorities generated by this MDM.""" __mapper_args__ = { 'polymorphic_identity': CertificateType.CA.value } @classmethod def from_crypto(cls, certificate: x509.Certificate): # type: () -> CACertificate m = cls.from_crypto_type(certificate, CertificateType.CA) return m class DeviceIdentityCertificate(Certificate): """Polymorphic single table inheritance specifically for device identity certificates.""" __mapper_args__ = { 'polymorphic_identity': CertificateType.DEVICE.value } @classmethod def from_crypto(cls, certificate: x509.Certificate): m = cls() m.serial = certificate.serial_number m.pem_data = certificate.public_bytes(encoding=serialization.Encoding.PEM) m.not_after = certificate.not_valid_after m.not_before = certificate.not_valid_before m.fingerprint = certificate.fingerprint(hashes.SHA256()) subject: x509.Name = certificate.subject cns = subject.get_attributes_for_oid(NameOID.COMMON_NAME) if cns is not None: m.x509_cn = cns[0].value # m.x509_c = subject.get_attributes_for_oid(NameOID.COUNTRY_NAME) # m.x509_o = subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) # m.x509_ou = subject.get_attributes_for_oid(NameOID.ORGANIZATIONAL_UNIT_NAME) # m.x509_st = subject.get_attributes_for_oid(NameOID.STATE_OR_PROVINCE_NAME) return m class EncryptionCertificate(Certificate): """Polymorphic single table inheritance specifically for Encryption Certificates""" __mapper_args__ = { 'polymorphic_identity': CertificateType.ENCRYPT.value } @classmethod def from_crypto(cls, certificate: x509.Certificate): # TODO: sometimes serial numbers are too large even for SQLite BIGINT m = cls() m.serial = certificate.serial_number m.pem_data = certificate.public_bytes(encoding=serialization.Encoding.PEM) m.not_after = certificate.not_valid_after m.not_before = certificate.not_valid_before m.fingerprint = certificate.fingerprint(hashes.SHA256()) subject: x509.Name = certificate.subject cns = subject.get_attributes_for_oid(NameOID.COMMON_NAME) if cns is not None: m.x509_cn = cns[0].value return m ================================================ FILE: commandment/pki/openssl.py ================================================ # Regrettably, some functionality must come from PyOpenSSL from typing import Optional from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from cryptography.hazmat.primitives import serialization import OpenSSL def create_pkcs12( private_key: rsa.RSAPrivateKeyWithSerialization, certificate: x509.Certificate, passphrase: Optional[str] = None) -> Optional[bytes]: """Create a PKCS#12 container from the given RSA key and Certificate.""" p12 = OpenSSL.crypto.PKCS12() pkey = OpenSSL.crypto.PKey.from_cryptography_key(private_key) p12.set_privatekey(pkey) cert = OpenSSL.crypto.X509.from_cryptography(certificate) p12.set_certificate(cert) return p12.export(passphrase) ================================================ FILE: commandment/pki/ormutils.py ================================================ from typing import Optional from asn1crypto.cms import ContentInfo, EnvelopedData, KeyTransRecipientInfo, RecipientIdentifier from commandment.pki.models import Certificate def find_recipient(cms_data: bytes) -> Optional[Certificate]: """Find the Certificate + Private Key of a recipient indicated by encoded CMS/PKCS#7 data from the database and return the database model that matches (if any). Requires that the indicated recipient is present in the `certificates` table, and has a matching private key in the `rsa_private_keys` table. """ content_info = ContentInfo.load(cms_data) assert content_info['content_type'].native == 'enveloped_data' content: EnvelopedData = content_info['content'] for recipient_info in content['recipient_infos']: if recipient_info.name == 'ktri': # KeyTransRecipientInfo recipient: KeyTransRecipientInfo = recipient_info.chosen recipient_id: RecipientIdentifier = recipient['rid'] assert recipient_id.name == 'issuer_and_serial_number' else: pass # Unsupported recipient type return None ================================================ FILE: commandment/pki/serialization.py ================================================ from cryptography.hazmat.backends import default_backend from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import rsa from asn1crypto import pkcs12, pem, x509 as asn1x509 # cryptography helper functions def from_pem(pem_data: str) -> x509.Certificate: return x509.load_pem_x509_certificate(pem_data, default_backend()) def from_der(der_data: bytes) -> x509.Certificate: return x509.load_der_x509_certificate(der_data, default_backend()) def rsa_from_der(rsa_der_data: bytes, password: str = None) -> rsa.RSAPrivateKeyWithSerialization: return serialization.load_der_private_key( rsa_der_data, password, default_backend() ) def rsa_from_pem(rsa_pem_data: bytes, password: str = None) -> rsa.RSAPrivateKeyWithSerialization: return serialization.load_pem_private_key( rsa_pem_data, password, default_backend() ) def rsa_to_pem(key: rsa.RSAPrivateKeyWithSerialization) -> str: return key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) def to_pem(certificate: x509.Certificate) -> str: """Convert an instance of x509.Certificate to a PEM string Args: certificate (x509.Certificate): Cert to convert Returns: PEM string """ serialized = certificate.public_bytes( encoding=serialization.Encoding.PEM ) return serialized def to_der(certificate: x509.Certificate) -> bytes: """Convert an instance of x509.Certificate to DER bytes Args: certificate (x509.Certificate): Cert to convert Returns: DER bytes """ serialized = certificate.public_bytes( encoding=serialization.Encoding.DER, format=serialization.PublicFormat.PKCS8, encryption_algorithm=serialization.NoEncryption() ) return serialized ================================================ FILE: commandment/pki/ssl.py ================================================ import datetime from typing import Optional from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa from cryptography import x509 from cryptography.x509 import NameOID, DNSName def generate_signing_request(cn: str, dnsname: Optional[str] = None) -> (rsa.RSAPrivateKey, x509.CertificateSigningRequest): """Generate a Private Key + Certificate Signing Request using the given dnsname as the CN and SAN dNSName. Args: cn (str): The certificate common name dnsname (str): The public facing dns name of the MDM server. Returns: Tuple of rsa private key, csr """ private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) name = x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'commandment') ]) builder = x509.CertificateSigningRequestBuilder() builder = builder.subject_name(name) if dnsname is not None: san = x509.SubjectAlternativeName([ x509.DNSName(dnsname) ]) builder = builder.add_extension(san, critical=True) builder = builder.add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) request = builder.sign( private_key, hashes.SHA256(), default_backend() ) return private_key, request def generate_self_signed_certificate(cn: str) -> (rsa.RSAPrivateKey, x509.Certificate): """Generate an X.509 Certificate with the given Common Name. Args: cn (string): """ name = x509.Name([ x509.NameAttribute(NameOID.COMMON_NAME, cn), x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'commandment') ]) private_key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) certificate = x509.CertificateBuilder().subject_name( name ).issuer_name( name ).public_key( private_key.public_key() ).serial_number( x509.random_serial_number() ).not_valid_before( datetime.datetime.utcnow() ).not_valid_after( datetime.datetime.utcnow() + datetime.timedelta(days=365) ).add_extension( x509.SubjectAlternativeName([ DNSName(cn) ]), False ).sign(private_key, hashes.SHA256(), default_backend()) return private_key, certificate ================================================ FILE: commandment/plistutil/__init__.py ================================================ ================================================ FILE: commandment/plistutil/nonewriter.py ================================================ from io import BytesIO from plistlib import _PlistWriter, FMT_XML, _FORMATS class PlistNoneWriter(_PlistWriter): """This subclass of PlistWriter writes out XML formatted property lists, but specifically skips any key for which its value is **None**.""" def write_dict(self, d): if d: self.begin_element("dict") if self._sort_keys: items = sorted(d.items()) else: items = d.items() for key, value in items: if not isinstance(key, str): if self._skipkeys: continue raise TypeError("keys must be strings") if value is None: continue self.simple_element("key", key) self.write_value(value) self.end_element("dict") else: self.simple_element("dict") # These methods are copied here for convenience def dump(value, fp, *, fmt=FMT_XML, sort_keys=True, skipkeys=False): """Write 'value' to a .plist file. 'fp' should be a (writable) file object. """ if fmt not in _FORMATS: raise ValueError("Unsupported format: %r"%(fmt,)) writer = PlistNoneWriter(fp, sort_keys=sort_keys, skipkeys=skipkeys) writer.write(value) def dumps(value, *, fmt=FMT_XML, skipkeys=False, sort_keys=True): """Return a bytes object with the contents for a .plist file. """ fp = BytesIO() dump(value, fp, fmt=fmt, skipkeys=skipkeys, sort_keys=sort_keys) return fp.getvalue() ================================================ FILE: commandment/profiles/__init__.py ================================================ """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ from enum import Enum PROFILE_CONTENT_TYPE = 'application/x-apple-aspen-config' class PayloadScope(Enum): User = 'User' System = 'System' ================================================ FILE: commandment/profiles/ad.py ================================================ from typing import Set from enum import Enum, Flag, auto class ADMountStyle(Enum): AFP = 'afp' SMB = 'smb' class ADNamespace(Enum): Domain = 'domain' Forest = 'forest' class ADOption(Flag): CreateMobileAccountAtLogin = auto() WarnUserBeforeCreatingMobileAccount = auto() ForceHomeLocal = auto() UseWindowsUNCPath = auto() AllowMultiDomainAuth = auto() ADOptions = Set[ADOption] class ADPacketSignPolicy(Enum): Allow = 'allow' Disable = 'disable' Require = 'require' class ADPacketEncryptPolicy(Enum): Allow = 'allow' Disable = 'disable' Require = 'require' SSL = 'ssl' class ADCertificateAcquisitionMechanism(Enum): RPC = 'RPC' HTTP = 'HTTP' ================================================ FILE: commandment/profiles/api.py ================================================ from flask import Blueprint from flask_rest_jsonapi import Api from commandment.profiles.resources import ProfilesList, ProfileDetail, ProfileRelationship profiles_api_app = Blueprint('profiles_api', __name__) api = Api(blueprint=profiles_api_app) # Profiles (Different to profiles returned by inventory) api.route(ProfilesList, 'profiles_list', '/v1/profiles') api.route(ProfileDetail, 'profile_detail', '/v1/profiles/') api.route(ProfileRelationship, 'profile_tags', '/v1/profiles//relationships/tags') # api.route(PayloadsList, 'payloads_list', '/v1/payloads') # api.route(PayloadDetail, 'payload_detail', '/v1/payloads/') ================================================ FILE: commandment/profiles/certificates.py ================================================ """ Copyright (c) 2015 Jesse Peterson, 2017 Mosen Licensed under the MIT license. See the included LICENSE.txt file for details. """ from enum import IntFlag from marshmallow import Schema, fields, post_load, post_dump class KeyUsage(IntFlag): """Intended key usage flag. Used in SCEP payload.""" Signing = 1 Encryption = 4 All = Signing | Encryption ================================================ FILE: commandment/profiles/eap.py ================================================ from typing import Set from enum import Enum, IntEnum class EAPTypes(IntEnum): """EAP Types accepted by the EAPClient. See Also: EAP8021X, EAP.h:51 """ Invalid = 0 Identity = 1 Notification = 2 Nak = 3 MD5Challenge = 4 OneTimePassword = 5 GenericTokenCard = 6 TLS = 13 CiscoLEAP = 17 EAP_SIM = 18 SRP_SHA1 = 19 TTLS = 21 EAP_AKA = 23 PEAP = 25 MSCHAPv2 = 26 Extensions = 33 EAP_FAST = 43 EAP_AKA_Prime = 50 AcceptEAPTypes = Set[EAPTypes] class TTLSInnerAuthentication(Enum): PAP = 'PAP' CHAP = 'CHAP' MSCHAP = 'MSCHAP' MSCHAPv2 = 'MSCHAPv2' EAP = 'EAP' ================================================ FILE: commandment/profiles/email.py ================================================ from enum import Enum class EmailAccountType(Enum): POP = 'EmailTypePOP' IMAP = 'EmailTypeIMAP' class EmailAuthenticationType(Enum): Password = 'EmailAuthPassword' CRAM_MD5 = 'EmailAuthCRAMMD5' NTLM = 'EmailAuthNTLM' HTTP_MD5 = 'EmailAuthHTTPMD5' ENone = 'EmailAuthNone' ================================================ FILE: commandment/profiles/energy.py ================================================ from enum import Enum, IntFlag, auto class ScheduledPowerEventType(Enum): wake = 'wake' wakepoweron = 'wakepoweron' sleep = 'sleep' shutdown = 'shutdown' restart = 'restart' class ScheduledPowerEventWeekdays(IntFlag): def _generate_next_value_(name, start, count, last_values): return 2 ** count Monday = auto() Tuesday = auto() Wednesday = auto() Thursday = auto() Friday = auto() Saturday = auto() Sunday = auto() All = Monday | Tuesday | Wednesday | Thursday | Friday | Saturday | Sunday ================================================ FILE: commandment/profiles/models.py ================================================ from commandment.profiles import PayloadScope from commandment.profiles.certificates import KeyUsage from ..dbtypes import GUID, JSONEncodedDict from uuid import uuid4 from ..models import db class Payload(db.Model): __tablename__ = 'payloads' id = db.Column(db.Integer, primary_key=True) type = db.Column(db.String, index=True, nullable=False) version = db.Column(db.Integer, default=1) identifier = db.Column(db.String) uuid = db.Column(GUID, index=True, default=uuid4(), nullable=False) display_name = db.Column(db.String) description = db.Column(db.Text) organization = db.Column(db.String) # Dependencies should be tracked in cases where the payload refers to another required payload. # eg. a reference to certificate payload in an 802.1x configuration. # depends_on = relationship("Payload", # secondary=payload_dependencies, # backref="dependents") __mapper_args__ = { 'polymorphic_identity': 'payload', 'polymorphic_on': type, } class MDMPayload(Payload): id = db.Column(db.Integer, db.ForeignKey('payloads.id'), primary_key=True) identity_certificate_uuid = db.Column(GUID, nullable=False) topic = db.Column(db.String, nullable=False) server_url = db.Column(db.String, nullable=False) server_capabilities = db.Column(db.String) sign_message = db.Column(db.Boolean) check_in_url = db.Column(db.String) check_out_when_removed = db.Column(db.Boolean) access_rights = db.Column(db.Integer) use_development_apns = db.Column(db.Boolean) __mapper_args__ = { 'polymorphic_identity': 'com.apple.mdm' } class SCEPPayload(Payload): id = db.Column(db.Integer, db.ForeignKey('payloads.id'), primary_key=True) url = db.Column(db.String, nullable=False) name = db.Column(db.String, nullable=True) subject = db.Column(JSONEncodedDict, nullable=False) # eg. O=x/OU=y/CN=z challenge = db.Column(db.String, nullable=True) key_size = db.Column(db.Integer, default=2048, nullable=False) ca_fingerprint = db.Column(db.LargeBinary, nullable=True) key_type = db.Column(db.String, default='RSA', nullable=False) key_usage = db.Column(db.Enum(KeyUsage), default=KeyUsage.All) subject_alt_name = db.Column(db.String, nullable=True) retries = db.Column(db.Integer, default=3, nullable=False) retry_delay = db.Column(db.Integer, default=10, nullable=False) certificate_renewal_time_interval = db.Column(db.Integer, default=14, nullable=False) __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.scep', } class CertificatePayload(Payload): id = db.Column(db.Integer, db.ForeignKey('payloads.id'), primary_key=True) certificate_file_name = db.Column(db.String) payload_content = db.Column(db.LargeBinary) password = db.Column(db.String) __mapper_args__ = { 'polymorphic_identity': 'certificate' } class PEMCertificatePayload(CertificatePayload): __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.pem' } class DERCertificatePayload(CertificatePayload): __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.pkcs1' } class PKCS12CertificatePayload(CertificatePayload): __mapper_args__ = { 'polymorphic_identity': 'com.apple.security.pkcs12' } profile_payloads = db.Table('profile_payloads', db.metadata, db.Column('profile_id', db.Integer, db.ForeignKey('profiles.id')), db.Column('payload_id', db.Integer, db.ForeignKey('payloads.id'))) profile_tags = db.Table('profile_tags', db.metadata, db.Column('profile_id', db.Integer, db.ForeignKey('profiles.id')), db.Column('tag_id', db.Integer, db.ForeignKey('tags.id')), ) class Profile(db.Model): """Top level profile. See Also: - `Configuration Profile Keys `_. Attributes: """ __tablename__ = 'profiles' id = db.Column(db.Integer, primary_key=True) data = db.Column(db.LargeBinary) description = db.Column(db.Text) display_name = db.Column(db.String) expiration_date = db.Column(db.DateTime) # Only for old style OTA identifier = db.Column(db.String, nullable=False) organization = db.Column(db.String) uuid = db.Column(GUID, index=True, default=uuid4()) removal_disallowed = db.Column(db.Boolean) version = db.Column(db.Integer, default=1) payload_type = db.Column(db.String, default='Configuration') scope = db.Column(db.Enum(PayloadScope), default=PayloadScope.User.value) removal_date = db.Column(db.DateTime) duration_until_removal = db.Column(db.BigInteger) consent_en = db.Column(db.Text) is_encrypted = db.Column(db.Boolean, default=False) payloads = db.relationship('Payload', secondary=profile_payloads, backref='profiles') tags = db.relationship('Tag', secondary=profile_tags, backref='profiles') ================================================ FILE: commandment/profiles/plist_schema.py ================================================ """ This module defines marshmallow schemas for use in converting .mobileconfig (plist) representations into SQLAlchemy model representations. """ from typing import Union, Callable, Type, List, Dict from marshmallow import Schema, fields, post_load, post_dump from marshmallow_enum import EnumField from commandment.profiles import models from commandment.profiles.certificates import KeyUsage from . import PayloadScope _schemas: Dict[str, Schema] = {} """Hold all registered schemas by their PayloadType.""" def schema_for(payload_type: str) -> Union[None, Type[Schema]]: """Get a class that represents the marshmallow schema for a payload, using the payload type. Args: payload_type (str): The value of PayloadType Returns: None or a class that represents a schema for that payload. """ return _schemas.get(payload_type, None) def register_payload_schema(*args) -> Callable[[Type[Schema]], Type[Schema]]: """Decorate a Payload schema to register its type. For use with schema_for.""" def wrapper(cls: Type[Schema]) -> Type[Schema]: for payload_type in args: _schemas[payload_type] = cls return cls return wrapper class Payload(Schema): PayloadType = fields.Str(attribute='type', required=True) PayloadVersion = fields.Integer(attribute='version', default=1) PayloadIdentifier = fields.String(attribute='identifier') PayloadUUID = fields.UUID(attribute='uuid') PayloadDisplayName = fields.String(attribute='display_name') PayloadDescription = fields.String(attribute='description') PayloadOrganization = fields.String(attribute='organization') @register_payload_schema('Profile Service') class ProfileServicePayload(Schema): URL = fields.URL() DeviceAttributes = fields.String(many=True) Challenge = fields.String() class ConsentTextSchema(Schema): en = fields.String(attribute='consent_en') @register_payload_schema('com.apple.security.pem', 'com.apple.security.root', 'com.apple.security.pkcs1', 'com.apple.security.pkcs12') class CertificatePayloadSchema(Payload): PayloadCertificateFileName = fields.Str(attribute='certificate_file_name') PayloadContent = fields.Raw(attribute='payload_content') Password = fields.Str(attribute='password') @register_payload_schema('com.apple.security.scep') class SCEPPayload(Payload): URL = fields.URL(attribute='url') Name = fields.String(attribute='name') # Subject = fields.Nested() Challenge = fields.String(attribute='challenge') Keysize = fields.Integer(attribute='key_size') CAFingerprint = fields.String(attribute='ca_fingerprint') KeyType = fields.String(attribute='key_type') KeyUsage = EnumField(KeyUsage, attribute='key_usage', by_value=True) # SubjectAltName = fields.Dict(attribute='subject_alt_name') Retries = fields.Integer(attribute='retries') RetryDelay = fields.Integer(attribute='retry_delay') @post_dump(pass_many=False) def wrap_payload_content(self, data: dict) -> dict: """SCEP Payload is silly and double wraps its PayloadContent item.""" inner_content = { 'URL': data.pop('URL', None), 'Name': data.pop('Name'), 'Challenge': data.pop('Challenge'), 'Keysize': data.pop('Keysize'), 'CAFingerprint': data.pop('CAFingerprint'), 'KeyType': data.pop('KeyType'), 'KeyUsage': data.pop('KeyUsage'), 'Retries': data.pop('Retries'), 'RetryDelay': data.pop('RetryDelay'), } data['PayloadContent'] = inner_content return data @post_load def make_payload(self, data: dict) -> models.SCEPPayload: return models.SCEPPayload(**data) @register_payload_schema('com.apple.mdm') class MDMPayload(Payload): IdentityCertificateUUID = fields.UUID(attribute='identity_certificate_uuid', required=True) Topic = fields.String(attribute='topic', required=True) ServerURL = fields.URL(attribute='server_url', required=True) # ServerCapabilities = fields.Nested(many=True) SignMessage = fields.Boolean(attribute='sign_message') CheckInURL = fields.String(attribute='check_in_url') CheckOutWhenRemoved = fields.Boolean(attribute='check_out_when_removed') AccessRights = fields.Integer(attribute='access_rights') UseDevelopmentAPNS = fields.Boolean(attribute='use_development_apns') @post_load def make_payload(self, data: dict) -> models.MDMPayload: return models.MDMPayload(**data) class ProfileSchema(Schema): PayloadDescription = fields.Str(attribute='description') PayloadDisplayName = fields.Str(attribute='display_name') PayloadExpirationDate = fields.DateTime(attribute='expiration_date') PayloadIdentifier = fields.Str(attribute='identifier', required=True) PayloadOrganization = fields.Str(attribute='organization') PayloadUUID = fields.UUID(attribute='uuid') PayloadRemovalDisallowed = fields.Bool(attribute='removal_disallowed') PayloadType = fields.Function(lambda obj: 'Configuration', attribute='payload_type') PayloadVersion = fields.Function(lambda obj: 1, attribute='version') PayloadScope = EnumField(PayloadScope, attribute='scope') RemovalDate = fields.DateTime(attribute='removal_date') DurationUntilRemoval = fields.Float(attribute='duration_until_removal') ConsentText = fields.Nested(ConsentTextSchema()) PayloadContent = fields.Method('get_payloads', deserialize='load_payloads') def get_payloads(self, obj): payloads = [] for payload in obj.payloads: schema = schema_for(payload.type) if schema is not None: result = schema().dump(payload) payloads.append(result.data) else: print('Unsupported PayloadType: {}'.format(payload.type)) return payloads def load_payloads(self, payload_content: list) -> List[Schema]: payloads = [] for content in payload_content: schema = schema_for(content['PayloadType']) if schema is not None: result = schema().load(content) payloads.append(result.data) else: print('Unsupported PayloadType: {}'.format(content['PayloadType'])) return payloads @post_load def make_profile(self, data): payloads = data.pop('PayloadContent', []) p = models.Profile(**data) # for pl in payloads: # p.payloads.append(pl) return p ================================================ FILE: commandment/profiles/resources.py ================================================ from flask_rest_jsonapi import ResourceDetail, ResourceList, ResourceRelationship from commandment.models import db from commandment.profiles.models import Profile from commandment.profiles.schema import ProfileSchema class ProfilesList(ResourceList): schema = ProfileSchema data_layer = { 'session': db.session, 'model': Profile } class ProfileDetail(ResourceDetail): schema = ProfileSchema data_layer = { 'session': db.session, 'model': Profile, 'url_field': 'profile_id' } class ProfileRelationship(ResourceRelationship): schema = ProfileSchema data_layer = { 'session': db.session, 'model': Profile, 'url_field': 'profile_id' } ================================================ FILE: commandment/profiles/schema.py ================================================ from marshmallow_jsonapi import fields from marshmallow_jsonapi.flask import Relationship, Schema from marshmallow import Schema as FlatSchema, post_load class ProfileSchema(Schema): class Meta: type_ = 'profiles' self_view = 'profiles_api.profile_detail' self_view_kwargs = {'profile_id': ''} self_view_many = 'profiles_api.profiles_list' id = fields.Int(dump_only=True) data = fields.String() description = fields.Str() display_name = fields.Str() expiration_date = fields.DateTime() identifier = fields.Str() organization = fields.Str() uuid = fields.UUID() removal_disallowed = fields.Boolean() version = fields.Int() scope = fields.Str() removal_date = fields.DateTime() duration_until_removal = fields.Int() consent_en = fields.Str() tags = Relationship( related_view='api_app.tag_detail', related_view_kwargs={'tag_id': ''}, many=True, schema='TagSchema', type_='tags' ) ================================================ FILE: commandment/profiles/vpn.py ================================================ from enum import Enum class VPNType(Enum): L2TP = 'L2TP' PPTP = 'PPTP' IPSec = 'IPSec' IKEv2 = 'IKEv2' AlwaysOn = 'AlwaysOn' VPN = 'VPN' ================================================ FILE: commandment/profiles/wifi.py ================================================ from enum import Enum class WIFIEncryptionType(Enum): ENone = 'None' Any = 'Any' WPA2 = 'WPA2' WPA = 'WPA' WEP = 'WEP' class WIFIProxyType(Enum): ENone = 'None' Manual = 'Manual' Auto = 'Auto' ================================================ FILE: commandment/signals.py ================================================ from blinker import Namespace signals = Namespace() # Sent when a device enrolls for the first time, or re-enrols after checking out device_enrolled = signals.signal('device-enrolled') # Sent when a device voluntarily checks out device_unenrolled = signals.signal('device-unenrolled') # Sent when a device checks in, including: Authenticate, TokenUpdate, Acknowledge, NotNow device_checkin = signals.signal('device-checkin') # If APNS tells us that a device token expired device_token_expired = signals.signal('device-token-expired') ================================================ FILE: commandment/static/.gitignore ================================================ app.js *.map fonts/* css/* images/* ================================================ FILE: commandment/static/index.dev.html ================================================ commandment
================================================ FILE: commandment/static/index.html ================================================ commandment
================================================ FILE: commandment/storage/.gitignore ================================================ * !.gitignore ================================================ FILE: commandment/templates/index.html ================================================ {# The URL that assets will be loaded from if running from webpack-dev-server #} {% set webpack_dev_url = 'https://localhost:4000' %} {% block head %} {% if config['DEBUG'] == False %} {% endif %} commandment {% endblock %}
loading {{ config['DEBUG'] }}
================================================ FILE: commandment/threads/__init__.py ================================================ ================================================ FILE: commandment/threads/startup_thread.py ================================================ """ This thread should run delayed, once at startup to initialise the internal CA and self-signed certificates to provide a baseline configuration for messing around with. """ import threading import logging import datetime import os from flask_alembic import Alembic from oscrypto.keys import parse_pkcs12 from commandment.models import db from commandment.pki.models import RSAPrivateKey, CertificateSigningRequest, CACertificate from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization, hashes from cryptography import x509 from cryptography.x509.oid import NameOID import sqlalchemy from commandment.pki.ca import get_ca from flask import Flask startup_thread = None startup_delay = 1.0 logger = logging.getLogger('startup thread') def generate_ca(app: Flask): """Generate internal CA certificate for sandbox setups.""" with app.app_context(): app.logger.info('Generating Internal CA if necessary...') ca = get_ca() # Implicit creation of `certificate_authority` row and certificates def split_pkcs12(app: Flask): """Split up .p12 containers if necessary.""" with app.app_context(): if 'PUSH_CERTIFICATE' not in app.config: app.logger.warn('No push certificate specified, you will not be able to manage devices until this is configured') return push_certificate_path = app.config['PUSH_CERTIFICATE'] if not os.path.exists(push_certificate_path): raise RuntimeError('You specified a push certificate at: {}, but it does not exist.'.format(push_certificate_path)) # We can handle loading PKCS#12 but APNS2Client specifically requests PEM encoded certificates push_certificate_basename, ext = os.path.splitext(push_certificate_path) if ext.lower() == '.p12': pem_key_path = push_certificate_basename + '.key' pem_certificate_path = push_certificate_basename + '.crt' if not os.path.exists(pem_key_path) or not os.path.exists(pem_certificate_path): app.logger.info('You provided a PKCS#12 push certificate, we will have to encode it as PEM to continue...') app.logger.info('.key and .crt files will be saved in the same location: %s, %s', pem_key_path, pem_certificate_path) with open(push_certificate_path, 'rb') as fd: if 'PUSH_CERTIFICATE_PASSWORD' in app.config: key, certificate, intermediates = parse_pkcs12(fd.read(), bytes(app.config['PUSH_CERTIFICATE_PASSWORD'], 'utf8')) else: key, certificate, intermediates = parse_pkcs12(fd.read()) try: crypto_key = serialization.load_der_private_key(key.dump(), None, default_backend()) with open(pem_key_path, 'wb') as fd: fd.write(crypto_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, encryption_algorithm=serialization.NoEncryption())) crypto_cert = x509.load_der_x509_certificate(certificate.dump(), default_backend()) with open(pem_certificate_path, 'wb') as fd: fd.write(crypto_cert.public_bytes(serialization.Encoding.PEM)) except PermissionError: app.logger.error('Could not write out .key or .crt file. You will not be able to push APNS messages') app.logger.error('This means your MDM is BROKEN until you fix permissions') else: app.logger.info('.p12 already split into PEM/KEY components') def run_migrations(app: Flask): """Run the database migrations.""" with app.app_context(): app.logger.info('Running Alembic Migrations') alembic = Alembic() alembic.init_app(app, run_mkdir=False) alembic.upgrade('head') def startup_callback(app: Flask): """Run the StartUp Thread jobs""" logger.debug("Started Thread: Startup") split_pkcs12(app) run_migrations(app) generate_ca(app) def start(app: Flask): """Start the StartUp thread""" logger.info('Startup thread will run in 5 seconds') startup_thread = threading.Timer(startup_delay, startup_callback, [app]) startup_thread.daemon = True startup_thread.start() ================================================ FILE: commandment/threads/vpp_thread.py ================================================ """ This thread should synchronise available licenses """ ================================================ FILE: commandment/utils.py ================================================ from flask import current_app import plistlib def plistify(*args, **kwargs): """Similar to jsonify, which ships with Flask, this function wraps plistlib.dumps and sets up the correct mime type for the response.""" if args and kwargs: raise TypeError('plistify() behavior undefined when passed both args and kwargs') elif len(args) == 1: # single args are passed directly to dumps() data = args[0] else: data = args or kwargs mimetype = kwargs.get('mimetype', current_app.config['PLISTIFY_MIMETYPE']) return current_app.response_class( (plistlib.dumps(data), '\n'), mimetype=mimetype ) ================================================ FILE: commandment/vpp/__init__.py ================================================ from flask import g, current_app from commandment.vpp.errors import VPPError from commandment.vpp.vpp import VPP def get_vpp() -> VPP: vpp = getattr(g, '_vpp', None) if vpp is None: if 'VPP_STOKEN' not in current_app.config: raise VPPError('VPP stoken not configured') g._vpp = VPP(current_app.config['VPP_STOKEN']) return vpp ================================================ FILE: commandment/vpp/app.py ================================================ from flask import Blueprint, jsonify, g, current_app, request, abort from flask_rest_jsonapi import Api from commandment.vpp.models import db, VPPAccount from commandment.vpp.schema import VPPAccountSchema vpp_app = Blueprint('vpp_app', __name__) api = Api(blueprint=vpp_app) @vpp_app.route('/api/v1/vpp/token', methods=['GET']) def token(): """Retrieve information about the current VPP token. :resheader Content-Type: application/json :statuscode 200: :statuscode 404: No VPP token has been uploaded """ account = db.session.query(VPPAccount).first() schema = VPPAccountSchema() result = schema.dumps(account) if result.errors: abort(500) else: return result.data, 200, {'Content-Type': 'application/json'} @vpp_app.route('/api/v1/vpp/upload/token', methods=['POST']) def upload_token(): """Upload the VPP service token in the format normally issued by vpp.itunes.apple.com. :reqheader Accept: application/octet-stream :resheader Content-Type: application/json :statuscode 201: VPP token stored successfully. :statuscode 400: The request did not contain a valid VPP token. """ if 'file' not in request.files: abort(400, 'no file uploaded in request data') f = request.files['file'] if not f.content_type == 'application/octet-stream': abort(400, 'incorrect MIME type in request') data = f.read() account = VPPAccount(stoken=data) db.session.add(account) db.session.commit() return '{}', 201, {'Content-Type': 'application/json'} ================================================ FILE: commandment/vpp/cli.py ================================================ ================================================ FILE: commandment/vpp/decorators.py ================================================ import functools from commandment.vpp.errors import VPPAPIError def raise_error_replies(f): """Decorator which wraps a function that returns the dict representing a direct response body from the VPP service. The reply is checked for VPP errors and, if there are any errors, the error is raised as a VPPAPIError exception. """ @functools.wraps(f) def wrapper(*args, **kwargs): reply = f(*args, **kwargs) if reply['status'] == -1: # VPP Error occurred raise VPPAPIError(reply['errorNumber'], reply['errorMessage']) return reply return wrapper ================================================ FILE: commandment/vpp/enum.py ================================================ from typing import Tuple from enum import Enum, IntEnum class VPPPricingParam(Enum): """Valid values for the VPP pricingParam argument.""" StandardQuality = 'STDQ' """str: Standard Quality""" HighQuality = 'PLUS' """str: High Quality - Does not apply to Software""" class VPPUserStatus(Enum): """Valid values for the status of a VPP registered user.""" Registered = 'Registered' """str: Registered""" Associated = 'Associated' """str: Associated""" Retired = 'Retired' """str: Retired (can still be changed back)""" Deleted = 'Deleted' """str: Deleted""" AdamID = str PricingParam = str VPPAsset = Tuple[AdamID, PricingParam] """VPPAsset: A tuple representing a pair of product adam id and pricing parameter.""" class LicenseAssociationType(Enum): """Valid types of license association operations which are mutually exclusive in a single batch.""" ClientUserID = 'ClientUserID' """str: Associate user to license by Client ID""" SerialNumber = 'SerialNumber' """str: Associate device to license by Serial Number""" LicenseAssociation = Tuple[LicenseAssociationType, AdamID] """LicenseAssociation: A tuple representing a combination of a product by adam id and a type of association operation""" class LicenseDisassociationType(Enum): """Valid types of license disassociation operations which are mutually exclusive in a single batch.""" ClientUserID = 'ClientUserID' """str: Disassociate license from user by Client ID""" SerialNumber = 'SerialNumber' """str: Disassociate license from device by Serial Number""" LicenseID = 'LicenseID' """str: Disassociate license by ID regardless of User/Device""" LicenseDisassociation = Tuple[LicenseDisassociationType, AdamID] """LicenseDisassociation: A tuple representing a combination of a product by adam id and a type of disassociation operation""" class VPPProductType(IntEnum): """A VPP product type. Required by some of the VPP API""" Software = 7 """int: An piece of software""" Application = 8 """int: Don't ask me""" Publication = 10 """int: An ebook""" ================================================ FILE: commandment/vpp/errors.py ================================================ from enum import IntEnum class VPPErrorType(IntEnum): """An enumeration representation of all (currently) possible error codes returned by the VPP API.""" MissingArgument = 9600 LoginRequired = 9601 InvalidArgument = 9602 InternalError = 9603 ResultNotFound = 9604 AccountStorefrontIncorrect = 9605 ErrorConstructingToken = 9606 LicenseIrrevocable = 9607 EmptyResponseFromSharedData = 9608 UserNotFound = 9609 LicenseNotFound = 9610 AdminNotFound = 9611 FailedCreatingClaimJob = 9612 FailedCreatingUnclaimJob = 9613 InvalidDateFormat = 9614 OrgCountryNotFound = 9615 LicenseAlreadyAssigned = 9616 UserAlreadyRetired = 9618 LicenseNotAssociated = 9619 UserAlreadyDeleted = 9620 TokenExpired = 9621 InvalidAuthenticationToken = 9622 InvalidAPNSToken = 9623 LicenseRefunded = 9624 STokenRevoked = 9625 LicenseAlreadyAssignedUser = 9626 DeviceAssignmentNotAllowed = 9628 TooManyAssignmentErrors = 9630 TooManyNoLicenseErrors = 9631 TooManyDuplicateAssignments = 9632 DataBatchUnrecoverable = 9633 Deprecated = 9634 AppleIDInvalid = 9635 RegisteredUserNotFound = 9636 STokenPermissionDenied = 9637 FacilitatorHasNoManagedID = 9638 FacilitatorMemberIDNotFound = 9639 FacilitatorDetailsNotAvailable = 9640 class VPPError(Exception): """Generic error used when the service returns an error of any kind""" pass class VPPAPIError(VPPError): """If the VPP API returns an error code, it is raised using this error class. Attributes: errno (int): The errorNumber message (str): The error message """ def __init__(self, errno, message): self.errno = errno self.message = message ================================================ FILE: commandment/vpp/models.py ================================================ from ..dbtypes import GUID, JSONEncodedDict from .enum import VPPUserStatus, VPPPricingParam, VPPProductType from ..models import db from sqlalchemy.ext.hybrid import hybrid_property, hybrid_method import base64 import json import dateutil.parser class VPPAccount(db.Model): __tablename__ = 'vpp_accounts' id = db.Column(db.Integer, primary_key=True) @hybrid_property def stoken(self) -> str: return self._stoken @stoken.setter def stoken(self, value: str): self._stoken = value decoded = base64.b64decode(value) data = json.loads(decoded) self.exp_date = dateutil.parser.parse(data['expDate']) self.org_name = data['orgName'] _stoken = db.Column(db.String, nullable=False) exp_date = db.Column(db.DateTime) """datetime: Populated for convenience when checking the VPP token expiry date.""" org_name = db.Column(db.String) """string: Populated for convenience.""" # at least one of these must be null at all times licenses_since_modified_token = db.Column(db.String) licenses_batch_token = db.Column(db.String) users_since_modified_token = db.Column(db.String) users_batch_token = db.Column(db.String) # ASM/ABM Location Information location_id = db.Column(db.Integer) location_name = db.Column(db.String) class VPPUser(db.Model): __tablename__ = 'vpp_users' user_id = db.Column(db.Integer, primary_key=True) client_user_id = db.Column(GUID, nullable=False) email = db.Column(db.String) status = db.Column(db.Enum(VPPUserStatus)) invite_url = db.Column(db.String) invite_code = db.Column(db.String) class VPPLicense(db.Model): __tablename__ = 'vpp_licenses' license_id = db.Column(db.Integer, primary_key=True) adam_id = db.Column(db.String) product_type = db.Column(db.Enum(VPPProductType)) product_type_name = db.Column(db.String) pricing_param = db.Column(db.Enum(VPPPricingParam)) is_irrevocable = db.Column(db.Boolean) user_id = db.Column(db.ForeignKey('vpp_users.user_id')) client_user_id = db.Column(db.ForeignKey('vpp_users.client_user_id')) its_id_hash = db.Column(db.String) ================================================ FILE: commandment/vpp/schema.py ================================================ from marshmallow import Schema, fields class VPPAccountSchema(Schema): exp_date = fields.DateTime() org_name = fields.String() ================================================ FILE: commandment/vpp/vpp.py ================================================ # -*- coding: utf-8 -*- """ Volume Purchase Programme Support TODO: - Not all error conditions are unit tested. - Only the license cursor has been tested. - The license assignment method remains untested. """ import requests from typing import List, Optional, Iterator, Tuple, Dict, Text, Any import json import base64 from commandment.vpp.decorators import raise_error_replies from commandment.vpp.enum import LicenseAssociation, LicenseDisassociation, LicenseAssociationType, \ LicenseDisassociationType, VPPPricingParam SERVICE_CONFIG_URL = 'https://vpp.itunes.apple.com/WebObjects/MZFinance.woa/wa/VPPServiceConfigSrv' """str: The default production URL to fetch VPP service configuration from.""" def encode_stoken(token: dict) -> bytes: """Encode a dict containing the sToken properties into a base64 token for use with VPP. Args: token (dict): Token containing the 'token', 'expDate', and 'orgName' fields. Returns: bytes: Base64 encoded token. """ return base64.urlsafe_b64encode(json.dumps(token, separators=(',', ':')).encode('utf8')) class VPPCursor(object): """Generic base class for operations on endpoints that require a token to retrieve multiple pages of records. Attributes: _current (dict): Current results. _vpp (VPP): Instance of the VPP object that generated this cursor. """ @property def batch_count(self) -> Optional[int]: """Optional[int]: Number of records returned in this batch.""" return self._current.get('batchCount', None) @property def total(self) -> Optional[int]: """Optional[int]: Number of records in total that will be returned.""" return self._current.get('totalCount', None) @property def batch_token(self) -> Optional[str]: """Optional[str]: The batch token, if a batch fetch is in progress and is not complete.""" return self._current.get('batchToken', None) @property def since_modified_token(self) -> Optional[str]: """Optional[str]: The since modified token, if a batch fetch is not in progress, but a fetch has been made.""" return self._current.get('sinceModifiedToken', None) def __init__(self, since_modified_token: str = None, vpp=None) -> None: self._current: Dict[Text, Any] = {} if since_modified_token is not None: self._current['sinceModifiedToken'] = since_modified_token self._vpp = vpp class VPPUserCursor(VPPCursor): """VPPUserCursor represents a batch fetch operation on the `getVPPUsersSrv` endpoint. Attributes: includes_retired (bool): This fetch operation includes users that have been marked as *Retired*. """ @property def users(self) -> Optional[List[dict]]: """Optional[List[dict]]: The current set of users in the cursor result, or None if there are no results.""" return self._current.get('users', None) def __init__(self, includes_retired: bool = True, vpp=None) -> None: super(VPPUserCursor, self).__init__(vpp=vpp) self.includes_retired = includes_retired def next(self): """ Returns: next VPPUserCursor or None when batch is exhausted """ if self.batch_token is not None: next_cursor = self._vpp.users(batch_token=self.batch_token) next_cursor.includes_retired = self.includes_retired return next_cursor else: return None class VPPLicenseCursor(VPPCursor): """VPPLicenseCursor represents a batch fetch operation on the `getVPPLicensesSrv` endpoint. """ @property def licenses(self) -> Optional[List[dict]]: """Optional[List[dict]]: The current set of licenses in the cursor result, or None if there are no results.""" return self._current.get('licenses', None) def __init__(self, *args, **kwargs) -> None: super(VPPLicenseCursor, self).__init__(*args, **kwargs) def next(self): """ Returns: next VPPLicenseCursor or None when batch is exhausted """ if self.batch_token is not None: next_cursor = self._vpp.licenses(batch_token=self.batch_token) self._current = next_cursor._current return self else: return None class VPPLicenseOperation(object): """VPPLicenseOperation represents a number of license operations on a single Adam ID (iTunes Store Product). Attributes: _association_type (LicenseAssociationType): This specifies the type of association this license operation represents. The API only accepts one of these in a single request. _disassociation_type (LicenseDisassociationType): This specifies the type of disassociation this license operation represents. The API only accepts one of these in a single request. """ # _vpp: VPP @property def adam_id(self) -> int: return self._adam_id @property def pricing_param(self) -> str: return self._pricing_param @property def associations(self) -> Tuple[LicenseAssociationType, List[LicenseAssociation]]: return self._association_type, self._associate @property def disassociations(self) -> Tuple[LicenseDisassociationType, List[LicenseDisassociation]]: return self._disassociation_type, self._disassociate def __init__(self, adam_id: int, pricing_param: str = 'STDQ', license_association_type: Optional[LicenseAssociationType] = None, license_disassociation_type: Optional[LicenseDisassociationType] = None) -> None: self._adam_id = adam_id self._pricing_param = pricing_param self._associate: List[LicenseAssociation] = [] self._disassociate: List[LicenseDisassociation] = [] self._association_type = license_association_type self._disassociation_type = license_disassociation_type def add(self, association_type: LicenseAssociationType, value: str): if self._association_type is None: self._association_type = association_type elif association_type != self._association_type: raise ValueError('You cannot specify two different types of association in a license operation.') self._associate.append((association_type, value)) def additions_for_type(self, association_type: LicenseAssociationType) -> Iterator[LicenseAssociation]: return filter(lambda x: x[0] == association_type, self._associate) def remove(self, disassociation_type: LicenseDisassociationType, value: str): if self._disassociation_type is None: self._disassociation_type = disassociation_type elif disassociation_type != self._disassociation_type: raise ValueError('You cannot specify two different types of disassociation in a license operation.') self._disassociate.append((disassociation_type, value)) def removals_for_type(self, disassociation_type: LicenseDisassociationType) -> Iterator[LicenseDisassociation]: return filter(lambda x: x[0] == disassociation_type, self._disassociate) class VPPUserLicenseOperation(VPPLicenseOperation): """This object represents a batch operation on a license which will be associated to or disassociated from an MDM user. AKA VPP User License Assignment. Args: adam_id (int): The Adam ID of the iTunes Store asset to manage. pricing_param (str): The pricing parameter, defaults to 'STDQ' (Standard Quality) """ def __init__(self, *args, **kwargs) -> None: super(VPPUserLicenseOperation, self).__init__(*args, **kwargs) self._association_type = LicenseAssociationType.ClientUserID self._disassociation_type = LicenseDisassociationType.ClientUserID class VPPDeviceLicenseOperation(VPPLicenseOperation): """This object represents a batch operation on a license which will be associated to or disassociated from a Device Serial Number. AKA VPP Device License Assignment. Args: adam_id (int): The Adam ID of the iTunes Store asset to manage. pricing_param (str): The pricing parameter, defaults to 'STDQ' (Standard Quality) """ def __init__(self, *args, **kwargs) -> None: super(VPPDeviceLicenseOperation, self).__init__(*args, **kwargs) self._association_type = LicenseAssociationType.SerialNumber self._disassociation_type = LicenseDisassociationType.SerialNumber class VPP(object): """ VPP Object. The main VPP API wrapper class. Attributes: VPP.AssociationProperties (dict): Mapping of the LicenseAssociationType enum to the expected JSON keys in the request. VPP.DisassociationProperties (dict): Mapping of the LicenseDisassociationType enum to the expected JSON keys in the request. """ AssociationProperties = { LicenseAssociationType.ClientUserID: 'associateClientUserIdStrs', LicenseAssociationType.SerialNumber: 'associateSerialNumbers' } DisassociationProperties = { LicenseDisassociationType.SerialNumber: 'disassociateSerialNumbers', LicenseDisassociationType.ClientUserID: 'disassociateClientUserIdStrs', LicenseDisassociationType.LicenseID: 'disassociateLicenseIdStrs', } def __init__(self, stoken: str, vpp_service_config_url: str = SERVICE_CONFIG_URL, service_config: dict = None) -> None: """ The VPP class is a wrapper around a requests session and provides an API for interacting with Apple's VPP service. Args: stoken (str): Service Token vpp_service_config_url (str): URL to the VPPServiceConfigSrv endpoint. defaults to Apple's live server. service_config (dict): Dictionary containing service config, if you do not want to fetch it (testing only). """ self._session = requests.Session() self._session.headers.update({'Content-Type': 'application/json'}) self._stoken = stoken if not service_config: fetched_service_config = self._fetch_config(vpp_service_config_url) self._service_config = fetched_service_config else: self._service_config = service_config def _fetch_config(self, service_config_url: str) -> dict: """Fetch the service configuration from Apple, which contains all of the URLs required for VPP. Args: service_config_url (str): The VPPServiceConfigSrv URL to use """ res = self._session.get(service_config_url) return res.json() @raise_error_replies def register_user(self, client_user_id: str, email: str = None, facilitator_member_id: str = None, managed_apple_id: str = None): """ Register an MDM user with VPP. Args: client_user_id (str): A unique string, usually a UUID to identify the user in the MDM. email (str): The e-mail address of the user. facilitator_member_id (str): Currently unused managed_apple_id (str): Currently unused Returns: dict: Containing the decoded body of the reply from the VPP service, eg:: { "status": 0, "user": { "userId": 2878111686099947, "email": "vpp-test@localhost", "status": "Registered", "inviteUrl": "http://localhost:8080/D1971F9DD5F8E67BDD", "inviteCode": "D1971F9DD5F8E67BDD", "clientUserIdStr": "F33D9E0F-CDE3-427E-A444-B137BEF9EFA2" } } """ res = self._session.post(self._service_config['registerUserSrvUrl'], data=json.dumps({ 'clientUserIdStr': client_user_id, 'email': email, 'sToken': self._stoken, })) return res.json() @raise_error_replies def get_user(self, client_user_id: str = None, its_id_hash: str = None, facilitator_member_id: str = None, user_id: int = None): """ Get the status of a user by their unique ID. Args: client_user_id (str): A unique string, usually a UUID to identify the user in the MDM. You can use this OR the user_id to identify the user. its_id_hash (str): (Optional) iTunes Store ID hash facilitator_member_id: user_id (int): User ID which uniquely identifies the user with the iTunes store. Returns: dict: Containing the reply from the service. """ request_body = {'sToken': self._stoken} if user_id is not None: request_body['userId'] = user_id else: request_body['clientUserIdStr'] = client_user_id if its_id_hash is not None: request_body['itsIdHash'] = its_id_hash res = self._session.post(self._service_config['getUserSrvUrl'], data=json.dumps(request_body)) return res.json() def users(self, include_retired: int = 1, facilitator_member_id: str = None, batch_token: str = None, since_modified_token: str = None) -> VPPUserCursor: """ Args: include_retired (int): 0 - do not include retired users, 1 - include retired users facilitator_member_id: Currently unused batch_token (str): Batch token (if being called from a cursor) since_modified_token (str): Since modified token (if requesting a time delta) Returns: """ request_body = {'sToken': self._stoken} if include_retired == 1: request_body['includeRetired'] = 1 if batch_token is not None: request_body['batchToken'] = batch_token elif since_modified_token is not None: request_body['sinceModifiedToken'] = since_modified_token res = self._session.post(self._service_config['getUsersSrvUrl'], data=json.dumps(request_body)) results = res.json() cursor = VPPUserCursor(includes_retired=(include_retired == 1)) cursor._current = results cursor._vpp = self return cursor @raise_error_replies def retire_user(self, client_user_id: str = None, facilitator_member_id: str = None, user_id: str = None): """ Unregister a user from VPP. Args: client_user_id (str): A unique string, usually a UUID to identify the user in the MDM. You can use this OR the user_id to identify the user. facilitator_member_id: Currently unused user_id (int): User ID which uniquely identifies the user with the iTunes store. Returns: dict: Containing the reply from the service. """ request_body = {'sToken': self._stoken} if user_id is not None: request_body['userId'] = user_id else: request_body['clientUserIdStr'] = client_user_id res = self._session.post(self._service_config['retireUserSrvUrl'], data=json.dumps(request_body)) return res.json() @raise_error_replies def edit_user(self, client_user_id: str = None, facilitator_member_id: str = None, email: str = None, managed_apple_id: str = None, user_id: str = None): """ Edit a user's VPP record. Args: client_user_id (str): A unique string, usually a UUID to identify the user in the MDM. You can use this OR the user_id to identify the user. facilitator_member_id: Currently unused email (str): Supply an E-mail address to update the current address. user_id (int): User ID which uniquely identifies the user with the iTunes store. managed_apple_id (str): Managed Apple ID Returns: dict: Containing the reply from the service. """ request_body = {'sToken': self._stoken} if user_id is not None: request_body['userId'] = user_id else: request_body['clientUserIdStr'] = client_user_id if email is not None: request_body['email'] = email if managed_apple_id is not None: request_body['managedAppleIDStr'] = managed_apple_id res = self._session.post(self._service_config['editUserSrvUrl'], data=json.dumps(request_body)) return res.json() @raise_error_replies def assets(self, include_license_counts: bool = True, facilitator_member_id: str = None) -> List[dict]: """ Get assets for which the organization has licenses. Args: include_license_counts (bool): Include counts of total/assigned/unassigned licenses. facilitator_member_id: Currently unused Returns: List[dict]: List of VPP assets for which this organization has licenses. """ request_body = { 'sToken': self._stoken, 'includeLicenseCounts': include_license_counts, } res = self._session.post(self._service_config['getVPPAssetsSrvUrl'], data=json.dumps(request_body)) return res.json() def manage(self, adam_id: int, pricing_param: str = 'STDQ') -> VPPLicenseOperation: """Manage VPP licenses for the given Adam ID. Args: adam_id (str): The Adam ID pricing_param (str): The pricing param defaults to 'STDQ' but may be 'PLUS' for things which aren't software. Returns: VPPLicenseOperation: an instance of a VPP license operation which can be modified to add or remove devices, and then submitted. """ op = VPPLicenseOperation(adam_id, pricing_param) op._vpp = self return op def manage_user_licenses(self, adam_id: int, pricing_param: str = 'STDQ') -> VPPUserLicenseOperation: """Manage VPP User License Assignment. Args: adam_id (str): The Adam ID pricing_param (str): The pricing param defaults to 'STDQ' but may be 'PLUS' for things which aren't software. Returns: VPPUserLicenseOperation: an instance of a VPP license operation which can be modified to add or remove license associations by user client id """ op = VPPUserLicenseOperation(adam_id, pricing_param) op._vpp = self return op def manage_device_licenses(self, adam_id: int, pricing_param: str = 'STDQ') -> VPPDeviceLicenseOperation: """Manage VPP Device License Assignment. Args: adam_id (str): The Adam ID pricing_param (str): The pricing param defaults to 'STDQ' but may be 'PLUS' for things which aren't software. Returns: VPPDeviceLicenseOperation: an instance of a VPP license operation which can be modified to add or remove license associations by device serial number """ op = VPPDeviceLicenseOperation(adam_id, pricing_param) op._vpp = self return op def licenses(self, adam_id: int = None, pricing_param: Optional[VPPPricingParam] = None, assigned_only: bool = False, facilitator_member_id: str = None, batch_token: str = None, since_modified_token: str = None) -> VPPLicenseCursor: """Retrieve a list of licenses matching the supplied criteria. Args: adam_id (int): Get licenses that match this Adam ID pricing_param (Optional[VPPPricingParam]): Get licenses that match this 'Quality' param. assigned_only (bool): Return only licenses that are assigned to users, if this value is true. facilitator_member_id (str): Currently unused batch_token (str): Supplied if there are more results to fetch. since_modified_token (str): Supplied if you want to fetch results modified since a certain date. This will be supplied on the last page of your most recent set of results. Returns: VPPLicenseCursor: A cursor that can be used to fetch all remaining results, pre-populated with the first page. """ request_body = {'sToken': self._stoken} if assigned_only: request_body['assignedOnly'] = True if batch_token: request_body['batchToken'] = batch_token if since_modified_token: request_body['sinceModifiedToken'] = since_modified_token # These parameters are normally ignored if a batch/modified token is supplied. if batch_token is None and since_modified_token is None: if adam_id is not None: request_body['adamId'] = adam_id if pricing_param is not None: request_body['pricingParam'] = pricing_param.value res = self._session.post(self._service_config['getLicensesSrvUrl'], data=json.dumps(request_body)) reply = res.json() cursor = VPPLicenseCursor(vpp=self) cursor._current = reply return cursor def save(self, operation: VPPLicenseOperation, notify: bool = False) -> dict: """Execute a license management operation, represented by a VPPLicenseOperation or subclass. This provides a more convenient interface than bulk_update_licenses. Args: operation (VPPLicenseOperation): The license operation to perform. notify (bool): Optional. Notify devices of license disassociation. Returns: dict: Reply from the license endpoint. """ atype, associations = operation.associations dtype, disassociations = operation.disassociations request_body = { 'sToken': self._stoken, 'adamIdStr': operation.adam_id, 'pricingParam': operation.pricing_param, 'notifyDisassociation': notify, VPP.AssociationProperties[atype]: associations, VPP.DisassociationProperties[dtype]: disassociations, } res = self._session.post(self._service_config['manageVPPLicensesByAdamIdSrvUrl'], data=json.dumps(request_body)) reply = res.json() return reply def bulk_update_licenses(self, adam_id: int, association_type: Optional[LicenseAssociationType] = None, associate: Optional[List[str]] = None, disassociation_type: Optional[LicenseDisassociationType] = None, disassociate: Optional[List[str]] = None, pricing_param: str = 'STDQ', notify: bool = False) -> dict: """Perform a batch operation of license associations and disassociations. Args: adam_id (int): Adam ID - The iTunes Store Product for which licenses will be managed. association_type (Optional[LicenseAssociationType]): Provide an association type if associate length > 0 associate (Optional[List[str]]): A list of values that will be used to associate licenses, corresponding to the association_type disassociation_type (Optional[LicenseDisassociationType]): Provide a disassociation type if disassociate length > 0. disassociate (Optional[List[str]]): A list of values that will be used to disassociate licenses, corresponding to the association_type pricing_param (str): Defaults to Standard Quality 'STDQ' notify (bool): Notify disassociation, default is False See Also: - manageVPPLicensesByAdamIdSrv """ request_body = { 'sToken': self._stoken, 'adamIdStr': adam_id, 'pricingParam': pricing_param, 'notifyDisassociation': notify, } if association_type in VPP.AssociationProperties: request_body[VPP.AssociationProperties[association_type]] = associate if disassociation_type in VPP.DisassociationProperties: request_body[VPP.DisassociationProperties[disassociation_type]] = disassociate res = self._session.post(self._service_config['manageVPPLicensesByAdamIdSrvUrl'], data=json.dumps(request_body)) reply = res.json() return reply ================================================ FILE: doc/.gitignore ================================================ _build ================================================ FILE: doc/Makefile ================================================ # Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " applehelp to make an Apple Help Book" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " epub3 to make an epub3" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" @echo " text to make text files" @echo " man to make manual pages" @echo " texinfo to make Texinfo files" @echo " info to make Texinfo files and run them through makeinfo" @echo " gettext to make PO message catalogs" @echo " changes to make an overview of all changed/added/deprecated items" @echo " xml to make Docutils-native XML files" @echo " pseudoxml to make pseudoxml-XML files for display purposes" @echo " linkcheck to check all external links for integrity" @echo " doctest to run all doctests embedded in the documentation (if enabled)" @echo " coverage to run coverage check of the documentation (if enabled)" @echo " dummy to check syntax errors of document sources" .PHONY: clean clean: rm -rf $(BUILDDIR)/* .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." .PHONY: singlehtml singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." .PHONY: pickle pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." .PHONY: json json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." .PHONY: htmlhelp htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." .PHONY: qthelp qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/commandment.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/commandment.qhc" .PHONY: applehelp applehelp: $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp @echo @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." @echo "N.B. You won't be able to view it unless you put it in" \ "~/Library/Documentation/Help or install it in your application" \ "bundle." .PHONY: devhelp devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/commandment" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/commandment" @echo "# devhelp" .PHONY: epub epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." .PHONY: epub3 epub3: $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 @echo @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." .PHONY: latex latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." .PHONY: latexpdf latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." $(MAKE) -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: latexpdfja latexpdfja: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through platex and dvipdfmx..." $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." .PHONY: text text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." .PHONY: man man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." .PHONY: texinfo texinfo: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." @echo "Run \`make' in that directory to run these through makeinfo" \ "(use \`make info' here to do that automatically)." .PHONY: info info: $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo @echo "Running Texinfo files through makeinfo..." make -C $(BUILDDIR)/texinfo info @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." .PHONY: gettext gettext: $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale @echo @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." .PHONY: changes changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." .PHONY: linkcheck linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." .PHONY: doctest doctest: $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest @echo "Testing of doctests in the sources finished, look at the " \ "results in $(BUILDDIR)/doctest/output.txt." .PHONY: coverage coverage: $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage @echo "Testing of coverage in the sources finished, look at the " \ "results in $(BUILDDIR)/coverage/python.txt." .PHONY: xml xml: $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml @echo @echo "Build finished. The XML files are in $(BUILDDIR)/xml." .PHONY: pseudoxml pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." .PHONY: dummy dummy: $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy @echo @echo "Build finished. Dummy builder generates no files." ================================================ FILE: doc/_static/config/nginx-commandment.conf ================================================ server { listen 7443 ssl; ssl_certificate /usr/local/commandment/server.crt; ssl_certificate_key /usr/local/commandment/server.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; root /usr/local/commandment/commandment/static; index index.html; access_log /usr/local/commandment/log/commandment-access.log; error_log /usr/local/commandment/log/commandment-error.log; location /api { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:/usr/local/var/run/uwsgi-commandment.sock; } location /enroll { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:/usr/local/var/run/uwsgi-commandment.sock; } location /checkin { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:/usr/local/var/run/uwsgi-commandment.sock; } location /mdm { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:/usr/local/var/run/uwsgi-commandment.sock; } location /scep { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:/usr/local/var/run/uwsgi-commandment.sock; } location / { try_files $uri /index.html; } location /static { alias /usr/local/commandment/commandment/static; } } ================================================ FILE: doc/_static/config/uwsgi-commandment.ini ================================================ [uwsgi] base = /usr/local/commandment pythonpath = %(base) module = commandment:create_app() home = /usr/local/commandment/virtualenv # This might be different if you used pipenv to install the dependencies eg. # home = /Users//.local/share/virtualenvs/commandment- plugins = python3 env = COMMANDMENT_SETTINGS=/usr/local/commandment/settings.cfg # This is necessary to make multi-threading / multi-processing not fail on High Sierra with # `+[__NSPlaceholderDate initialize] may have been in progress in another thread when fork() was called.` env = OBJC_DISABLE_INITIALIZE_FORK_SAFETY=YES master = true processes = 4 enable-threads = true socket = /usr/local/var/run/uwsgi-commandment.sock chmod-socket = 660 die-on-term = true logto = /usr/local/commandment/log/uwsgi-commandment.log ================================================ FILE: doc/_static/uml/checkin.puml ================================================ @startuml actor Device boundary MDM entity DeviceModel Device -> MDM: Authenticate message MDM -> EnrollPolicy: Check whitelist EnrollPolicy -> MDM: Device passed MDM -> DeviceModel: Update Attributes MDM -> DeviceModel: Clear Token MDM -> Device: 200 "OK" Device -> MDM: TokenUpdate message @enduml ================================================ FILE: doc/_static/uml/commandqueue.puml ================================================ @startuml start :device has commands; :at least one command is "Queued" status; :command does not have "after" date; end @enduml ================================================ FILE: doc/_static/uml/models/Certificate.plantuml ================================================ @startuml skinparam defaultFontName Courier Class certificates { INTEGER ★ id INTEGER ☆ rsa_private_key_id VARCHAR[20] ⚪ discriminator VARCHAR[64] ⚪ fingerprint DATETIME ⚪ not_after DATETIME ⚪ not_before TEXT ⚪ pem_data VARCHAR ⚪ push_topic VARCHAR[2] ⚪ x509_c VARCHAR[64] ⚪ x509_cn VARCHAR[64] ⚪ x509_o VARCHAR[32] ⚪ x509_ou VARCHAR[128] ⚪ x509_st INDEX[fingerprint] » ix_certificates_fingerprint } right footer generated by sadisplay v0.4.8 @enduml ================================================ FILE: doc/_static/uml/models/Command.plantuml ================================================ @startuml skinparam defaultFontName Courier Class commands { INTEGER ★ id INTEGER ☆ device_id DATETIME ⚪ acknowledged_at DATETIME ⚪ after TEXT ⚪ parameters DATETIME ⚪ queued_at VARCHAR ⚪ request_type DATETIME ⚪ sent_at VARCHAR[1] ⚪ status INTEGER ⚪ ttl CHAR[32] ⚪ uuid INDEX[status] » ix_commands_status INDEX[uuid] » ix_commands_uuid } right footer generated by sadisplay v0.4.8 @enduml ================================================ FILE: doc/_static/uml/models/InstalledApplication.plantuml ================================================ @startuml skinparam defaultFontName Courier Class installed_applications { INTEGER ★ id INTEGER ☆ device_id VARCHAR ⚪ bundle_identifier BIGINT ⚪ bundle_size CHAR[32] ⚪ device_udid BIGINT ⚪ dynamic_size BOOLEAN ⚪ is_validated VARCHAR ⚪ name VARCHAR ⚪ short_version VARCHAR ⚪ version INDEX[bundle_identifier] » ix_installed_applications_bundle_identifier INDEX[device_udid] » ix_installed_applications_device_udid INDEX[version] » ix_installed_applications_version } right footer generated by sadisplay v0.4.8 @enduml ================================================ FILE: doc/_static/uml/models/InstalledCertificate.plantuml ================================================ @startuml skinparam defaultFontName Courier Class installed_certificates { INTEGER ★ id INTEGER ☆ device_id BLOB ⚪ der_data CHAR[32] ⚪ device_udid VARCHAR[64] ⚪ fingerprint_sha256 BOOLEAN ⚪ is_identity VARCHAR ⚪ x509_cn INDEX[device_udid] » ix_installed_certificates_device_udid INDEX[fingerprint_sha256] » ix_installed_certificates_fingerprint_sha256 } right footer generated by sadisplay v0.4.8 @enduml ================================================ FILE: doc/_static/uml/models/InstalledProfile.plantuml ================================================ @startuml skinparam defaultFontName Courier Class installed_profiles { INTEGER ★ id INTEGER ☆ device_id CHAR[32] ⚪ device_udid BOOLEAN ⚪ has_removal_password BOOLEAN ⚪ is_encrypted VARCHAR ⚪ payload_description VARCHAR ⚪ payload_display_name VARCHAR ⚪ payload_identifier VARCHAR ⚪ payload_organization BOOLEAN ⚪ payload_removal_disallowed CHAR[32] ⚪ payload_uuid INDEX[device_udid] » ix_installed_profiles_device_udid INDEX[payload_uuid] » ix_installed_profiles_payload_uuid } right footer generated by sadisplay v0.4.8 @enduml ================================================ FILE: doc/about-mdm.rst ================================================ About MDM ========= This section is intended to give you some basic knowledge around how MDM works, so that you understand why some of the prerequisites exist. Setting up an MDM requires a few different certificates: - To prove to Apple that you're allowed to use their Push Service (APNS). - To prove that your MDM isn't being impersonated (TLS). - To prove that someone isn't impersonating an Apple Device connecting to your MDM (SCEP/Identity Certificate). .. note:: I'm glossing over a lot of detail to give you a general sense of the requirements. By far, the best in-depth explanation is the MicroMDM Blog post by Jesse Peterson on `Understanding MDM Certificates `_. APNS MDM Certificate -------------------- Apple devices listen for Push Notifications, sent via Apple's Push Notification Service [#f1]_. The notifications you send from your MDM are used to poke the devices, which contact your MDM in turn. .. uml:: :align: center "Joe's iPad" <-> "Apple Push Notification Service": Listening to MDM channel MDM -> "Apple Push Notification Service": Push to "Joe's iPad" "Apple Push Notification Service" -> "Joe's iPad": Hey, contact the MDM! "Joe's iPad" -> MDM: Give me the next command! To send MDM push notifications, you will need a special Push Certificate issued by Apple. There are several ways to get one: - Apply for an `Apple Enterprise Developer `_ account (US$300/year), enabling the MDM Vendor option. You can then use this account to sign push certificate requests. The MDM Vendor option is now available as a checkbox when you apply for the account. - Have an MDM vendor, or someone with that account sign the CSR for you. `mdmcert.download `_ is one such service. - Extract the *com.apple.mgmt.* certificate from a previously installed copy of **Server.app** TLS/Web Certificate ------------------- The MDM protocol requires a secure encrypted connection between your devices and your MDM. The TLS certificate on your MDM is just like any other web server, so all the same methods apply for getting one of these certificates. It's recommended to purchase an SSL certificate that will already be trusted by your devices. You can also use an Enterprise CA, as long as you understand that there's an extra step to allow your devices to trust the CA. .. warning:: If you are using a self-signed SSL certificate, or your Enterprise CA won't automatically be trusted by your devices, then you need to make sure your devices trust the certificate. This is normally done by pushing a trust profile or including trust information in the enrollment profile. commandment has an option to bundle these certificates with the enrollment profile. Device Identity Certificate --------------------------- The MDM protocol requires that each device enrolled with the MDM has its own certificate. There are two options for providing the identity certificate: - Include the Identity certificate in the enrollment payloads. - Contact a SCEP service to issue a certificate when the device enrolls. The second option is always the preferred method, since it allows you to use whatever existing infrastructure you have for issuing certificates. If you are testing commandment you can use `SCEPy `_ as your SCEP server. This is provided as part of commandment for testing out of the box, but I would strongly encourage you to use a commercial solution for SCEP. .. rubric:: Footnotes .. [#f1] `Push Notification Developer Guide `_. ================================================ FILE: doc/api/certificates.rst ================================================ Certificates ============ JSON-API Endpoints ------------------ .. http:get:: /api/v1/certificates/ Retrieve a list of all metadata about certificates stored in the system. :query page[size]: The number of items to return for each page :query page[number]: The page to fetch :query filter[]: An array of filtering rules :query sort: A list of fields (separated by comma) to sort ascending. Mark with a **-** minus to denote descending sort. :reqheader Accept: application/vnd.api+json :resheader Content-Type: application/vnd.api+json .. http:post:: /api/v1/certificates/ Add a new certificate to the system. .. http:get:: /api/v1/certificates/(int:certificate_id) Retrieve metadata about the certificate (`certificate_id`) **Example request**: .. sourcecode:: http GET /api/v1/certificates/1 HTTP/1.1 Accept: application/vnd.api+json **Example response** .. sourcecode:: http HTTP/1.1 200 OK Content-Type: application/vnd.api+json { "data": [{ "type": "certificates", "attributes": { "not_after": "2018-03-26T23:42:09+00:00", "pem_certificate": "-----BEGIN CERTIFICATE----ABCDEF==\n-----END CERTIFICATE-----\n", "subject": "commandment.dev", "purpose": "mdm.cacert", "not_before": "2017-03-26T23:42:09+00:00" }, "id": 1, "links": { "self": "/api/v1/certificates/1" } }], "meta": {"count": 1}, "jsonapi": {"version": "1.0"} } :reqheader Accept: application/vnd.api+json :resheader Content-Type: application/vnd.api+json :statuscode 200: :statuscode 404: Other Endpoints --------------- .. autoflask:: commandment:create_app() :blueprints: api_app ================================================ FILE: doc/api/commands.rst ================================================ Commands ======== Summary ------- Detail ------ .. autoflask:: commandment:create_app() :blueprints: api_app .. http:get:: /api/v1/commands Get all commands :reqheader Accept: application/vnd.api+json :resheader Content-Type: application/vnd.api+json .. http:post:: /api/v1/commands Create a command .. http:patch:: /api/v1/commands/(int:command_id) Update a command .. http:delete:: /api/v1/commands/(int:command_id) Delete a command .. http:get:: /api/v1/devices/(int:device_id)/commands Get MDM commands associated with the device specified by **device_id** .. http:all:: /api/v1/devices/(int:device_id)/relationships/commands Attach/Detach command relationships to specific devices ================================================ FILE: doc/api/dep.rst ================================================ Device Enrollment Programmes ============================ Summary ------- .. autoflask:: commandment:create_app() :blueprints: dep_app ================================================ FILE: doc/api/devices.rst ================================================ Devices ======= .. autoflask:: commandment:create_app() :blueprints: api_app .. http:get:: /api/v1/devices Get a list of devices :reqheader Accept: application/vnd.api+json :resheader Content-Type: application/vnd.api+json .. http:get:: /api/v1/devices/(int:device_id) Get information about a specific device. .. http:post:: /api/v1/devices Create a new enrolled device .. http:patch:: /api/v1/devices/(int:device_id) Update an enrolled device .. http:delete:: /api/v1/devices/(int:device_id) Delete an enrolled device .. http:get:: /api/v1/devices/(int:device_id)/commands Get MDM commands associated with this device. .. http:get:: /api/v1/devices/(int:device_id)/tags Get tags associated with this device. ================================================ FILE: doc/api/index.rst ================================================ API Reference ============= Almost all responses and requests are expected to follow the `JSON-API `_ standard, except in cases where binary or encoded data needs to be uploaded or downloaded, *OR* the endpoint is a one-off RPC style action eg. "Erase Device". All of the API is generated via the `flask-rest-jsonapi `_ library. .. toctree:: :maxdepth: 2 certificates commands dep devices organization ================================================ FILE: doc/api/organization.rst ================================================ Organization ============ :SQLAlchemy: :ref:`model-organization` .. autoflask:: commandment:create_app() :blueprints: configuration_app ================================================ FILE: doc/conf.py ================================================ # -*- coding: utf-8 -*- # # commandment documentation build configuration file, created by # sphinx-quickstart on Sat Mar 25 14:50:50 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath('../')) sys.path.append(os.path.abspath('../venv/lib/python3.6/site-packages/')) #sys.path.append(os.path.abspath('../')) import guzzle_sphinx_theme os.environ["COMMANDMENT_SETTINGS"] = "../settings.cfg" # Necessary to prevent autohttp.flask from raising exception # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.napoleon', 'sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.githubpages', 'sphinxcontrib.autohttp.flask', 'sphinxcontrib.autohttp.flaskqref', 'sphinxcontrib.plantuml', 'guzzle_sphinx_theme' ] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The encoding of source files. # # source_encoding = 'utf-8-sig' # The master toctree document. master_doc = 'index' # General information about the project. project = u'commandment' copyright = u'2017, Jesse Peterson, Mosen' author = u'Jesse Peterson, Mosen' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = u'1.0' # The full version, including alpha/beta/rc tags. release = u'1.0' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: # # today = '' # # Else, today_fmt is used as the format for a strftime call. # # today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The reST default role (used for this markup: `text`) to use for all # documents. # # default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. # # add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). # # add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. # # show_authors = False # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. # modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. # keep_warnings = False # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = True # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # # html_theme = 'sphinx_rtd_theme' html_theme = 'guzzle_sphinx_theme' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} html_theme_options = { "project_nav_name": "Commandment" } # Add any paths that contain custom themes here, relative to this directory. # html_theme_path = [] # html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] html_theme_path = guzzle_sphinx_theme.html_theme_path() # The name for this set of Sphinx documents. # " v documentation" by default. # # html_title = u'commandment v1.0' # A shorter title for the navigation bar. Default is the same as html_title. # # html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. # # html_logo = None # The name of an image file (relative to this directory) to use as a favicon of # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. # # html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # Add any extra paths that contain custom files (such as robots.txt or # .htaccess) here, relative to this directory. These files are copied # directly to the root of the documentation. # # html_extra_path = [] # If not None, a 'Last updated on:' timestamp is inserted at every page # bottom, using the given strftime format. # The empty string is equivalent to '%b %d, %Y'. # # html_last_updated_fmt = None # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. # # html_use_smartypants = True # Custom sidebar templates, maps document names to template names. # # html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. # # html_additional_pages = {} # If false, no module index is generated. # # html_domain_indices = True # If false, no index is generated. # # html_use_index = True # If true, the index is split into individual pages for each letter. # # html_split_index = False # If true, links to the reST sources are added to the pages. # # html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. # # html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. # # html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. # # html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). # html_file_suffix = None # Language to be used for generating the HTML full-text search index. # Sphinx supports the following languages: # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr', 'zh' # # html_search_language = 'en' # A dictionary with options for the search language support, empty by default. # 'ja' uses this config value. # 'zh' user can custom change `jieba` dictionary path. # # html_search_options = {'type': 'default'} # The name of a javascript file (relative to the configuration directory) that # implements a search results scorer. If empty, the default will be used. # # html_search_scorer = 'scorer.js' # Output file base name for HTML help builder. htmlhelp_basename = 'commandmentdoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'commandment.tex', u'commandment Documentation', u'Jesse Peterson', 'manual'), ] # The name of an image file (relative to this directory) to place at the top of # the title page. # # latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. # # latex_use_parts = False # If true, show page references after internal links. # # latex_show_pagerefs = False # If true, show URL addresses after external links. # # latex_show_urls = False # Documents to append as an appendix to all manuals. # # latex_appendices = [] # If false, no module index is generated. # # latex_domain_indices = True # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'commandment', u'commandment Documentation', [author], 1) ] # If true, show URL addresses after external links. # # man_show_urls = False # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'commandment', u'commandment Documentation', author, 'commandment', 'One line description of project.', 'Miscellaneous'), ] # Documents to append as an appendix to all manuals. # # texinfo_appendices = [] # If false, no module index is generated. # # texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. # # texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. # # texinfo_no_detailmenu = False # Default for homebrew plantuml = '/usr/local/bin/plantuml' ================================================ FILE: doc/dev/MUSINGS.rst ================================================ # Musings # Dynamic device groups by attribute. Problem: too slow to resolve group membership Possible solutions: update group membership on change? --- Problem: storage of dynamic group predicates --- Group predicate attributes model os_version enrolled / not enrolled check in date/delta device capacity <> how about IN or NOT IN has installed application(s) => has installed profile(s) => identifier in Profile Install via Tag ======================= - Device and profile share tag: Profile should be installed. - Queue profile when tag changes or when device checks in? - If tag is subsequently removed, we have to manage the queue too. - VS: generate install while device checks in - What if multiple tags are assigned to the same profile and the device is too? - Checking the queue gets more complex. ================================================ FILE: doc/developer/guide/architecture.rst ================================================ Architecture ============ Backend ------- The backend is a `Flask `_ application which is expected to run on **Python 3.6+** using type annotations. The persistence layer is handled using SQLAlchemy via `Flask-SQLAlchemy `_. Database schema migrations are performed by `Alembic `_. The REST API follows the `JSON-API standard `_ using `Flask-REST-JSONAPI `_. API that fits an RPC model better than a REST model is serialized using `marshmallow `_ which is what **Flask-REST-JSONAPI** uses anyway. Frontend -------- The frontend framework is `React `_, using `Redux `_ for state management. The source is written in `TypeScript `_ and transpiled to ES5. The UI framework/CSS framework is `semantic-ui `_. We use the React components for this as well. Services -------- Python is notoriously bad for multi-threaded or concurrent i/o, so it would make sense to split responsibilities across microservices. The difficulty in installation can be resolved via the use of docker-compose as the primary "kick the tyres" method of deployment. Services can be broken down like this: - **DEPuty**: The DEPuty should be responsible for scanning and syncing DEP devices and automatically assigning default profiles to those devices. - **Frontdesk**: The frontdesk should take connections from MDM devices and relay queued commands back to those devices. It can report command errors back to the main application. - **Classifier**: This should arrange devices into groups based on inventory and attributes of those devices. It can be notified of changes in inventory but should be a delayed evaluation. The groups it produces should just be marked as non editable by the user. ================================================ FILE: doc/developer/guide/building.rst ================================================ Building ======== Backend ------- None of the Python backend is compiled. So there is no build step. Since we are using type annotations, you may perform "linting" of sorts with `mypy `_. Frontend -------- From the **ui** directory inside the repository, there are several **npm run** scripts/commands that you can use in each stage of development. If you are working on live changes and want to see the results immediately, you can run:: $ npm start This starts a `webpack-dev-server `_, listening on ``localhost:4000`` by default. When flask is run with the setting ``DEBUG=True``, the javascript code is loaded from this webpack dev server on localhost. Documentation ------------- The documentation is built using `Sphinx `_. Sphinx, it's extensions, and the documentation theme are included in the **pipenv** developer dependencies. If you have installed the dependencies using **pipenv** you may run:: $ pipenv run make html from the :file:`doc/` directory in order to build the documentation. ================================================ FILE: doc/developer/guide/index.rst ================================================ Developer Guide =============== This guide explains how to get commandment set up for development. .. warning:: The guide only covers macOS at this point in time. .. toctree:: :maxdepth: 2 dependencies building architecture running ================================================ FILE: doc/developer/guide/running.rst ================================================ Running ======= Backend ------- Before you get started, make a copy of ``settings.cfg.example`` and name it ``settings.cfg``. Note the settings for ``SSL_CERTIFICATE`` and ``SSL_RSA_KEY``. SSL Certificates are required to run an MDM. You'll have to generate those yourself, and they can be self-signed as long as you install trust profiles on your test device(s). If you want to use Python debugging, it's a lot easier with the Flask development server. This is the recommended way to develop commandment. Because MDM requires an SSL connection, the ``flask run`` command won't work out of the box. For this, We've provided a command line application which runs the development server with an SSL context. The command line application is contained in ``commandment.cli``. Assuming you have installed all the Pipenv dependencies, run:: COMMANDMENT_SETTINGS=/path/to/settings.cfg pipenv run commandment From the checked-out git repository. This will start an SSL server on port 5443, using the private key and certificate specified in the :file:`settings.cfg`. .. note:: The backend will assume that you are also running a webpack-dev-server [#f1]_ (front end dev server) if the setting ``DEBUG = True``. This is extremely useful for seeing javascript changes on the fly. Database -------- commandment is configured by default to use an SQLite database (commandment.db) in the same directory as the repository. To initialise the database you should use the ``alembic`` tool, which was part of our python dependencies. To do this, change to the commandment directory and run:: $ pipenv run alembic upgrade head This runs the alembic tool inside the pipenv virtual environment. Frontend -------- When running the backend on the dev server, front-end assets will be loaded from **localhost** on port **4000**. To start the webpack dev server [#f1]_, run the following command inside the :file:`ui` directory:: NODE_ENV=development npm start You should see some output indicating that the webpack-dev-server is running on port 4000. The webpack dev server is configured to use the same SSL certificate and private key as the Flask backend by default. In some browsers you will have to trust BOTH the python backend on 5443 and the webpack-dev-server on port 4000. It helps if they are using the same hostname and ssl certificates. .. rubric:: Footnotes .. [#f1] `webpack-dev-server `_. ================================================ FILE: doc/developer/index.rst ================================================ ####################### Developer Documentation ####################### .. toctree:: :maxdepth: 2 install guide/index ================================================ FILE: doc/developer/microservices.rst ================================================ Microservices Architecture ========================== MDM only has certain limitations which means that microservices have only a limited range of definition in terms of where dependent services can live. Here's some ideas for services DEP Scanner + Default Profiler ------------------------------ - Some process needs to scan/sync DEP - This is a good point in time to evaluate which DEP profile should be assigned to the devices as they come in. - If there was a rules based evaluation of DEP profile assignment, that could also happen here. - Manual DEP assignment does NOT have to live here, because it is performed imperatively against collections of objects. - This process can create new device records when it finds new DEP records. These can exist in a "pre-enrolled" state. APNS Pusher ----------- - Most MDM systems have some sort of Queue monitor/APNS push watcher. - After a certain amount of time, devices with >0 commands to send are evaluated. - Some commands are imperative and you would expect them to happen almost immediately (Shutdown, Restart). with exception to device collections larger than 100, where the push may take some time. - Some commands are expected to happen in good time (InstallProfile, InstallApplication). Inventory --------- - End users expect REASONABLY recent device inventory. - Some process needs to Queue inventory commands at a refresh interval, but not queue all devices at once. - It must also not queue commands if they are already queued. - It must also not queue commands for recently refreshed inventory. Profiles -------- - Try not to introspect profile payload structure because it can literally be anything almost. - Examine desired profiles vs installed profiles and create a command for it. Applications ------------ - Same theoretical application as PRofiles but with a different object type. Calculated Groups (Classifier) ------------------------------ - Isolate a sub-population of devices by attribute predicates. - Many cloud providers de-prioritise the calculation of these groups in order to reduce impact, but this also results in sluggish feedback. - Tactics for speeding up or lessening impact of calculated groups: - Do not recalculate if inventory data did not change: therefore track devices which did change in the last x duration. - Newly created groups must force a recalculation of membership immediately to provide feedback to the user. - Compound predicates are the union intersection of simple predicates, so maybe this can be exploited to lower the cost of group calculation. - Consider groupable attributes for indexing - Groups can be used to functionally identify the workflow state of a device from its pre-enrolled DEP state through DeviceConfigured into enrolled. - Pre-defined groups: - By form factor (Desktop, Tablet, Phone, ATV) - Workflow state (DEP -> AwaitConfiguration -> Enrolled -> Stale -> Unenrolled) - OS Flavour+Major Version (becomes a derivative of form factor groups) - Minor Version (becomes a derivative of major version) - Cellular v non cellular (subset of union Tablet+Phone) - Freestyle composite groups: - Nominate a pre-defined group to limit calculation results. - Enforce a predicate on that. Reaper ------ - Scan age of devices and mark them as Stale if no communications recently. - Unenroll devices once they have not communicated in a long amount of time, ================================================ FILE: doc/guides/INSTALL.md ================================================ ## Requirements ##### Software Requirements * [Python](https://www.python.org/) 2.7+ * [cryptography](https://cryptography.io/en/latest/) * [Flask](http://flask.pocoo.org/) * [SQLAlchemy](http://www.sqlalchemy.org/) * [SQLite](https://www.sqlite.org/) (default database) ##### Apple MDM push notification certificate You will need an Apple MDM APNs certificate in order to send MDM push notifications to devices. To get one, for now, you'll need to have an [Apple Enterprise Developer](https://developer.apple.com/programs/enterprise/) account (US$300/year). Eventually this software may support an ability to assist in getting one of these Push Certificates from Apple's servers. But for now please read [Pepijn Bruienne's excellent blog post](http://enterprisemac.bruienne.com/2015/06/06/mdm-azing-setting-up-your-own-mdm-server/) on how to get this certificate. Note that that post includes information on setting up [Project iMAS MDM server](https://github.com/project-imas/mdm-server) and so some of his post isn't directly relevant to getting the Apple push certificate. Note we don't yet deal with any intermediate steps or certificates with this MDM software (such as Vendor MDM Certificates or generating and submitting CSRs to Apple, etc.). Those steps are required, but they have little to do with running this software. We just require the very end product of the actual MDM push certificate (certificate subject that contains `com.apple.mgmt.*` and associated private key). It needs to be an unencrypted certificate and private key in PEM form for later import into the MDM server. This may require an additional export and unencryption of the exported certificate. ##### DNS and network configuration for SSL hostname matching It is possible to use self-signed certificates with an Apple MDM system. However the hostname and SSL certificate subject matching is strict and an enrolling device needs to trust the MDM server's HTTPS certificate. The trust is established when the device enrolls: by default the HTTPS certificate is included as a profile payload for the device to trust when enrolling. However the hostname matching still needs to be successful. Practically speaking this means that if you intend for your devices to access your MDM via something like https://mymdm.example.com:5443/ then the MDM's web server certificate must also "match" this name (in typical SSL matching rules which includes wildcards and such) by having a certificate subject Common Name (CN) of `mymdm.example.com`. If you have a DNS record already setup and don't want to use the default `mymdm.example.com` name then you'll need to generate a new web server certificate. Instructions for doing so are below, **but remember to restart the development webserver** after you've *generated a new* web certificate with an appropriate Common Name (CN) and *deleted the old* web certificate. While not recommended it's possible to use a `/etc/hosts` entry to test the system including enrollment and MDM operation of a single system on the same host as the server. Useful for quick and dirty virtual machine testing. _**Note:** while IP address certificates appear to work for MDM on iOS not much luck was had with OS X doing this. Besides this it is [not recommended](http://tools.ietf.org/html/rfc6125#section-1.7.2) to use IP addresses in the Common Name field for certificates. Also the CA/B has [deprecated](https://cabforum.org/internal-names/) *internal* IP addresses in certificate subjects from public Certificate Authorities. In other words: best not to go this route._ ## Installing the requirements #### Setting up on OS X for development and testing in a Python virtual environment Instructions for OS X 10.10. These aren't definitive instructions for getting the dependencies installed on OS X. See also [Greg Neagle's post](https://groups.google.com/d/msg/ossmdm/onF7KFWnIa4/LMMRu7OrBiIJ) on getting Project IMAS requirements setup (which are similar to our requirements). ##### Install & create virtualenv [virtualenv](https://virtualenv.pypa.io/en/latest/) is a tool to create isolated Python environments. We want to use this so we're not installing Python packages to the system Python locations and to have a self-contained Python environment. We do have to install virtualenv to the system locations, however: ```bash sudo easy_install virtualenv ``` Then, create the virtualenv and import the configuration into the current shell: ```bash virtualenv commandment-venv source commandment-venv/bin/activate ``` After `source`ing the virtualenv your bash prompt should have changed to include the `commandment-venv`. From: ```bash Mac:Desktop jesse$ ``` To: ```bash (commandment-venv)Mac:Desktop jesse$ ``` ##### Clone the source Use Git to clone the GitHub source of commandment: ```bash git clone git@github.com:jessepeterson/commandment.git cd commandment ``` ##### Install Python project dependencies While still in the `commandment-venv`-activated virtualenv, and in the `commandment` checked-out source code directory, tell `pip` to install the requirements: ```bash pip install -r requirements.txt ``` Assuming all of the above steps completed without problems you should be good to go now. Next steps are to run the server and begin in-webapp configuration. For OS X 10.9 users: M2Crypto can't find the OpenSSL header files on the system. To get around this download the latest tarball of [M2Crypto from PyPi](https://pypi.python.org/pypi/M2Crypto), unpack it, change to it's directory, and while still in the virtualenv run: ```bash python setup.py install build_ext --openssl=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.9.sdk/usr ``` Then re-run the `pip -r` command from above to get the rest of the dependencies which should install fine. ## Server installation and setup ### 1. Start runserver.py and visit web site ```bash ./runserver.py ``` This will immediately configure application settings, setup the database's ORM (SQLAlchemy), create the database schema (in the default SQLite database `mdm.db` in the current directory), create initial self-signed SSL certificates and keys, configure the development web server and start the application. Soon after start it should start listening on the default port 5443 and amongst the verbose output should be these lines: ``` * Running on https://0.0.0.0:5443/ (Press CTRL+C to quit) * Restarting with stat ``` This means the server has started and is listening for connections. Go visit https://127.0.0.1:5443/ (remembering the http**S**:// as it is a secure site). You'll get an SSL certificate warning prompt as the server is using a newly generated self-signed certificate. But you can ignore that for now and continue to the site. You should be presented with the enrollment interface. Don't try to enroll devices yet or click on the enroll link. We're not setup yet. ### 2. Add your push certificate to the system Per the above requirements you'll need your Apple MDM push certificate and private key in unencrypted PEM form. Once you have those then visit https://127.0.0.1:5443/admin/certificates. You should be presented with a list of all the non-device identity certificates currently configured in the system. One of items listed will be "APNS MDM Push Certificate (Required)" and a "(Certificate Missing)" in red where the subject would be. To the right of this table row click the "[ Add ]" link. This will give us two large text fields to paste in the PEM certificate and private key into the system. Do so and click Submit. If all worked then you should now see that the Subject column of the APNS MDM Push Certificate row is filled in with a `UID=com.apple.mgmt.*, CN=APSP:*` entry where the asterisks are UUID-looking values. The APNS certificate is now in the system ready for use. ### 3. Setup an appropriate web server SSL certificate and verify Per the above requirements you're likely not going to use the default server name of `mymdm.example.com`. So we'll want to generate ourselves a more appropriate self-signed certificate. This can be done in the app itself. Visit the admin certificates page again. You should see the default `CN=mymdm.example.com` certificate under the "MDM Web Server Certificate". Before you delete this certificate realize that without a proper web certificate the development server cannot start. Delete the `CN=mymdm.example.com` certificate and then click the "[ Generate New ]" link to make a new one. The only type of certificate (currently) allowed that you can create is a web server certificate. Fill in the various fields of the of the new certificate but **importantly** using a Common Name that matches the DNS name of the server. Now **restart the web server** (press controll-C in the server Terminal) to start using this new certificate. Navigate to your server URL using the new DNS name that you just created and the same port number. As an example if we used `newtestmdm.example.com` to generate a certificate then navigate to https://newtestmdm.example.com:5443/. You should still get a browser prompt for a certificate (it is still a self-signed certificate) but the name of the certificate should be the new name that you gave it when you generated the certificate. Verify this when the certificate prompt comes up. For our example here the certificate subject common name would be `newtestmdm.example.com`. If it does not match the URL you're using then expect problems enrolling devices and using the MDM server. ### 4. Create MDM certificate configuration Now that we have correct DNS & web server certificate, we need to create the MDM configuration. In the web admin click the "Config" link at the top. Fill out the form as it's page describes. The Profile prefix is often just the domain name in reverse "domain component" form. For our example this might be "com.example.newtestmdm". Take special care for the hostname and web server port. This field should match the hostname used for the web server certificate above, and the same port number (default 5443). There should only be one Certificate Authority and Push Certificate available to select at this stage. ***Keep in mind** that the MDM Push certificate "topic" and hostname/port (MDM URLs) cannot change for the lifetime that a device is enrolled: this is a specific requirement of MDM profile payloads.* Now Click Submit. The Config Admin page should reload but now with some values statically set. You should now be able to enroll a device! ### 5. Enroll a device _**WARNING:** It goes without saying MDM systems are powerful. They can lock, wipe, or otherwise disable a device. It's recommended to test using a device that is not important in case of accidental or inadvertant data loss or lock-out (which would require a reset)._ On a device to be enrolled go to the landing page of the MDM system. This is the root URL we accessed above. In our example this is https://newtestmdm.example.com:5443/. Click the link to enroll. This will dynamically generate an MDM enrollment profile that should trust the web server certificate, includes the device's newly generated identity certificate (signed by the built-in CA), the MDM payload, and enroll the device. If the device was successfully enrolled then you should be able to visit the devices list (https://newtestmdm.example.com:5443/admin/devices in our example setup) to view the newly enrolled device. Sometimes the very first MDM notification is not sent (and thus the device details are missing from the table) so the "[ Send Push Notif. ]" button can be used to request the machine specifically check-in. Congrats! If it enrolled and you see device details (name, serial number, etc.) then it's working! Now something a little more useful.. _**Warning:** Apple recommends MDM developers use a SCEP system to enroll certificates with an MDM vendor. To simplify setup we do not do this and instead generate a device identity certificate directly embedded in the enrollment profile. For iOS this is likely fine as the enrollment profile isn't by default downloaded anywhere. But on OS X the enrollment profile is downloaded to disk (default browser action) which means the device's identity certificate is stored on the filesystem trivially accessible (usually just in the Downloads folder). Given access to the enrollment profile one can trivially spoof the device to the MDM system. This means you may want to enroll OS X devices using a script or other technique than just having users simply enroll to make sure the original enrollment profile is deleted after enrollment. The device identity private key is not stored in the MDM server after the enrollment profile is generated but it is embedded in the enrollment profile._ ### 6. Create a device group and apply a (the) example profile to it From the admin area of the web app go to the Groups page. Add a group naming it however you wish. Now go to the Profiles admin page. Create a new one. Uncheck the "Allow iTunes" checkbox (note this profile only does anything on iOS devices). And click Add Profile. Edit this newly created Profile. Select the newly created group under the Group Applicablility section. You've now associated that profile to be installed on any devices in that group. ### 7. Assign the device to this group to apply the profile to it Go to the Devices list. Click the "[ View Device ]" link for your newly enrolled device. Select the newly assigned profile group you created and click the 'Update Device Group' link. Performing this action will create the new group membership, queue a new MDM command to install the profile on the device, and send a push notification to the device to run this command. If all worked then the iOS device you enrolled should have it's iTunes icon removed from it's home screen. You should be able to unassign the device from the group to put it back. *Currently only device group memebership triggers profile updates, further functionality is coming.* ================================================ FILE: doc/guides/nginx.rst ================================================ Nginx Configuration =================== If you are using commandment behind Nginx, Nginx will be terminating the SSL connection, therefore commandment is unable to manage the SSL certificates for you. You should always pass the client certificate back up to commandment so that the validity of the device certificate can be determined using CA's stored in commandment. With uWSGI ---------- Prerequisites: - Python 3.5+ + On macOS: ``brew install python3`` - uWSGI + On macOS: ``brew install uwsgi --with-python3`` + Linux DEB: ``uwsgi-plugin-python3`` - uWSGI python3 plugin Example configuration (with uWSGI):: server { listen 443 ssl; server_name commandment.dev; ssl_certificate commandment.dev.crt; ssl_certificate_key commandment.dev.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_verify_client optional_no_ca; root /path/to/commandment/static; access_log commandment-access.log; error_log commandment-error.log; location / { try_files $uri @commandment; } location @commandment { include uwsgi_params; uwsgi_param HTTP_X_CLIENT_CERT $ssl_client_cert; uwsgi_pass unix:/tmp/uwsgi.sock; } } References: - http://uwsgi-docs.readthedocs.io/en/latest/Nginx.html - http://flask.pocoo.org/docs/0.12/deploying/uwsgi/ With Gunicorn ------------- - ``pip3 install gunicorn`` gunicorn -w 4 commandment:create_app With Phusion Passenger ---------------------- Example configuration:: server { listen 443 ssl; server_name commandment.dev; ssl_certificate commandment.dev.crt; ssl_certificate_key commandment.dev.key; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_verify_client optional_no_ca; root /path/to/commandment/static; access_log commandment-access.log; error_log commandment-error.log; passenger_enabled on; } ================================================ FILE: doc/guides/scep.rst ================================================ Verifying CMS Replies:: /usr/local/Cellar/openssl/1.0.2k/bin/openssl cms -verify -in /tmp/reply.bin -inform DER -noverify ================================================ FILE: doc/index.rst ================================================ .. commandment documentation master file, created by sphinx-quickstart on Sat Mar 25 14:50:50 2017. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. Welcome to commandment's documentation! ======================================= Contents: .. toctree:: :maxdepth: 2 about-mdm installing/macos user/configuration user/dep developer/index api/index internal/index Indices and tables ================== * :ref:`genindex` * :ref:`modindex` * :ref:`search` ================================================ FILE: doc/installing/index.rst ================================================ ========== Installing ========== ================================================ FILE: doc/installing/install.rst ================================================ Installation ============ Dependencies ------------ These are the dependencies for developing with commandment. You won't need all of these to host it. Quick Start (Using Homebrew) ---------------------------- First clone the repository, then install dependencies:: $ brew install python3 nodejs yarn $ pip install pipenv $ pipenv install $ cd ui && yarn install The long version ---------------- Python 3.6+ ^^^^^^^^^^^ Commandment is written using Python 3.6, and uses type annotations as provided by the `typing `_ module. macOS ships with Python 2.7, so you will need to have a separate instance of Python 3.6 installed on your system. You can use `Homebrew `_ to install Python 3.6 alongside the system provided Python 2.7. It's as easy as:: $ brew install python3 You can also get an isolated environment using something such as `Anaconda `_, which is not covered here. On Linux you should be able to install python 3 using your distributions packaging tools such as **yum** or **apt-get**. NodeJS 7+ ^^^^^^^^^ All of the front end tooling requires NodeJS. You can download and install an official package from `here `_. I use `nvm `_ to run multiple NodeJS versions at a time, for testing purposes. You may also run:: $ brew install nodejs Setting up the environment -------------------------- This part will assume that you have now cloned the git repository somewhere on your system. Usually within your own home folder. Pipenv ^^^^^^ To download the Python dependencies, you first need `pipenv `_. You can install pipenv by running:: $ pip install pipenv See the **pipenv** documentation for information about how to install it on other Linux distributions. Python dependencies ^^^^^^^^^^^^^^^^^^^ To install python dependencies, change to the commandment directory and run:: $ pipenv install This should download and install all python requirements into a new virtualenv. .. note:: This supersedes the :file:`requirements.txt` method. Front end dependencies ---------------------- All of the front end code is contained within the **ui** subdirectory, so make that your current working directory. First, you need to install all of the **node** dependencies. For this i recommend `yarn `_, which you can install by running:: $ brew install yarn Then, to install all front end dependencies you can run:: $ yarn install From the ui directory. You now have the tools to develop both the backend and front end code. ================================================ FILE: doc/installing/macos.rst ================================================ Installation ============ macOS ----- .. note:: macOS is not a recommended platform for hosting an MDM. However, you can use it to test commandment. Manual Installation ^^^^^^^^^^^^^^^^^^^ - Install `Homebrew `_. - Install Pre-requisites:: $ brew install python3 $ brew install uwsgi --with-python --with-python3 $ brew install nginx - *TODO: upload release tarball. For now you will need to git clone* Unpack commandment to :file:`/usr/local/commandment`. - Use this example NGiNX configuration (:download:`download `). Copy the downloaded file to :file:`/usr/local/etc/nginx/servers/commandment.conf`. - Use this example uWSGI configuration (:download:`download `). Copy the downloaded file to :file:`/usr/local/etc/uwsgi/apps-enabled/uwsgi-commandment.ini`. SSL ^^^ MDM more or less requires an SSL certificate. The example NGiNX configuration file above expects a private key, located at :file:`/usr/local/commandment/server.key` and a certificate, located at :file:`/usr/local/commandment/server.crt`. For a production instance, you will require an SSL certificate issued by a 3rd party for the chosen domain. However, as this is a macOS installation guide, You may also use a self-signed certificate. .. note:: Creating SSL certificates is outside of the scope of this document. Handy tip for extracting PEM/key pair out of a .p12 exported by Keychain Assistant:: openssl pkcs12 -in yourP12File.p12 -nocerts -out privatekey.pem openssl pkcs12 -in yourP12File.p12 -clcerts -nokeys -out certificate.pem For converting DER to PEM:: openssl x509 -inform DER -outform PEM -text -in mykey.der -out mykey.pem Push Notification Certificate ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ You need a push certificate to tell devices when to check-in. You have three options: - Sign up for an Apple Enterprise Developer Account (ca. $400 USD). Enable the MDM option and sign your own Push Certificate request. - Register on `mdmcert.download `_. - Export the Push Certificate from Profile Manager (really not supported). This guide follows the **mdmcert.download** workflow. - First, register on `mdmcert.download `_. The e-mail address you use will be the one that receives all notifications and certificate signing requests. - *TODO* visit ``/apns/mdmcert`` using the web ui to request a new CSR. - *TODO* upload the CSR received in your e-mail to this same page. - *TODO* download the decrypted CSR for upload to the APNS portal. - Go to the Apple Push Certificate Portal and upload the CSR. - Download the resulting push certificate. .. note:: At this stage you should have an MDM Push Certificate and SSL Certificate ready so that your devices will talk to the MDM service. You should also decide whether to use `SCEPy `_ for testing or another SCEP service such as Microsoft NDES. Configuration ^^^^^^^^^^^^^ An example configuration file, called :file:`settings.cfg.example` is supplied with commandment. You should copy this file to a file named :file:`settings.cfg` and make updates as needed. Each setting is documented within the file. ================================================ FILE: doc/installing/ubuntu-server.rst ================================================ Installation on Ubuntu ====================== This guide was written using Ubuntu Server 19.04. Amendments are welcome for different versions. This guide assumes you are a regular user who is part of the sudoers group. 1. Dependencies --------------- Install packages:: sudo apt-get update sudo apt install -y python3 python3-venv nginx uwsgi uwsgi-plugin-python3 nodejs npm pipenv Clone the project into /var/www:: sudo git clone https://github.com/cmdmnt/commandment.git /var/www/commandment Install backend dependencies:: $ cd /var/www/commandment $ sudo python3 -m venv virtualenv $ . ./virtualenv/bin/activate (virtualenv)$ sudo -E pipenv --python /usr/bin/python3 install Install frontend dependencies:: $ cd /var/www/commandment/ui $ sudo npm install 2. Backend ---------- 2.1 uWSGI ^^^^^^^^^ uWSGI runs multiple copies of the backend to service requests. Create a new uWSGI configuration in /etc/uwsgi/apps-available/commandment.ini If you are following this guide use the template below, which you can adjust later if you want to move locations of various components:: cat <`_. Some things such as RPC style calls or singleton objects wouldn't make sense in this context, so they're placed into a flat_api blueprint. .. toctree:: :maxdepth: 2 api.rst json-api.rst ================================================ FILE: doc/internal/api/json-api.rst ================================================ JSON-API v1 =========== .. qrefflask:: commandment:create_app() :blueprints: api_app :endpoints: .. autoflask:: commandment:create_app() :blueprints: api_app :endpoints: ================================================ FILE: doc/internal/cms/decorators.rst ================================================ Decorators ========== .. automodule:: commandment.cms.decorators :members: ================================================ FILE: doc/internal/cms/index.rst ================================================ CMS - Cryptographic Message Syntax / PKCS#7 =========================================== Details of the **commandment.cms** package. This package implements most of the CMS / PKCS#7 functionality with the aid of *asn1crypto* and *cryptography*. .. toctree:: :maxdepth: 2 decorators ================================================ FILE: doc/internal/core/index.rst ================================================ Commandment Core ================ Details of the **commandment** core package .. toctree:: :maxdepth: 2 models/index signals ================================================ FILE: doc/internal/core/models/certificate.rst ================================================ .. _model-certificate: Certificate =========== .. uml:: /_static/uml/models/Certificate.plantuml .. py:currentmodule:: commandment.models .. autoclass:: Certificate :members: ================================================ FILE: doc/internal/core/models/certificate_request.rst ================================================ CertificateRequest ================== .. py:currentmodule:: commandment.models .. autoclass:: CertificateSigningRequest :members: :show-inheritance: ================================================ FILE: doc/internal/core/models/command.rst ================================================ .. _model-command: Command ======= .. uml:: /_static/uml/models/Command.plantuml .. py:currentmodule:: commandment.models .. autoclass:: Command :members: .. autoclass:: CommandStatus :members: ================================================ FILE: doc/internal/core/models/device.rst ================================================ Device ====== .. uml:: /_static/uml/models/Device.plantuml .. py:currentmodule:: commandment.models .. autoclass:: Device :members: ================================================ FILE: doc/internal/core/models/index.rst ================================================ ORM (SQLAlchemy) Models ======================= .. toctree:: :maxdepth: 2 certificate.rst certificate_request.rst command.rst device.rst organization.rst installed_profile installed_certificate installed_application profile.rst rsa_private_key.rst ================================================ FILE: doc/internal/core/models/installed_application.rst ================================================ InstalledApplication ==================== .. uml:: /_static/uml/models/InstalledApplication.plantuml .. py:currentmodule:: commandment.models .. autoclass:: InstalledApplication :members: ================================================ FILE: doc/internal/core/models/installed_certificate.rst ================================================ InstalledCertificate ==================== .. uml:: /_static/uml/models/InstalledCertificate.plantuml .. py:currentmodule:: commandment.models .. autoclass:: InstalledCertificate :members: ================================================ FILE: doc/internal/core/models/installed_profile.rst ================================================ InstalledProfile ================ .. uml:: /_static/uml/models/InstalledProfile.plantuml .. py:currentmodule:: commandment.models .. autoclass:: InstalledProfile :members: ================================================ FILE: doc/internal/core/models/organization.rst ================================================ .. _model-organization: Organization ============ .. py:currentmodule:: commandment.models .. autoclass:: Organization :members: ================================================ FILE: doc/internal/core/models/profile.rst ================================================ Profile ======= .. py:currentmodule:: commandment.models .. autoclass:: Profile :members: ================================================ FILE: doc/internal/core/models/rsa_private_key.rst ================================================ PrivateKey ========== .. py:currentmodule:: commandment.models .. autoclass:: RSAPrivateKey :members: ================================================ FILE: doc/internal/core/signals.rst ================================================ Signals ======= .. automodule:: commandment.signals :members: ================================================ FILE: doc/internal/decorators.rst ================================================ Decorators ========== commandment.mdm.parse_plist_input_data device_cert_check ================================================ FILE: doc/internal/dep/dep.rst ================================================ DEP Client ========== The main DEP API wrapper class .. autoclass:: commandment.dep.dep.DEP :members: ================================================ FILE: doc/internal/dep/index.rst ================================================ DEP - Device Enrollment Programme ================================= Details of the **commandment.dep** package .. toctree:: :maxdepth: 2 dep types models ================================================ FILE: doc/internal/dep/models.rst ================================================ SQLAlchemy Models ================= .. autoclass:: commandment.dep.models.DEPAnchorCertificate :members: .. autoclass:: commandment.dep.models.DEPSupervisionCertificate :members: .. autoclass:: commandment.dep.models.DEPServerTokenCertificate :members: .. autoclass:: commandment.dep.models.DEPConfiguration :members: .. autoclass:: commandment.dep.models.DEPProfile :members: ================================================ FILE: doc/internal/dep/types.rst ================================================ DEP Types ========= .. automodule:: commandment.dep :members: ================================================ FILE: doc/internal/enroll/app.rst ================================================ Enrollment Blueprint ==================== .. qrefflask:: commandment:create_app() :blueprints: enroll_app :endpoints: .. autoflask:: commandment:create_app() :blueprints: enroll_app :endpoints: ================================================ FILE: doc/internal/enroll/index.rst ================================================ Enrollment ========== Details of the **commandment.enroll** package. This package implements all of the non-dep enrollment logic. .. toctree:: :maxdepth: 2 app ================================================ FILE: doc/internal/flask/configuration.rst ================================================ Configuration Blueprint ======================= .. autoflask:: commandment:create_app() :blueprints: configuration_app :endpoints: ================================================ FILE: doc/internal/flask/index.rst ================================================ Flask Endpoints =============== This should contain only endpoints that are not REST endpoints. .. toctree:: :maxdepth: 2 configuration enroll mdm_app ================================================ FILE: doc/internal/index.rst ================================================ ################### Internals Reference ################### .. toctree:: :maxdepth: 2 core/index api/index cms/index dep/index enroll/index mdm/index vpp/index workers/index push.rst ================================================ FILE: doc/internal/mdm/app.rst ================================================ MDM Blueprint ============= .. autoflask:: commandment:create_app() :blueprints: mdm_app :endpoints: ================================================ FILE: doc/internal/mdm/handlers.rst ================================================ MDM Command Response Handlers ============================= .. automodule:: commandment.mdm.handlers :members: ================================================ FILE: doc/internal/mdm/index.rst ================================================ MDM === Details of the **commandment.mdm** package. This package implements the Apple MDM Protocol. Responses and requests from devices may be generated or handled by other modules also. .. toctree:: :maxdepth: 2 app handlers types ================================================ FILE: doc/internal/mdm/types.rst ================================================ MDM Types ========= .. automodule:: commandment.mdm :members: ================================================ FILE: doc/internal/push.rst ================================================ push ==== .. automodule:: commandment.push ================================================ FILE: doc/internal/vpp/decorators.rst ================================================ Decorators ========== .. automodule:: commandment.vpp.decorators :members: ================================================ FILE: doc/internal/vpp/enum.rst ================================================ VPP Types ========= .. automodule:: commandment.vpp.enum :members: ================================================ FILE: doc/internal/vpp/errors.rst ================================================ VPP Errors ========== .. automodule:: commandment.vpp.errors :members: ================================================ FILE: doc/internal/vpp/index.rst ================================================ VPP - Volume Purchasing Programme ================================= Details of the **commandment.vpp** package. This package implements all of the functionality related to Apple's **Volume Purchase Programme**. .. toctree:: :maxdepth: 2 decorators enum errors operations vpp ================================================ FILE: doc/internal/vpp/operations.rst ================================================ VPP License Operations ====================== .. autoclass:: commandment.vpp.vpp.VPPLicenseOperation :members: .. autoclass:: commandment.vpp.vpp.VPPUserLicenseOperation :members: .. autoclass:: commandment.vpp.vpp.VPPDeviceLicenseOperation :members: ================================================ FILE: doc/internal/vpp/vpp.rst ================================================ VPP Client ========== The main VPP API wrapper class .. autoclass:: commandment.vpp.vpp.VPP :members: ================================================ FILE: doc/internal/workers/index.rst ================================================ Worker Threads ============== .. toctree:: :maxdepth: 2 runner ================================================ FILE: doc/internal/workers/runner.rst ================================================ runner ====== .. automodule:: commandment.runner ================================================ FILE: doc/make.bat ================================================ @ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . set I18NSPHINXOPTS=%SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. epub3 to make an epub3 echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. texinfo to make Texinfo files echo. gettext to make PO message catalogs echo. changes to make an overview over all changed/added/deprecated items echo. xml to make Docutils-native XML files echo. pseudoxml to make pseudoxml-XML files for display purposes echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled echo. coverage to run coverage check of the documentation if enabled echo. dummy to check syntax errors of document sources goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* goto end ) REM Check if sphinx-build is available and fallback to Python version if any %SPHINXBUILD% 1>NUL 2>NUL if errorlevel 9009 goto sphinx_python goto sphinx_ok :sphinx_python set SPHINXBUILD=python -m sphinx.__init__ %SPHINXBUILD% 2> nul if errorlevel 9009 ( echo. echo.The 'sphinx-build' command was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) :sphinx_ok if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\commandment.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\commandment.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "epub3" ( %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdf" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "latexpdfja" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex cd %BUILDDIR%/latex make all-pdf-ja cd %~dp0 echo. echo.Build finished; the PDF files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "texinfo" ( %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo if errorlevel 1 exit /b 1 echo. echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. goto end ) if "%1" == "gettext" ( %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale if errorlevel 1 exit /b 1 echo. echo.Build finished. The message catalogs are in %BUILDDIR%/locale. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) if "%1" == "coverage" ( %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage if errorlevel 1 exit /b 1 echo. echo.Testing of coverage in the sources finished, look at the ^ results in %BUILDDIR%/coverage/python.txt. goto end ) if "%1" == "xml" ( %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml if errorlevel 1 exit /b 1 echo. echo.Build finished. The XML files are in %BUILDDIR%/xml. goto end ) if "%1" == "pseudoxml" ( %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml if errorlevel 1 exit /b 1 echo. echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. goto end ) if "%1" == "dummy" ( %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy if errorlevel 1 exit /b 1 echo. echo.Build finished. Dummy builder generates no files. goto end ) :end ================================================ FILE: doc/sadisplay/models.py ================================================ import os import codecs import sadisplay from flask import Flask from commandment.models import db, Device, Command, InstalledApplication, InstalledCertificate, \ InstalledProfile from commandment.pki.models import Certificate dummyapp = Flask(__name__) db.init_app(dummyapp) UML_PATH = os.path.realpath(os.path.dirname(__file__) + '/../_static/uml/models') classes = [Certificate, Command, InstalledApplication, InstalledApplication, InstalledCertificate, InstalledProfile] with dummyapp.app_context(): for cls in classes: desc = sadisplay.describe( [getattr(cls, attr) for attr in dir(cls)], show_methods=True, show_properties=True, show_indexes=True, ) with codecs.open(os.path.join(UML_PATH, '{}.plantuml'.format(cls.__name__)), 'w', encoding='utf-8') as f: f.write(sadisplay.plantuml(desc)) ================================================ FILE: doc/user/configuration.rst ================================================ Configuration ============= An example configuration `is provided `_ with the source code. It is recommended to copy this file to your own ``settings.cfg``, and make modifications to that file. When commandment runs, it will expect an environment variable ``COMMANDMENT_SETTINGS``, that contains the full path to the settings file. Database Connection ------------------- commandment uses SQLAlchemy as its database connection API. For more information about available configuration variables see `Flask-SQLAlchemy Configuration `_. For a testing setup, SQLite is more than adequate, so you will only need to add this line:: SQLALCHEMY_DATABASE_URI = 'sqlite:////path/to/commandment/commandment.db' To use a local SQLite database. Self-Signed SSL Certificate --------------------------- If your SSL certificate is self signed, or signed via an untrusted enterprise CA, you will need to provide it as part of the configuration. If your CA isn't already trusted throughout all of your clients (which is typically the case when you are self-signing), you will need to provide the certificate eg:: CA_CERTIFICATE="/path/to/CA.crt" MDM Push Certificate -------------------- If you have both the private and public key in **PEM** format, you can simply add a single variable pointing to that file:: PUSH_CERTIFICATE="/path/to/push.pem" Otherwise, if you need to provide a **PKCS#12** ``.p12`` file, you will also need to specify a password:: PUSH_CERTIFICATE="/path/to/push.p12" PUSH_CERTIFICATE_PASSWORD = "sekret" Nuts and Bolts -------------- - For flask web application settings, refer to `Flask - Built In Configuration Values `_. - For database settings, refer to `Flask-SQLAlchemy Configuration `_. ================================================ FILE: doc/user/dep.rst ================================================ DEP (Device Enrollment Program) =============================== This document outlines configuration of the DEP device syncing service. This information applies to classic DEP as well as Apple School Manager and Apple Business Manager. Configuring via the UI ---------------------- - Click on **Settings** -> **DEP Accounts**. - **New DEP Account** - Click on the **Download** button to begin downloading a Public Key. You will use this to upload to Apple Business Manager [#abm]_ or Apple School Manager [#asm]_. - Create a new **MDM Server** in ASM or ABM, as described in the ASM Help `here `_. - Upload the :file:`commandment-dep.cer` file you just downloaded, using the **Upload Key** button. .. figure:: /_static/images/asm/upload-key.png :align: right - Download the DEP token using the **Get Token** link. - Unfortunately, for now you will have to upload the token using the **curl** command as outlined in API step 5. Configuring via API ------------------- 1. Make a *GET* request to ``/dep/certificate/download`` to download the initial DEP Public Key. The public key is generated on request, and stored in the database with name ``COMMANDMENT-DEP``. 2. Perform the manual process of Adding an ASM/ABM **MDM Server**, and uploading the certificate you retrieved in step 1. 3. Download the DEP token from ASM/ABM, which will be a file ending in ``_smime.p7m``. 4. Upload the file to ``/dep/stoken/upload`` as multipart/form-encoded with the file field of **file**, the equivalent curl command line would be:: curl -F 'file=@/path/to/_smime.p7m' https://commandment.local/dep/stoken/upload 5. The DEP token should be decrypted, and devices should start appearing when the next DEP sync happens or when the server is restarted. For convenience, the decrypted token is provided in the result of this request as a json payload, structured like so:: { "access_secret": "AS_1234", "access_token": "AT_1234", "access_token_expiry": "2019-10-02T00:00:00Z", "consumer_key": "CK_1234", "consumer_secret": "CS_1234" } .. rubric:: Footnotes .. [#abm] Apple Business Manager .. [#asm] Apple School Manager, available at https://school.apple.com ================================================ FILE: doc/user/index.rst ================================================ ################## User Documentation ################## .. toctree:: :maxdepth: 2 preface install configuration dep ================================================ FILE: docker-compose.yml ================================================ version: "3" services: commandment: build: context: . dockerfile: .docker/Dockerfile image: cmdmnt/commandment:latest # volumes: # - "./.docker/settings.cfg.docker:/settings.cfg" # - "./server.crt:/etc/nginx/ssl.crt" # - "./server.key:/etc/nginx/ssl.key" ports: - "8445:443" environment: - SSL_HOSTNAME=commandment.dev ================================================ FILE: mypy.ini ================================================ [mypy] ignore_missing_imports=True ================================================ FILE: pytest.ini ================================================ [pytest] testpaths = tests markers = depsim: mark a test requiring depsim vppsim: mark a test requiring vppsim dep: mark a test requiring a live DEP account vpp: mark a test requiring a live VPP account ================================================ FILE: settings.cfg.example ================================================ from os import path dirname = path.dirname(__file__) # The public facing hostname of the MDM # This will also be used as the self signed certificate dnsname PUBLIC_HOSTNAME = 'commandment.dev' # Development mode listen port PORT = 5443 # Configure your Database URI. # All SQLAlchemy options are available here: # http://flask-sqlalchemy.pocoo.org/2.1/config/ SQLALCHEMY_DATABASE_URI = 'sqlite:///commandment.db' # SQLALCHEMY_DATABASE_ECHO = True # SQLALCHEMY_TRACK_MODIFICATIONS = False # --------------- # Certificates # --------------- # [APNS] # You may supply the certificate as a pair of PEM encoded files, or as a .p12 container. # If you supply .p12 it will be encoded as a PEM keypair # ----- PUSH_CERTIFICATE = '../push.pem' PUSH_KEY = '../push.key' PUSH_CERTIFICATE_PASSWORD = 'sekret' # for pkcs12 only # If commandment is running in development mode, specify the path to the certificate and private key. # These can also be generated at start up. # Normally SSL should be handled by Apache/Nginx/etc. # [SSL] # Specify the Enterprise CA here if Apple Devices won't natively trust your CA eg. If you are using a # self-signed CA or Enterprise CA Certificate. # ----- CA_CERTIFICATE = path.join(dirname, 'ssl', 'ca.crt') # Specify the development web server SSL certificate. # This only applies if you are running via the CLI or flask run # ----- SSL_CERTIFICATE = path.join(dirname, 'ssl', 'server.crt') SSL_RSA_KEY = path.join(dirname, 'ssl', 'server.key') # If not using external storage, the path to the root directory for upload storage. # This should not be used in production. # ----- STORAGE_ROOT = path.join(dirname, 'storage') # ------------------------- # SCEP via SCEPy (optional) # ------------------------- # Directory where certs, revocation lists, serials etc will be kept # ----- SCEPY_CA_ROOT = "/path/to/ca" # X.509 Name Attributes used to generate the CA Certificate. # ----- SCEPY_CA_X509_CN = 'SCEPY-CA' SCEPY_CA_X509_O = 'SCEPy' SCEPY_CA_X509_C = 'AU' # SubjectAltName extension is always on and will use this DNSName SAN_DNSNAME = 'scepy.dev' # (Optional) SCEP static challenge. This will have to be part of your SCEP profile # ----- SCEPY_CHALLENGE = 'sekret' # Raw data will be dumped to this directory for inspection with tools such as OpenSSL (openssl asn1parse) # ----- SCEPY_DUMP_DIR = '/tmp/scepy_dump' # If the GetCACert would return a single cert, force it to use a CMS degenerate case? # ----- SCEPY_FORCE_DEGENERATE_FOR_SINGLE_CERT = False # ------------------------ # Authlib (Authentication) # ------------------------ # Token Expiry # # OAUTH2_TOKEN_EXPIRES_IN = { # 'authorization_code': 864000, # 'implicit': 3600, # 'password': 864000, # 'client_credentials': 864000 # } ================================================ FILE: setup.cfg ================================================ [aliases] test=pytest [tool:pytest] python_files=tests/*.py ================================================ FILE: setup.py ================================================ from setuptools import setup, find_packages setup( name="commandment", version="0.1", description="Commandment is an Open Source Apple MDM server with support for managing iOS and macOS devices", packages=['commandment'], include_package_data=True, author="mosen", license="MIT", url="https://github.com/cmdmnt/commandment", classifiers=[ 'Development Status :: 3 - Alpha', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python :: 3.6' ], keywords='MDM', install_requires=[ 'acme==0.34.2', 'alembic==1.0.10', 'apns2-client==0.5.4', 'asn1crypto==0.24.0', 'authlib==0.11', 'biplist==1.0.3', 'blinker>=1.4', 'cryptography==2.6.1', 'flask==1.0.3', 'flask-alembic==2.0.1', 'flask-cors==3.0.4', 'flask-jwt==0.3.2', 'flask-marshmallow==0.10.1', 'flask-rest-jsonapi==0.29.0', 'flask-sqlalchemy==2.4.0', 'marshmallow==2.18.0', 'marshmallow-enum==1.4.1', 'marshmallow-jsonapi==0.21.0', 'marshmallow-sqlalchemy==0.16.3', 'oscrypto==0.19.1', 'passlib==1.7.1', 'requests==2.22.0', 'semver', 'sqlalchemy==1.3.3', 'typing==3.6.4' ], python_requires='>=3.6', tests_require=[ 'factory-boy==2.10.0', 'faker==0.8.10', 'mock==2.0.0', 'mypy==0.560' 'pytest==3.4.0', 'pytest-runner==3.0' ], extras_requires={ 'ReST': [ 'sphinx-rtd-theme', 'guzzle-sphinx-theme', 'sadisplay==0.4.8', 'sphinx==1.7.0b2', 'sphinxcontrib-httpdomain==1.6.0', 'sphinxcontrib-napoleon==0.6.1', 'sphinxcontrib-plantuml==0.10', ], 'macOS': [ 'pyobjc' ] }, setup_requires=['pytest-runner'], entry_points={ 'console_scripts': [ 'commandment=commandment.cli:server', 'appmanifest=commandment.pkg.appmanifest:main', ] }, zip_safe=False ) ================================================ FILE: testdata/Authenticate/10.11.x.xml ================================================ BuildVersion 15G1004 Challenge YXBwbGU= DeviceName micromdm-test MessageType Authenticate Model iMac15,1 ModelName iMac OSVersion 10.11.6 ProductName iMac15,1 SerialNumber C00000000004 Topic com.apple.mgmt.test.00000000-1111-2222-3333-444455556666 UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/Authenticate/10.12.2.xml ================================================ BuildVersion 16C67 Challenge YXBwbGU= DeviceName commandment MessageType Authenticate Model iMac17,1 ModelName iMac OSVersion 10.12.2 ProductName iMac17,1 SerialNumber 000000000000 Topic com.apple.mgmt.commandment.dev UDID E3568F17-92ED-450A-8904-C3BF4CB7E9A5 ================================================ FILE: testdata/Authenticate/IOS-11.3.1.xml ================================================ BuildVersion 15E302 MessageType Authenticate OSVersion 11.3.1 ProductName iPad4,1 SerialNumber XXXXXXXXXXXX Topic com.apple.mgmt.XServer.1c111c11-1c11-1c11-1c11-1c111c111c11 UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/Authenticate/IOS-9.x.xml ================================================ BuildVersion 13F69 MessageType Authenticate OSVersion 9.3.2 ProductName iPad4,1 SerialNumber XXXXXXXXXXXX Topic io.micromdm.topic.00000000-1111-2222-3333-444455556666 UDID 1111111111111111111111111111111111111111 ================================================ FILE: testdata/Authenticate/iOS-11.3.1-cell.xml ================================================ BuildVersion 15E302 IMEI 11 111111 111111 1 MEID 1111111111111 MessageType Authenticate OSVersion 11.3.1 ProductName iPhone7,2 SerialNumber XXXXXXXXXXXX Topic com.apple.mgmt.XServer.1c111c11-1c11-1c11-1c11-1c111c111c11 UDID 1111111111111111111111111111111111111111 ================================================ FILE: testdata/AvailableOSUpdates/10.12.5.xml ================================================ AvailableOSUpdates AllowsInstallLater AppIdentifiersToClose HumanReadableName XProtectPlistConfigData HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/14/03/091-09590/oq7k627iuqlyoe0aceifh4uqugpp5db7pm/XProtectPlistConfigData.smd ProductKey 091-09590 RestartRequired Version 1.0 AllowsInstallLater AppIdentifiersToClose HumanReadableName MRTConfigData HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/14/01/091-14577/6cg68lk6jg3dqkkoiea8bq5vmrg9y4lid5/MRTConfigData.smd ProductKey 091-14577 RestartRequired Version 1.0 AllowsInstallLater AppIdentifiersToClose HumanReadableName Gatekeeper Configuration Data HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/22/20/091-16180/5zs9vcfyfv0aszvsv4numivit8nr636usg/GatekeeperConfigData.smd ProductKey 091-16180 RestartRequired Version 112 AllowsInstallLater AppIdentifiersToClose HumanReadableName Chinese Word List Update HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/49/37/091-16458/9nl6q1ygvhew2ip8hlumsd8oolgquxc8vp/ChineseWordlistUpdate.smd ProductKey 091-16458 RestartRequired Version 5.26 CommandUUID 00000000-1111-2222-3333-444455556666 RequestType AvailableOSUpdates Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/AvailableOSUpdates/iOS-11.3.1.xml ================================================ AvailableOSUpdates AllowsInstallLater Build 15F79 DownloadSize 225236247 HumanReadableName iOS 11.4 InstallSize 537395200 IsCritical ProductKey iOSUpdate15F79 ProductName iOS RestartRequired Version 11.4 CommandUUID 8fb53ef6-5fcd-46c2-bf05-ee502406f240 Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/AvailableOSUpdates/macOS-10.13.1.xml ================================================ AvailableOSUpdates AllowsInstallLater AppIdentifiersToClose HumanReadableName Security Update 2017-001 HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/16/13/091-51303/nay02ahjmx7ksv7y3siwy02rlwfxbkltv3/macOSUpd10.13.1Supplemental.smd ProductKey 091-51303 RestartRequired Version AllowsInstallLater AppIdentifiersToClose HumanReadableName XProtectPlistConfigData HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/52/53/091-60868/g76fwkprfxgubzoa7ugaphfx5iutbek52z/XProtectPlistConfigData.smd ProductKey 091-60868 RestartRequired Version 2099 AllowsInstallLater AppIdentifiersToClose HumanReadableName Gatekeeper Configuration Data HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/21/42/091-86646/nkbr84bbslxj8pyy2lsaxrveryeevomjcu/GatekeeperConfigData.smd ProductKey 091-86646 RestartRequired Version 140 AllowsInstallLater AppIdentifiersToClose HumanReadableName macOS High Sierra 10.13.5 Update HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/41/40/091-86782/frzvpm2pwu5997tia30oepu729x87hm8jp/macOSUpdCombo10.13.5Auto.smd ProductKey 091-86782 RestartRequired Version AllowsInstallLater AppIdentifiersToClose HumanReadableName MRTConfigData HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/27/47/091-89184/gohwwfmyzg8bpx345nxcfkmtk8nnmtfffx/MRTConfigData.smd ProductKey 091-89184 RestartRequired Version 1.35 AllowsInstallLater AppIdentifiersToClose HumanReadableName Gatekeeper Configuration Data HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/25/03/091-92948/8dnb4p6sa45djtrj7gdz97x87zxog1k1yc/GatekeeperConfigData.smd ProductKey 091-92948 RestartRequired Version 144 AllowsInstallLater AppIdentifiersToClose com.apple.iPhoto com.apple.Aperture com.apple.dt.Xcode com.apple.PurpleRestore com.apple.iTunes com.apple.AppleConfigurationUtility com.apple.configurator HumanReadableName iTunes HumanReadableNameLocale en IsConfigDataUpdate IsCritical IsFirmwareUpdate MetadataURL http://swcdn.apple.com/content/downloads/01/56/zzzz091-81933/uoyng1yndy3zayq9i3jr1abrfwgehhfnxp/iTunesX.smd ProductKey zzzz091-81933 RestartRequired Version 12.7.5 CommandUUID a6121a90-4100-4928-93d8-6645722bfda7 Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/CertificateList/10.11.x.xml ================================================ CertificateList CommonName com.apple.systemdefault Data MIICFDCCAX2gAwIBAgIEMshmtjALBgkqhkiG9w0BAQUwPDEgMB4G A1UEAwwXY29tLmFwcGxlLnN5c3RlbWRlZmF1bHQxGDAWBgNVBAoM D1N5c3RlbSBJZGVudGl0eTAeFw0xNTAzMDgyMjM4NDBaFw0zNTAz MDMyMjM4NDBaMDwxIDAeBgNVBAMMF2NvbS5hcHBsZS5zeXN0ZW1k ZWZhdWx0MRgwFgYDVQQKDA9TeXN0ZW0gSWRlbnRpdHkwgZ8wDQYJ KoZIhvcNAQEBBQADgY0AMIGJAoGBALWpKmld573u/zaPBwCuAMSy SxwUqQsTCi8TxPIbDLSssMkDJMlcukB9zpDkVZnP49uZow7TGE6t VuXKDmcx6gfskwkdyra05X2xNZACopdbV2OSjv87hh2yMRcmq+tt ao/3L3Ynp2ZWTVFIfgcJTHzkIKFLRwWfmEV0DE1WYNMpAgMBAAGj JTAjMAsGA1UdDwQEAwIEsDAUBgNVHSUEDTALBgkqhkiG92NkBAQw DQYJKoZIhvcNAQEFBQADgYEAjN23bV17Xk9+om0NVMhcb1dou3E0 bVfMuvpYx6xcClP8Im9gGaIt8sHQPx2sRfZ0EHIPAWIDdtg8Qun3 cIOLal4LigmgZEgU2V+JLyFRI7ps9QVDfbM0/So0j/B5VvUH+K8h ZTM0Y7Dg0GVwQg2tP2Fg7xFjnKz/6AO0n03Im44= IsIdentity CommonName Apple Worldwide Developer Relations Certification Authority Data MIIEIjCCAwqgAwIBAgIIAd68xDltoBAwDQYJKoZIhvcNAQEFBQAw YjELMAkGA1UEBhMCVVMxEzARBgNVBAoTCkFwcGxlIEluYy4xJjAk BgNVBAsTHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRYw FAYDVQQDEw1BcHBsZSBSb290IENBMB4XDTEzMDIwNzIxNDg0N1oX DTIzMDIwNzIxNDg0N1owgZYxCzAJBgNVBAYTAlVTMRMwEQYDVQQK DApBcHBsZSBJbmMuMSwwKgYDVQQLDCNBcHBsZSBXb3JsZHdpZGUg RGV2ZWxvcGVyIFJlbGF0aW9uczFEMEIGA1UEAww7QXBwbGUgV29y bGR3aWRlIERldmVsb3BlciBSZWxhdGlvbnMgQ2VydGlmaWNhdGlv biBBdXRob3JpdHkwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK AoIBAQDKOFSmy1aqyCQ5SOmM7uxfuH8mkbw0U3rOfGOAYXdkXqUH I7Y5/lAtFVZYcC1+xG7BSoU+L/DehBqhV8mvexj/avoVEkkVCBms qtsqMu2WY2hSFT2Miuy/axiV4AOsAX2XBWfODoWVN2rtCbauZ81R ZJ/GXNG8V25nNYB2NqSHgW44j9grFU57Jdhav06DwY3Sk9UacbVg nJ0zTlX5ElgMhrgWDcHld0WNUEi6Ky3klIXh6MSdxmilsKP8Z35w ugJZS3dCkTm59c3hTO/AO0iMpuUhXf1qarunFjVg0uat80YpyejD i+l5wGphZxWy8P3laLxiX27Pmd3vG2P+kmWrAgMBAAGjgaYwgaMw HQYDVR0OBBYEFIgnFwmpthhgi+zruvZHWcVSVKO3MA8GA1UdEwEB /wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/ CF4wLgYDVR0fBCcwJTAjoCGgH4YdaHR0cDovL2NybC5hcHBsZS5j b20vcm9vdC5jcmwwDgYDVR0PAQH/BAQDAgGGMBAGCiqGSIb3Y2QG AgEEAgUAMA0GCSqGSIb3DQEBBQUAA4IBAQBPz+9Zviz1smwvj+4T hzLoBTWobot9yWkMudkXvHcs1Gfi/ZptOllc34MBvbKuKmFysa/N w0Uwj6ODDc4dR7Txk4qjdJukw5hyhzs+r0ULklS5MruQGFNrCk4Q ttkdUGwhgAqJTleMa1s8Pab93vcNIx0LSiaHP7qRkkykGRIZbVf1 eliHe2iK5IaMSuviSRSqpd1VAKmuu0swruGgsbwpgOYJd+W+NKIB yn/c4grmO7i77LpilfMFY0GCzQ87HUyVpNur+cmV6U/kTecmmYHp vPm0KdIBembhLoz2IYrF+Hjhga6/05Cdqa3zr/04GpZnMBxRpVzs cYqCtGwPDBUf IsIdentity CommandUUID 00000000-1111-2222-3333-444455556666 RequestType CertificateList Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/CertificateList/iOS-11.3.1.xml ================================================ CertificateList CommonName COMMON-NAME Data Base64= IsIdentity CommonName device-identity Data Base64= IsIdentity CommandUUID 506315e2-386a-44eb-9f46-e402afce7e80 Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/CheckOut/10.11.x.xml ================================================ MessageType CheckOut Topic com.apple.mgmt.test.00000000-1111-2222-3333-444455556666 UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/CheckOut/iOS-11.3.1.xml ================================================ MessageType CheckOut Topic com.apple.mgmt.XServer.1c111c11-1c11-1c11-1c11-1c111c111c11 UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/DeviceInformation/10.11.x.xml ================================================ CommandUUID 00000000-1111-2222-3333-444455556666 QueryResponses ActiveManagedUsers 00000000-1111-2222-3333-444455556666 AvailableDeviceCapacity 60.977592468261719 AwaitingConfiguration BluetoothMAC 00-00-00-00-00-00 BuildVersion 15G1004 CurrentConsoleManagedUser 00000000-1111-2222-3333-444455556666 DeviceCapacity 464.82241058349609 DeviceName micromdm-testing HostName micromdm-testing.dev Languages en LocalHostName micromdm-testing Model iMac15,1 ModelName iMac OSUpdateSettings AutoCheckEnabled AutomaticAppInstallationEnabled AutomaticOSInstallationEnabled AutomaticSecurityUpdatesEnabled BackgroundDownloadEnabled CatalogURL https://swscan.apple.com/content/catalogs/others/index-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz IsDefaultCatalog PerformPeriodicCheck PreviousScanDate 2016-11-03T03:12:26Z PreviousScanResult 0 OSVersion 10.11.6 ProductName iMac15,1 SerialNumber C00000000004 UDID 00000000-1111-2222-3333-444455556666 WiFiMAC 00:00:00:00:00:00 iTunesStoreAccountHash aAaAaAaAaAaAaAaAaAaAaAaAaAa= iTunesStoreAccountIsActive RequestType DeviceInformation Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/DeviceInformation/iOS-11.3.1.xml ================================================ CommandUUID 3ab89975-490b-4f33-80c7-0982069272ba QueryResponses AvailableDeviceCapacity 15.468269348144531 BluetoothMAC 00:00:00:00:00:00 BuildVersion 15E302 DataRoamingEnabled DeviceCapacity 26.914535522460938 DeviceName iPad IsRoaming Model MD786X ModelName iPad OSVersion 11.3.1 ProductName iPad4,1 SerialNumber C00000000004 SubscriberMCC SubscriberMNC UDID 1c111c111c111c111c111c111c111c111c111c11 VoiceRoamingEnabled WiFiMAC 00:00:00:00:00:00 Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/DeviceInformation/macOS-10.13.1.xml ================================================ CommandUUID be926f7e-9cd2-465a-ba2b-b573dc4eaa7a QueryResponses ActiveManagedUsers AutoSetupAdminAccounts AvailableDeviceCapacity 217.90493011474609 BuildVersion 17B48 DeviceCapacity 953.67411804199219 DeviceName commandment HostName commandment.local Languages en LocalHostName commandment Locales en_AU eu hr_BA en_CM en_BI rw_RW ast en_SZ he_IL ar uz_Arab en_PN as en_NF ks_IN es_KY rwk_TZ zh_Hant_TW en_CN gsw_LI ta_IN th_TH es_EA fr_GF ar_001 en_RW tr_TR de_CH ee_TG en_NG fr_TG az fr_SC es_HN en_AG ru_KZ gsw dyo so_ET zh_Hant_MO de_BE nus_SS km_KH my_MM mgh_MZ ee_GH es_EC kw_GB rm_CH en_ME nyn mk_MK bs_Cyrl_BA ar_MR es_GL en_BM ms_Arab en_AI gl_ES en_PR ff_CM ne_IN or_IN khq_ML en_MG pt_TL en_LC iu_CA ta_SG jmc_TZ om_ET lv_LV es_US en_PT vai_Latn_LR en_NL to_TO cgg_UG en_MH ta zu_ZA shi_Latn_MA es_FK ar_KM en_AL brx_IN te chr_US yo_BJ fr_VU pa tg kea ksh_DE sw_CD te_IN fr_RE th ur_IN yo_NG ti es_HT es_GP guz_KE tk kl_GL ksf_CM mua_CM lag_TZ lb fr_TN es_PA pl_PL to hi_IN dje_NE es_GQ en_BR kok_IN pl fr_GN bem ha ckb lg tr en_PW en_NO nyn_UG sr_Latn_RS gsw_FR pa_Guru he qu_BO ps_AF lu_CD mgo_CM sn_ZW en_BS da ps ln pt hi lo ebu de gu_IN seh en_CX en_ZM fr_HT fr_GP pt_GQ lt lu es_TT ln_CD vai_Latn el_GR lv en_KE sbp hr en_CY es_GT twq_NE zh_Hant_HK kln_KE fr_GQ chr hu es_UY fr_CA ms_BN en_NR mer shi es_PE fr_SN bez sw_TZ wae_CH kkj hy dz_BT en_CZ teo_KE teo en_AR ar_JO yue_Hans_CN mer_KE khq ln_CF nn_NO es_SR en_MO ar_TD dz ses en_BW en_AS ar_IL es_BB bo_CN nnh teo_UG hy_AM ln_CG sr_Latn_BA en_MP ksb_TZ ar_SA smn_FI ar_LY en_AT so_KE fr_CD af_NA en_NU es_PH en_KI en_JE lkt fa_IR pt_FR uz_Latn_UZ zh_Hans_CN ewo_CM fr_PF ca_IT es_GY en_BZ ar_KW pt_GW fr_FR am_ET en_VC es_DM fr_DJ fr_CF es_SV en_MS pt_ST ar_SD luy_KE gd_GB de_LI it_VA fr_CG pt_CH ckb_IQ zh_Hans_SG en_MT ha_NE en_ID ewo af_ZA os_GE om_KE nl_SR es_ES es_DO ar_IQ fr_CH nnh_CM es_SX es_419 en_MU en_US_POSIX yav_CM luo_KE dua_CM et_EE en_IE ak_GH rwk es_CL kea_CV fr_CI ckb_IR fr_BE se en_NZ en_MV en_LR es_PM en_KN nb_SJ ha_NG sg sr_Cyrl_RS ru_RU en_ZW sv_AX si ga_IE en_VG ff_MR sk ky_KG agq_CM mzn fr_BF mr_IN en_MW de_AT az_Latn en_LS ka naq_NA sl sn sr_Latn_ME fr_NC so is_IS twq ig_NG sq fo_FO sr tzm ga om en_LT bas_CM se_NO ki nl_BE ar_QA gd sv kk rn_BI es_CO az_Latn_AZ kl or es_AG ca en_VI km os sw en_MY kn en_LU fr_SY ar_TN en_JM fr_PM ko fr_NE ce fr_MA gl ru_MD es_BL saq_KE ks fr_CM lb_LU gv_IM fr_BI en_LV en_KR es_NI en_GB kw nl_SX dav_KE tr_CY ky en_UG es_BM en_TC es_AI ar_EG fr_BJ gu es_PR fr_RW gv lrc_IQ sr_Cyrl_BA es_MF fr_MC cs bez_TZ es_CR asa_TZ ar_EH fo_DK ms_Arab_BN en_JP sbp_TZ en_IL lt_LT mfe en_GD es_LC cy ug_CN ca_FR es_BO en_SA fr_BL bn_IN uz_Cyrl_UZ lrc_IR az_Cyrl en_IM sw_KE en_SB pa_Arab ur_PK haw_US ar_SO en_IN fil fr_MF en_WS es_CU es_BQ ja_JP fy_NL en_SC yue_Hant_HK en_IO pt_PT en_HK en_GG fr_MG de_LU tzm_MA es_BR en_TH en_SD nds_DE shi_Tfng ln_AO as_IN en_GH ms_MY ro_RO jgo_CM es_CW dua en_UM es_BS en_SE kn_IN en_KY vun_TZ kln lrc en_GI ca_ES rof pt_CV kok pt_BR ar_DJ yi_001 fi_FI zh es_PY ar_SS mua sr_Cyrl_ME vai_Vaii_LR en_001 nl_NL en_TK si_LK en_SG fr_DZ ca_AD sv_SE pt_AO vi xog_UG xog en_IS nb seh_MZ es_AR sk_SK en_SH ti_ER nd az_Cyrl_AZ zu ne nd_ZW el_CY en_IT nl_BQ da_GL ja rm fr_ML rn en_VU rof_TZ ro ebu_KE ru_KG en_SI sg_CF mfe_MU nl brx bs_Latn fa zgh_MA en_GM shi_Latn en_FI nn en_EE ru yue kam_KE fur vai_Vaii ar_ER rw ti_ET ff luo fa_AF nl_CW es_MQ en_HR en_FJ fi pt_MO be en_US en_TO en_SK bg ru_BY it_IT ml_IN gsw_CH qu_EC fo sv_FI en_FK nus ta_LK vun sr_Latn es_BZ fr en_SL bm es_VC ar_BH guz bn bo ar_SY es_MS lo_LA ne_NP uz_Latn be_BY es_IC sr_Latn_XK ar_MA pa_Guru_IN br luy kde_TZ es_AW bs fy fur_IT hu_HU ar_AE en_HU sah_RU zh_Hans en_FM fr_MQ ko_KP en_150 en_DE ce_RU en_CA hsb_DE sq_AL en_TR ro_MD es_VE tg_TJ fr_WF mt_MT kab nmg_CM ms_SG en_GR ru_UA fr_MR zh_Hans_MO de_IT ff_GN bs_Cyrl nds_NL es_KN sw_UG yue_Hans ko_KR en_DG bo_IN en_CC shi_Tfng_MA lag it_SM os_RU en_TT ms_Arab_MY sq_MK es_VG bem_ZM kde ar_OM kk_KZ cgg bas kam wae es_MX sah zh_Hant en_GU fr_MU fr_KM ar_LB en_BA en_TV sr_Cyrl mzn_IR es_VI dje kab_DZ fil_PH se_SE vai hr_HR bs_Latn_BA nl_AW dav so_SO ar_PS en_FR uz_Cyrl ff_SN en_BB ki_KE en_TW naq en_SS mg_MG mas_KE en_RO en_PG mgh dyo_SN mas agq bn_BD haw yi nb_NO da_DK en_DK saq ug cy_GB fr_YT jmc ses_ML en_PH de_DE ar_YE es_TC bm_ML yo lkt_US uz_Arab_AF jgo sl_SI pt_LU uk en_CH asa en_BD lg_UG nds qu_PE mgo id_ID en_NA en_GY zgh pt_MZ fr_LU dsb mas_TZ en_DM ta_MY es_GD en_BE mg ur fr_GA ka_GE nmg en_TZ eu_ES ar_DZ id so_DJ hsb yav mk pa_Arab_PK ml en_ER ig se_FI mn ksb uz vi_VN ii qu en_PK ee ast_ES yue_Hant mr ms en_ES ha_GH it_CH sq_XK mt en_CK br_FR en_BG es_GF tk_TM sr_Cyrl_XK ksf en_SX bg_BG en_PL af el cs_CZ fr_TD zh_Hans_HK is ksh my mn_MN en it dsb_DE ii_CN eo iu en_ZA smn en_AD ak en_RU kkj_CM am es et uk_UA Model MacPro6,1 ModelName Mac Pro OSUpdateSettings AutoCheckEnabled AutomaticAppInstallationEnabled AutomaticOSInstallationEnabled AutomaticSecurityUpdatesEnabled BackgroundDownloadEnabled CatalogURL https://swscan.apple.com/content/catalogs/others/index-10.13-10.12-10.11-10.10-10.9-mountainlion-lion-snowleopard-leopard.merged-1.sucatalog.gz IsDefaultCatalog PerformPeriodicCheck PreviousScanDate 2018-06-30T13:46:38Z PreviousScanResult 0 OSVersion 10.13.1 ProductName MacPro6,1 SerialNumber AB123CD45G SystemIntegrityProtectionEnabled UDID 00000000-1111-2222-3333-444455556666 WiFiMAC 00:00:00:00:00:00 Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/DeviceLock/iOS-11.3.1.xml ================================================ CommandUUID b6c8627f-bf4a-4ef4-8f40-d8673cd568c9 MessageResult NoPasscodeSet Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/Errors/10.12.5-invalid-command.xml ================================================ CommandUUID 93a81e2a-8fbe-4e4b-b6e5-ee9148893f33 ErrorChain ErrorCode 97 ErrorDomain MDMClientError LocalizedDescription No \'Identifier\' in \'RemoveProfile\' command <MDMClientError:97> RequestType RemoveProfile Status Error UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/Errors/10.13.6-invalid-command.xml ================================================ CommandUUID bac6348e-3291-4523-a354-037ec379b738 ErrorChain ErrorCode 12021 ErrorDomain MCMDMErrorDomain LocalizedDescription Unknown command: ShutdownDevice <MDMClientError:91> Status Error UDID 2F6DE437-7C14-5735-85B4-DC6B365BCAF1 ================================================ FILE: testdata/Errors/error_invalid_request_type.plist ================================================ CommandUUID 00000000-1111-2222-3333-444455556666 ErrorChain ErrorCode 12021 ErrorDomain MCMDMErrorDomain LocalizedDescription “OSUpdateStatus” is not a valid request type. USEnglishDescription “OSUpdateStatus” is not a valid request type. Status Error UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/Errors/iOS-11.3.1-AvailableOSUpdatesFailure.xml ================================================ CommandUUID 93289d88-c8da-4732-9a7e-33dd51851b6e ErrorChain ErrorCode 2213 ErrorDomain DeviceManagement.error LocalizedDescription No update available. ErrorCode 3 ErrorDomain com.apple.softwareupdateservices.errors LocalizedDescription The operation couldn\xe2\x80\x99t be completed. (com.apple.softwareupdateservices.errors error 3.) Status Error UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/Errors/iOS-11.3.1-CommandFormatError.xml ================================================ CommandUUID 52aab5d2-6a61-4fe8-b685-44f8b56972d0 Status CommandFormatError UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/Errors/iOS-11.3.1-RemoveProfile-Unmanaged.xml ================================================ CommandUUID b2da2591-a2e5-45e5-8dd8-0ffb050d8407 ErrorChain ErrorCode 12013 ErrorDomain MCMDMErrorDomain LocalizedDescription The profile \xe2\x80\x9corg.github.cmdmnt.commandment.trust\xe2\x80\x9d is not managed by MDM. USEnglishDescription The profile \xe2\x80\x9corg.github.cmdmnt.commandment.trust\xe2\x80\x9d is not managed by MDM. Status Error UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/InstallApplication/iOS-11.3.1-alreadyprompting.xml ================================================ CommandUUID 94ee37e2-3e03-4bdf-ad9c-a81ccc9d78e9 ErrorChain ErrorCode 1407 ErrorDomain DeviceManagement.error LocalizedDescription The user is already being prompted. Status Error UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/InstallApplication/iOS-12.1-prompting.xml ================================================ CommandUUID 7a9df3db-d661-4c1c-aa0e-56d69c8718a5 Identifier com.tinyspeck.slackmacgap State Prompting Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/InstallApplication/manifests/Microsoft_AutoUpdate-3.11.17101000.plist ================================================ items assets kind software-package md5-size 10485760 md5s 4daa1b7740abf1ea74a019d5920d4a6a url https://officecdn-microsoft-com.akamaized.net/pr/C1297A47-86C4-4C1F-97FA-950631F94777/OfficeMac/Microsoft_AutoUpdate_3.11.17101000_Updater.pkg metadata bundle-identifier com.microsoft.autoupdate bundle-version 3.11.17101000 items bundle-identifier com.microsoft.autoupdate.fba bundle-version 3.11.17101000 bundle-identifier Microsoft.MicrosoftAzureMobile bundle-version 1 bundle-identifier com.microsoft.errorreporting bundle-version 15.39.17101000 bundle-identifier com.microsoft.autoupdate2 bundle-version 3.11.17101000 kind software sizeInBytes 3465086 title Microsoft AutoUpdate ================================================ FILE: testdata/InstallApplication/manifests/OneDrive-17.3.7078.1101.plist ================================================ items assets kind software-package md5-size 10485760 md5s ed75d7dff41873f8c8d4042fc240194c 436f7896d1cc458e0302c9cfaf6797f2 f7af904288dcbb05ec468432f754807b 144607843c5160cb234b967df62f4ce9 url https://oneclient.sfx.ms/Mac/Direct/17.3.7078.1101/OneDrive.pkg metadata bundle-identifier com.microsoft.OneDrive bundle-version 17.3.7078 items bundle-identifier com.microsoft.OneDrive.FinderSync bundle-version 7078.1101 bundle-identifier org.qt-project.QtCore bundle-version 5.9.1 bundle-identifier org.qt-project.QtQuickControls2 bundle-version 5.9.1 bundle-identifier com.microsoft.MSSyncEngine bundle-version 7078.1101 bundle-identifier com.microsoft.OneDrive bundle-version 7078.1101 bundle-identifier com.microsoft.OneDriveLauncher bundle-version 7078.1101 bundle-identifier net.hockeyapp.sdk.mac bundle-version 59 bundle-identifier com.microsoft.MacQtViews bundle-version 1 bundle-identifier org.qt-project.QtSvg bundle-version 5.9.1 bundle-identifier com.microsoft.SkyDriveLauncher bundle-version 7078.1101 bundle-identifier org.qt-project.QtQuickTemplates2 bundle-version 5.9.1 bundle-identifier org.qt-project.QtDBus bundle-version 5.9.1 bundle-identifier com.microsoft.MSP2P bundle-version 7078.1101 bundle-identifier com.microsoft.OneDriveUpdater bundle-version 7078.1101 bundle-identifier org.qt-project.QtWidgets bundle-version 5.9.1 bundle-identifier com.microsoft.PlaceholderManager bundle-version 1 bundle-identifier org.qt-project.QtPrintSupport bundle-version 5.9.1 bundle-identifier com.microsoft.MSCommon bundle-version 7078.1101 bundle-identifier org.qt-project.QtNetwork bundle-version 5.9.1 bundle-identifier org.qt-project.QtQml bundle-version 5.9.1 bundle-identifier org.qt-project.QtQuick bundle-version 5.9.1 bundle-identifier org.qt-project.QtMacExtras bundle-version 5.9.1 bundle-identifier org.qt-project.QtGui bundle-version 5.9.1 kind software sizeInBytes 31753894 title Microsoft OneDrive ================================================ FILE: testdata/InstallApplication/manifests/SkypeForBusinessInstaller-16.12.0.77.plist ================================================ items assets kind software-package md5-size 10485760 md5s 953ab65252fe1fd3cadae48b64d2cecf a3384c74c85a27a2dd120d6d1f95cdbc 6572835fe35aae86064b2d71e899e93e 01f6c5e722276ca87bc28f9597611aaf url http://download.microsoft.com/download/D/0/5/D055DA17-C7B8-4257-89A1-78E7BBE3833F/SkypeForBusinessInstaller-16.12.0.77.pkg metadata bundle-identifier com.microsoft.SkypeForBusiness bundle-version 16.12.77 items bundle-identifier com.microsoft.rdpkit bundle-version 15.18 bundle-identifier com.microsoft.mbulocale bundle-version 15.18 bundle-identifier com.microsoft.Model bundle-version 1 bundle-identifier com.microsoft.mbukernel bundle-version 15.18 bundle-identifier com.microsoft.netlib bundle-version 15.18 bundle-identifier net.hockeyapp.sdk.mac bundle-version 59 bundle-identifier com.microsoft.frameworks.wincrypto bundle-version 15.18 bundle-identifier com.microsoft.wlmkernel bundle-version 15.18 bundle-identifier com.microsoft.IPA bundle-version 16.12.77 bundle-identifier com.microsoft.mbufont bundle-version 15.18 bundle-identifier com.microsoft.ADAL bundle-version 1 bundle-identifier com.microsoft.SkypeAppKit bundle-version 1 bundle-identifier com.microsoft.mbuinstrument bundle-version 15.18 bundle-identifier com.microsoft.autoupdate.fba bundle-version 3.8.16112200 bundle-identifier com.microsoft.errorreporting bundle-version 15.29.16112200 bundle-identifier com.microsoft.autoupdate2 bundle-version 3.8.16112200 kind software sizeInBytes 35452911 title Skype for Business ================================================ FILE: testdata/InstallApplication/manifests/dotnet-sdk-2.0.2-osx-x64.plist ================================================ items assets kind software-package md5-size 10485760 md5s 46fc4f5d1d6988999a697d08867dab8b a950d6d1df6347d1dd61c8fb28b03bf4 0ae19fbd7fbced52c98844048ad980e9 cbd454fbb0b6e57344a7b996f6f9eb22 772f81302420ef668c5280ea3ad03438 6f8a30304d85a406b3351f880f325817 3ddb09ecfbe4bb2d371a00541947f183 0f430c3787aadfaae152dd59d34b2f7c 0c387e5a2ec6d722cda8080eec0e9c33 18827156403d3b503a8621d882de759d 4b00162e0de7068f20bc0ac2488762c2 0769ec04e2f9793979997f151a10660e f01f03afd6a7561fcc8c464f67b8f524 6caf9ffb84db47a296a267bcb2ea0447 url https://download.microsoft.com/download/7/3/A/73A3E4DC-F019-47D1-9951-0453676E059B/dotnet-sdk-2.0.2-osx-x64.pkg metadata bundle-identifier com.microsoft.dotnet.sharedframework.Microsoft.NETCore.App.2.0.0.component.osx.x64.pkg bundle-version 2.0.0 kind software sizeInBytes 140938719 title Microsoft .NET Core SDK - 2.0.2 (x64) ================================================ FILE: testdata/InstallApplication/manifests/munkitools-3.1.0.3430.plist ================================================ items assets kind software-package md5-size 10485760 md5s 0afbe2fbe7cb81ff531834cba82f3a75 url https://github.com/munki/munki/releases/download/v3.1.0/munkitools-3.1.0.3430.pkg metadata bundle-identifier com.googlecode.munki.core bundle-version 3.1.0.3430 items bundle-identifier com.googlecode.munki.MunkiStatus bundle-version 3401 bundle-identifier com.googlecode.munki.munki-notifier bundle-version 3251 bundle-identifier com.googlecode.munki.MSCDockTilePlugin bundle-version 1 bundle-identifier com.googlecode.munki.ManagedSoftwareCenter bundle-version 3425 kind software sizeInBytes 3594282 title Munki - Managed software installation for OS X ================================================ FILE: testdata/InstalledApplicationList/10.11.x.xml ================================================ CommandUUID 00000000-1111-2222-3333-444455556666 InstalledApplicationList BundleSize 5855484 Identifier com.apple.systempreferences Name System Preferences ShortVersion 14.0 Version 14.0 BundleSize 65092 Name Set Info BundleSize 0 Name Install OS X Yosemite RequestType InstalledApplicationList Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/InstalledApplicationList/iOS-11.3.1.xml ================================================ CommandUUID 368b6b82-f89c-4af7-8a02-ed72af86116c InstalledApplicationList AdHocCodeSigned AppStoreVendable BetaApp BundleSize 143695872 DeviceBasedVPP DynamicSize 192512 ExternalVersionIdentifier 827127239 HasUpdateAvailable Identifier com.microsoft.lync2013.iphone Installing IsValidated Name Business ShortVersion 6.20.2 Version 6.20.2.2 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 80281600 DeviceBasedVPP DynamicSize 1032192 ExternalVersionIdentifier 826041298 HasUpdateAvailable Identifier com.agilebits.onepassword-ios Installing IsValidated Name 1Password ShortVersion 7.0.6 Version 70006002 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 220049408 DeviceBasedVPP DynamicSize 303104 ExternalVersionIdentifier 827416674 HasUpdateAvailable Identifier com.microsoft.Office.Powerpoint Installing IsValidated Name PowerPoint ShortVersion 2.14 Version 2.14.18060400 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 17637376 DeviceBasedVPP DynamicSize 155648 ExternalVersionIdentifier 826584966 HasUpdateAvailable Identifier com.panic.Prompt2 Installing IsValidated Name Prompt ShortVersion 2.6.6 Version 86791 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 179638272 DeviceBasedVPP DynamicSize 225280 ExternalVersionIdentifier 827489856 HasUpdateAvailable Identifier com.microsoft.onenote Installing IsValidated Name OneNote ShortVersion 16.14 Version 16014000.18060400 Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/InstalledApplicationList/iOS-12.1.xml ================================================ CommandUUID ca851cf1-e85b-4fed-bf0a-dd7a4e4859b2 InstalledApplicationList AdHocCodeSigned AppStoreVendable BetaApp BundleSize 87191552 DeviceBasedVPP DynamicSize 196608 ExternalVersionIdentifier 829185909 HasUpdateAvailable Identifier com.agilebits.onepassword-ios Installing IsValidated Name 1Password ShortVersion 7.2.2 Version 70202006 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 56799232 DeviceBasedVPP DynamicSize 102400 ExternalVersionIdentifier 829315696 HasUpdateAvailable Identifier com.seek.jobseeker Installing IsValidated Name SEEK Jobs ShortVersion 2.17.0 Version 714 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 59449344 DeviceBasedVPP DynamicSize 45056 ExternalVersionIdentifier 829166341 HasUpdateAvailable Identifier com.microsoft.azure Installing IsValidated Name Azure ShortVersion 1.0.33 Version 1.0.33.20181102 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 47652864 DeviceBasedVPP DynamicSize 32768 ExternalVersionIdentifier 829257810 HasUpdateAvailable Identifier au.com.sbs.ondemand Installing IsValidated Name On Demand ShortVersion 2.10.3 Version 784 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 25075712 DeviceBasedVPP DynamicSize 28672 ExternalVersionIdentifier 829313936 HasUpdateAvailable Identifier com.amazonaws.mobileConsole Installing IsValidated Name AWS Console ShortVersion 2.0.1 Version 500 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 39378944 DeviceBasedVPP DynamicSize 94208 ExternalVersionIdentifier 828862112 HasUpdateAvailable Identifier au.net.abc.ABCiView Installing IsValidated Name ABC iview ShortVersion 4.4.1 Version 261 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 43778048 DeviceBasedVPP DynamicSize 45056 ExternalVersionIdentifier 829312998 HasUpdateAvailable Identifier com.safariflow.SafariQueue Installing IsValidated Name Queue ShortVersion 2.3.1 Version 418 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 83869696 DeviceBasedVPP DynamicSize 413696 ExternalVersionIdentifier 829229890 HasUpdateAvailable Identifier com.tinyspeck.chatlyio Installing IsValidated Name Slack ShortVersion 3.57 Version 399418 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 1355776 DeviceBasedVPP DynamicSize 3522560 ExternalVersionIdentifier 824204014 HasUpdateAvailable Identifier com.fastmail.FastMail Installing IsValidated Name FastMail ShortVersion 1.2.7 Version 409 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 34725888 DeviceBasedVPP DynamicSize 12288 ExternalVersionIdentifier 811934140 HasUpdateAvailable Identifier com.vmware.watchlist Installing IsValidated Name Watchlist ShortVersion 1.4.2 Version 2556390 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 17743872 DeviceBasedVPP DynamicSize 36864 ExternalVersionIdentifier 828512841 HasUpdateAvailable Identifier com.panic.Prompt2 Installing IsValidated Name Prompt ShortVersion 2.6.7 Version 95685 AdHocCodeSigned AppStoreVendable BetaApp BundleSize 72933376 DeviceBasedVPP DynamicSize 12288 ExternalVersionIdentifier 819117024 HasUpdateAvailable Identifier com.zmangames.f2z.pandemic-ios Installing IsValidated Name Pandemic ShortVersion 1.2.5 Version 1432 Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/ManagedApplicationList/iOS-11.3.1-Failed.xml ================================================ CommandUUID b3f87776-66a4-40f4-a194-8b451a2eacb1 ManagedApplicationList com.apple.iWork.Keynote HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status Failed com.tinyspeck.slackmacgap HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status Failed Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/ManagedApplicationList/iOS-12.1-Failed.xml ================================================ CommandUUID 7fc66d05-4bdb-41e4-9814-5a4c21e74e16 ManagedApplicationList com.apple.iWork.Keynote HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status Failed com.tinyspeck.slackmacgap HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status Failed Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/ManagedApplicationList/iOS-12.1-Installing.xml ================================================ CommandUUID cfaa6f63-6ea9-493c-ab2e-a534937c5eda ManagedApplicationList com.apple.Numbers ExternalVersionIdentifier 829165942 HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status Installing Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/ManagedApplicationList/iOS-12.1-Managed.xml ================================================ CommandUUID 0a40830d-1cf9-4a00-a153-d8294e576c3f ManagedApplicationList com.apple.Numbers ExternalVersionIdentifier 829165942 HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status Managed Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/ManagedApplicationList/iOS-12.1-RejectedPrompting.xml ================================================ CommandUUID 2dce0324-3a9c-493d-bd7a-2d2a2985a67e ManagedApplicationList com.apple.iWork.Keynote HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status UserRejected com.tinyspeck.slackmacgap HasConfiguration HasFeedback IsValidated ManagementFlags 0 Status UserRejected Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/NotNow/iOS-11.3.1.xml ================================================ CommandUUID bb5d7813-e7c3-4279-b954-4b678925de5f Status NotNow UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/ProfileList/10.11.x.xml ================================================ CommandUUID 00000000-1111-2222-3333-444455556666 ProfileList HasRemovalPasscode IsEncrypted PayloadContent PayloadDescription Installs the TLS certificate for MicroMDM PayloadDisplayName Self-signed TLS certificate for MicroMDM PayloadIdentifier com.github.micromdm.tls PayloadOrganization PayloadType com.apple.security.pkcs1 PayloadUUID 4b12d46f-0cfb-4d58-ab5c-74873235b60b PayloadVersion 1 PayloadDescription Installs the root CA certificate for MicroMDM PayloadDisplayName Root certificate for MicroMDM PayloadIdentifier com.github.micromdm.ssl.ca PayloadOrganization PayloadType com.apple.security.root PayloadUUID de6a8869-e0c3-4fa3-ba3e-f89001f8ee71 PayloadVersion 1 PayloadDescription Enrolls with the MDM server PayloadDisplayName PayloadIdentifier com.github.micromdm.mdm PayloadOrganization MicroMDM PayloadType com.apple.mdm PayloadUUID e021da61-092a-4b73-8c26-00f5fdcf7e4e PayloadVersion 1 PayloadDescription Configures SCEP PayloadDisplayName SCEP PayloadIdentifier com.github.micromdm.scep PayloadOrganization MicroMDM PayloadType com.apple.security.scep PayloadUUID 519e158c-c699-42fd-8cdf-1cd5612088df PayloadVersion 1 PayloadDescription The server may alter your settings PayloadDisplayName Enrollment Profile PayloadIdentifier com.github.micromdm.micromdm.mdm PayloadOrganization MicroMDM PayloadRemovalDisallowed PayloadUUID dd3c707b-b18c-4979-bd94-2fe5cd804a47 PayloadVersion 1 SignerCertificates RequestType ProfileList Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/ProfileList/iOS-11.3.1.xml ================================================ CommandUUID 2d1d3845-3e06-4687-a357-b343ebb17888 ProfileList HasRemovalPasscode IsEncrypted IsManaged PayloadContent PayloadDescription Required for your device to trust the server PayloadDisplayName Certificate Authority PayloadIdentifier dev.commandment.ca PayloadType com.apple.security.root PayloadVersion 1 PayloadDescription Required for your device to trust the server PayloadDisplayName Web Server Certificate PayloadIdentifier dev.commandment.ssl PayloadType com.apple.security.pkcs1 PayloadVersion 1 PayloadDescription Required to identify your device to the MDM PayloadDisplayName device-identity PayloadIdentifier dev.commandment.identity PayloadType com.apple.security.pkcs12 PayloadVersion 1 PayloadDescription Enrolls your device with the MDM server PayloadDisplayName Device Configuration and Management PayloadIdentifier dev.commandment.mdm PayloadType com.apple.mdm PayloadVersion 1 PayloadDescription Enrolls your device for Mobile Device Management PayloadDisplayName Commandment Enrollment Profile PayloadIdentifier dev.commandment.enroll PayloadOrganization Commandment Inc PayloadRemovalDisallowed PayloadUUID ac9e8c32-5c15-40b9-b187-70a81c49aa4e PayloadVersion 1 Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/README.rst ================================================ Test Data ========= This directory contains the test fixtures. You can also place test certificates here but they will be ignored by VCS. ================================================ FILE: testdata/SecurityInfo/10.11.x.xml ================================================ CommandUUID 00000000-1111-2222-3333-444455556666 RequestType SecurityInfo SecurityInfo FDE_Enabled Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/SecurityInfo/IOS-9.x.xml ================================================ CommandUUID 00000000-1111-2222-3333-444455556666 SecurityInfo HardwareEncryptionCaps 3 PasscodeCompliant PasscodeCompliantWithProfiles PasscodeLockGracePeriod 0 PasscodeLockGracePeriodEnforced 0 PasscodePresent Status Acknowledged UDID 1111111111111111111111111111111111111111 ================================================ FILE: testdata/SecurityInfo/iOS-11.3.1.xml ================================================ CommandUUID f1048316-6628-4b32-b2f2-708d7f4d7105 SecurityInfo HardwareEncryptionCaps 3 PasscodeCompliant PasscodeCompliantWithProfiles PasscodeLockGracePeriod 0 PasscodeLockGracePeriodEnforced 0 PasscodePresent Status Acknowledged UDID 1c111c111c111c111c111c111c111c111c111c11 ================================================ FILE: testdata/SecurityInfo/macOS-10.13.1.xml ================================================ CommandUUID bf88d054-bd86-480d-b406-a1bc74a403c0 SecurityInfo FDE_Enabled FirewallSettings Applications Allowed Name pia_openvpn Allowed Name pia_openvpn_client BlockAllIncoming FirewallEnabled StealthMode FirmwarePasswordStatus SystemIntegrityProtectionEnabled Status Acknowledged UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/TokenUpdate/10.11.x-user.plist ================================================ MessageType TokenUpdate NotOnConsole PushMagic 00000000-1111-2222-3333-444455556666 Token AAAA= Topic com.apple.mgmt.test.00000000-1111-2222-3333-444455556666 UDID 00000000-1111-2222-3333-444455556666 UserID 00000000-1111-2222-3333-444455556666 UserLongName Administrator UserShortName admin ================================================ FILE: testdata/TokenUpdate/10.11.x.plist ================================================ AwaitingConfiguration MessageType TokenUpdate PushMagic 00000000-1111-2222-3333-444455556666 Token YXBwbGU= Topic com.apple.mgmt.test.00000000-1111-2222-3333-444455556666 UDID 00000000-1111-2222-3333-444455556666 ================================================ FILE: testdata/TokenUpdate/10.12.2-user.xml ================================================ MessageType TokenUpdate NotOnConsole PushMagic B81B1FEC-09C6-4EC2-871C-E521EC971B38 Token MDAyMDEwNTItQTJEMC00MDNELUI4NTctNzJGOTEzRjVCQ0NECg== Topic com.apple.mgmt.commandment.dev UDID E3568F17-92ED-450A-8904-C3BF4CB7E9A5 UserID A522C2FB-D0BA-487E-BBC6-BE0DB2DC7883 UserLongName Commando Joe UserShortName cjoe ================================================ FILE: testdata/TokenUpdate/10.12.2.xml ================================================ AwaitingConfiguration MessageType TokenUpdate PushMagic B81B1FEC-09C6-4EC2-871C-E521EC971B38 Token MDAyMDEwNTItQTJEMC00MDNELUI4NTctNzJGOTEzRjVCQ0NFCg== Topic com.apple.mgmt.commandment.dev UDID E3568F17-92ED-450A-8904-C3BF4CB7E9A5 ================================================ FILE: testdata/TokenUpdate/iOS-11.3.1.xml ================================================ AwaitingConfiguration MessageType TokenUpdate PushMagic AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA Token Base64= Topic com.apple.mgmt.XServer.1c111c11-1c11-1c11-1c11-1c111c111c11 UDID 1c111c111c111c111c111c111c111c111c111c11 UnlockToken base64== ================================================ FILE: testdata/decrypt_dep_token.sh ================================================ #!/usr/bin/env bash openssl smime -decrypt -in "${1}" -recip "./dep-public.pem" -inkey "./dep-key.pem" ================================================ FILE: testdata/dep/profile.xml ================================================ LANGUAGE en-AU PRODUCT iPad4,1 SERIAL BLXLN1111111 UDID 00000000000000000000000000000000 VERSION 15A5278f ================================================ FILE: testdata/itunes/ios-search-slack.json ================================================ { "resultCount": 50, "results": [ { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/fc/dc/5b/fcdc5bcd-2281-addb-b750-0cebfd78936f/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple62/v4/50/87/7c/50877cd2-e726-4db9-55b1-91590b39c22b/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/16/86/6f/16866fb7-7321-699d-749a-d35b9fbd61d3/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/b0/11/a3/b011a319-8fe1-1eb9-96d9-3ef93461e55f/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/5b/68/08/5b68082f-5433-d7b3-6cbd-52ee58ab01ae/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/eb/1a/20/eb1a20ee-ac93-06cb-e8ce-194ee7708007/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple62/v4/d3/9b/94/d39b9408-3b20-96f3-6f6e-66e5c915547b/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/41/35/e7/4135e704-ae40-73e2-222f-1730adf32907/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/0d/fe/54/0dfe54b5-6a73-8b5e-e64e-0f7d2de484f5/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/09/f3/72/09f3729f-2495-ee78-df00-5dffcced03ae/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/09/f3/72/09f3729f-2495-ee78-df00-5dffcced03ae/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/09/f3/72/09f3729f-2495-ee78-df00-5dffcced03ae/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/slack-technologies-inc/id453420243?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Slack", "languageCodesISO2A": [ "EN", "FR", "DE", "JA", "ES" ], "fileSizeBytes": "166092800", "sellerUrl": "https://slack.com/is", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/slack/id618783545?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2019-01-07T20:00:45Z", "releaseNotes": "What’s New \n• We now compress jpeg images while uploading them, so image uploads should be quicker and more reliable. If you're happy to sacrifice a little time for a less compressed image, that's fine too: you can toggle this setting in Settings > Advanced. \n\nBug Fixes \n• Fixed: When using an external keyboard with an iPad, some keyboard shortcuts were not behaving as they should — or, in fact, at all. They have been brought back in line, and should now function just as you'd expect. \n• Fixed: You can now use the quick switcher to switch to a DM session with someone no longer on your team. Because people may leave, but knowledge remains. It's a very useful thing that way.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2013-03-20T19:23:34Z", "currency": "AUD", "wrapperType": "software", "version": "3.59", "isVppDeviceBasedLicensingEnabled": true, "artistId": 453420243, "artistName": "Slack Technologies, Inc.", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Slack brings team communication and collaboration into one place so you can get more work done, whether you belong to a large enterprise or a small business. Check off your to-do list and move your projects forward by bringing the right people, conversations, tools, and information you need together. Slack is available on any device, so you can find and access your team and your work, whether you’re at your desk or on the go.\n\nUse Slack to: \n• Communicate with your team and organize your conversations by topics, projects, or anything else that matters to your work\n• Message or call any person or group within your team\n• Share and edit documents and collaborate with the right people all in Slack \n• Integrate into your workflow, the tools and services you already use including Google Drive, Salesforce, Dropbox, Asana, Twitter, Zendesk, and more \n• Easily search a central knowledge base that automatically indexes and archives your team’s past conversations and files\n• Customize your notifications so you stay focused on what matters\n\nScientifically proven (or at least rumored) to make your working life simpler, more pleasant, and more productive. We hope you’ll give Slack a try.\n\nHaving trouble? Please reach out to feedback@slack.com", "minimumOsVersion": "10.0", "primaryGenreName": "Business", "bundleId": "com.tinyspeck.chatlyio", "trackName": "Slack", "trackId": 618783545, "sellerName": "Slack Technologies, Inc.", "averageUserRating": 4.5, "userRatingCount": 675 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/de/95/24/de9524b2-4d1e-74b3-b911-dadf61c4251b/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/4b/dd/1c/4bdd1c11-1782-6bfd-dbe3-397b22b7b744/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/61/83/4b/61834be6-cb58-6601-10d4-3e3c10442574/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/c2/91/2a/c2912a7d-bbb4-fd90-fde1-f0e3cc8cb0bf/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/34/3f/98/343f9860-1a1b-32b0-a465-9ab658854ff5/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/c9/6c/c8/c96cc830-d281-a1e0-cbef-54fdf887283e/source/552x414bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/c7/14/a5/c714a5fe-239f-7040-df6a-6b376b798e01/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/01/b2/55/01b255fa-4951-148c-e107-6989ed54c6d2/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/92/57/7d/92577d3a-a757-875a-d2aa-0daebce717ce/source/552x414bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/f6/0d/aa/f60daa14-8310-9714-d10a-4ae13638a727/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/7a/ce/74/7ace741f-f199-f0e8-bea9-87caf1864b7f/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/7a/ce/74/7ace741f-f199-f0e8-bea9-87caf1864b7f/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/7a/ce/74/7ace741f-f199-f0e8-bea9-87caf1864b7f/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/microsoft-corporation/id298856275?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 4.5, "trackCensoredName": "Microsoft Teams", "languageCodesISO2A": [ "AK", "AR", "BG", "CA", "HR", "CS", "DA", "NL", "EN", "ET", "FI", "FR", "DE", "EL", "HE", "HU", "IS", "ID", "IT", "JA", "KO", "LV", "LT", "NB", "NN", "PL", "PT", "RO", "RU", "SR", "ZH", "SK", "SL", "ES", "SV", "TH", "ZH", "TR", "UK", "VI", "CY" ], "fileSizeBytes": "143855616", "sellerUrl": "http://aka.ms/microsoftteams", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 139, "trackViewUrl": "https://itunes.apple.com/au/app/microsoft-teams/id1113153706?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-22T00:34:34Z", "releaseNotes": "Bug fixes and performance improvements", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2016-11-02T21:19:53Z", "currency": "AUD", "wrapperType": "software", "version": "1.0.61", "isVppDeviceBasedLicensingEnabled": true, "artistId": 298856275, "artistName": "Microsoft Corporation", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Microsoft Teams is your hub for teamwork in Office 365. All your team conversations, files, meetings, and apps live together in a single shared workspace, and you can take it with you on your favorite mobile device. Whether you’re sprinting towards a deadline or sharing your next big idea, Teams can help you achieve more.\n\nYOUR HUB FOR TEAMWORK\n* Easily manage your team’s projects with file editing and sharing on the go\n* Connect face-to-face with HD audio and video, and join meetings from almost anywhere\n* Chat privately or in groups, and communicate with the entire team in dedicated channels\n* Mention individual team members, or the whole team at once, to get your colleagues’ attention\n* Focus on what matters most by saving important conversations and customizing your notifications\n* Search your chats and team conversations to quickly find what you need\n* Get the enterprise-level security and compliance you expect from Office 365\n\nThis app requires a paid Office 365 commercial subscription, or a free or trial subscription of Microsoft Teams. If you’re not sure about your company’s subscription or the services you have access to, visit Office.com/Teams to learn more or contact your IT department.\n\nBy downloading Teams, you agree to the license (see aka.ms/eulateamsmobile) and privacy terms (see aka.ms/privacy). For support or feedback, email us at mtiosapp@microsoft.com", "minimumOsVersion": "10.0", "primaryGenreName": "Business", "bundleId": "com.microsoft.skype.teams", "trackName": "Microsoft Teams", "trackId": 1113153706, "sellerName": "Microsoft Corporation", "averageUserRating": 4.5, "userRatingCount": 6766 }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/1c/24/33/1c2433b9-b675-7e02-5485-48920342507d/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/26/ef/f0/26eff0e3-0447-68e1-4642-49c237368109/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/f6/11/a1/f611a1f0-a919-9d7c-4fa6-ca0e05482176/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/29/33/33/2933339a-eebd-7f60-114d-ac8bbf01008b/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/52/3b/7d/523b7d79-78e9-1af2-3ec7-448d09226b61/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/ff/af/e0/ffafe074-89e5-c167-dc66-3416688a0ad5/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/45/f1/cb/45f1cb0b-982f-cec1-e26b-afb2bd47fdab/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/56/c8/05/56c805b8-2240-7fc2-fcb4-4449a08da981/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/00/dc/5e/00dc5e8e-05bf-27eb-6e67-668211fd49ce/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/d0/35/55/d0355525-7805-3123-547a-bd1f25d8dfc0/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/9b/05/ac/9b05ac49-510b-e7cf-b278-0ae9b8ebcd2c/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/07/1b/b0/071bb00e-8d91-702a-bf79-9537455a80e7/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/42/3d/fb/423dfb2e-dd8d-f9cd-f3ef-2a2cdc050460/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/42/3d/fb/423dfb2e-dd8d-f9cd-f3ef-2a2cdc050460/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/42/3d/fb/423dfb2e-dd8d-f9cd-f3ef-2a2cdc050460/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/ifttt/id660944638?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 4.5, "trackCensoredName": "IFTTT", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "71264256", "sellerUrl": "https://ifttt.com", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 205, "trackViewUrl": "https://itunes.apple.com/au/app/ifttt/id660944635?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-10T21:32:38Z", "releaseNotes": "+ We fixed an issue that caused your Activity feed and My Applets to slow down.\n\n+ Recently launched services on IFTTT include: Ai-Sync, Fanimation, Aquanta, Home + Control, and Mitsubishi Electric kumo cloud.\n\n+ The latest version of the IFTTT app includes a security improvement for iOS Photos Applets and the Camera widget.", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6002" ], "releaseDate": "2013-07-11T07:00:00Z", "currency": "AUD", "wrapperType": "software", "version": "3.7.8", "isVppDeviceBasedLicensingEnabled": true, "artistId": 660944638, "artistName": "IFTTT", "genres": [ "Productivity", "Utilities" ], "price": 0.00, "description": "Applets bring your favorite services together to create new experiences.\n\nOver 600 apps work with IFTTT including Twitter, Telegram, Google Drive, Twitch, Weather Underground, Instagram, Gmail, and devices like Google Home, Amazon Alexa, Nest, Philips Hue, and your iPhone. The IFTTT app also integrates with the Health app, so you can easily track and maintain your habits. \n\nTurn on Applets and:\n\n• Control everything around you with your voice and Amazon Alexa or Google Assistant\n• Stay informed about what’s happening from publications like The New York Times and ProPublica\n• Always stay prepared for the weather with custom daily forecast notifications\n• Message roommates when you’re near the local grocery\n• Get an alert as soon as there’s a new Craigslist listing that matches you search\n• Stay safe with automated and intelligent home security alerts\n• Streamline your social media\n• Back up and share your iOS photos automatically\n• Back up important files, photos, and contacts to cloud-storage solutions, such as Dropbox or Google Drive\n• Set your home thermostat to an optimal temperature when you arrive home\n• Post all your Instagrams as Twitter photos or Pinterest pins\n• Trigger events based on your current location\n\n\nThere are thousands of other use cases! New services are added every week. Some popular ones include:\n\nTwitch, Telegram, Spotify, YouTube, Google Calendar, Tumblr, Medium, Pocket, Square, eBay, Giphy, Automatic, LIFX, Fitbit, Withings, littleBits, Google WiFi, Evernote, Reddit, Digg, Skype, Slack, LINE, MailChimp, Salesforce, Todoist, and hundreds more.\n\nBrowse our curated collections to find Applets for:\n\n• The home, office, and car\n• Staying informed on news and politics\n• Your iPhone and iPad\n• Exploring outer space\n• Improving how you use social media\n\nDo more with the services you love. Discover the power of Applets at ifttt.com/discover", "minimumOsVersion": "10.0", "primaryGenreName": "Productivity", "bundleId": "com.ifttt.ifttt", "trackName": "IFTTT", "trackId": 660944635, "sellerName": "IFTTT Inc", "averageUserRating": 4.5, "userRatingCount": 1924 }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple49/v4/10/03/cd/1003cd0d-c7b7-e101-6517-f073898cee64/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple69/v4/94/0c/6a/940c6ad6-8292-7659-548a-c04627f16538/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple69/v4/64/be/70/64be7070-633e-456c-c4a0-34c6f84993c9/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple69/v4/7c/9c/b1/7c9cb17d-5984-b75e-a722-d52422ce09ad/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple49/v4/35/29/4a/35294a32-6456-1ac0-6faf-4da93b826e5e/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple69/v4/69/71/ae/6971aeff-e935-4bc9-f0ef-d6cc899effb8/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple49/v4/5f/58/f6/5f58f688-d14b-2350-7e8c-197d5e00d82b/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple69/v4/2c/9e/2a/2c9e2a6e-2c83-6c8d-48c8-8185476b7c78/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple69/v4/fe/86/b1/fe86b171-52b6-c909-942e-d78d36cbce33/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple49/v4/f4/7c/9b/f47c9b96-ec55-f3b3-b0cb-28e7eee919d5/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple127/v4/c5/75/d1/c575d1b5-6f1d-0e50-6c1e-62524fd51b3a/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple127/v4/c5/75/d1/c575d1b5-6f1d-0e50-6c1e-62524fd51b3a/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple127/v4/c5/75/d1/c575d1b5-6f1d-0e50-6c1e-62524fd51b3a/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/weipei-deng/id1042972016?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 4.5, "trackCensoredName": "CountDown Tracker to Christmas Birthday Date Event", "languageCodesISO2A": [ "EN", "FR", "DE", "RU", "ZH", "ES", "ZH" ], "fileSizeBytes": "62970880", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 14, "trackViewUrl": "https://itunes.apple.com/au/app/countdown-tracker-to-christmas-birthday-date-event/id647750636?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-05-31T08:33:06Z", "releaseNotes": "* Bug fixes and stability improvements", "primaryGenreId": 6002, "formattedPrice": "Free", "genreIds": [ "6002", "6007" ], "releaseDate": "2013-05-21T03:25:40Z", "currency": "AUD", "wrapperType": "software", "version": "3.5", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1042972016, "artistName": "WEIPEI DENG", "genres": [ "Utilities", "Productivity" ], "price": 0.00, "description": "Sometimes our life is frittered away by detail, making us forget something important. Hence, a smart Countdown app to remind us is extremely necessary.\n\nCount the future and past dates as well. Just add events that you’ve been expecting: anniversary, birthday, Valentine's Day, Halloween, Christmas, etc. Also, you can count the past events such as: the first date with your darling, the date you were born…\n\nIt can be shown on the Notification Widget conveniently, and the timer would ring to remind you when time runs out. From now on, catch every precious moment, to “let your life lightly dance on the edges of Time, like dew on the tip of a leaf.”\n\nCool Features:\n- Show days, hours, minutes and even seconds\n- Displayed on Notification Widget conveniently\n-Smart Reminder are provided\n-Various wallpapers for any occasion\n-Customize event backgrounds \n-Share your happiness with others", "minimumOsVersion": "8.0", "primaryGenreName": "Utilities", "bundleId": "flyman.countdownlite", "trackName": "CountDown Tracker to Christmas Birthday Date Event", "trackId": 647750636, "sellerName": "WEIPEI DENG", "averageUserRating": 4.5, "userRatingCount": 614 }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/de/79/12/de7912b3-aa08-6802-8b0f-3672fc3a117c/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/97/d2/58/97d25815-cc8d-d2f7-08d7-a210e612a7f3/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/eb/80/a5/eb80a516-dbd4-5bcc-2682-2166d99e604f/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/99/f2/b1/99f2b14f-f8cb-4301-6901-446b0b769366/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/98/51/59/9851599b-9b63-d1f7-d27d-b51872fa0bc9/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/ac/ff/d3/acffd339-effe-4af0-2d6b-e463c4a9d381/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/4a/6c/38/4a6c3856-8eee-0fe1-7f52-b334b7a236d6/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/e2/8d/44/e28d4496-8863-dab9-7056-8f63dfdd62d0/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/a5/44/59/a54459eb-b189-caca-e7f5-edf3a88abcdc/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/8b/3a/3d/8b3a3db4-7328-9a2c-0280-166c1c67f322/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/8b/3a/3d/8b3a3db4-7328-9a2c-0280-166c1c67f322/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/8b/3a/3d/8b3a3db4-7328-9a2c-0280-166c1c67f322/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/slack-technologies-inc/id453420243?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Slack for EMM", "languageCodesISO2A": [ "EN", "FR", "DE", "JA", "ES" ], "fileSizeBytes": "162906112", "sellerUrl": "https://slack.com/is", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/slack-for-emm/id1254292716?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-13T21:51:49Z", "releaseNotes": "Bug Fixes \n• Fixed: If you have a DM conversation containing only one message, you can, once more, long press to mark that message as unread, and come back to it later. Or not. Up to you. \n• Fixed: Changing status was proving inconceivably tricky for people whose workspace had customized the list of statuses. Now you can select, deselect, reselect, and customize your status as you wish. \n• Everything else is fine. (We hope.)", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2017-08-07T20:54:16Z", "currency": "AUD", "wrapperType": "software", "version": "3.58", "isVppDeviceBasedLicensingEnabled": true, "artistId": 453420243, "artistName": "Slack Technologies, Inc.", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Slack for EMM is for Slack customers with Enterprise Mobility Management (EMM) enabled. \nIf you’re unsure whether this applies to your organization, we recommend using the regular Slack app.", "minimumOsVersion": "10.0", "primaryGenreName": "Business", "bundleId": "com.slack.slackmdm", "trackName": "Slack for EMM", "trackId": 1254292716, "sellerName": "Slack Technologies, Inc." }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/c1/18/82/c11882cb-e0fb-b151-83c0-92f4dd43a76b/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/4f/d7/a0/4fd7a04f-5355-feb7-1587-17db4f44d5fb/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/40/1d/08/401d0879-309f-610a-e120-4dc5b0fe21a4/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/9e/c1/d6/9ec1d6b6-53a7-3951-937d-d9d5edf66b6b/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/cc/a2/81/cca28173-81d4-84d1-4c2b-8d6fef9df8ed/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/4c/0d/7c/4c0d7c14-d51b-8bf0-2120-832edcc20112/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/b5/61/b0/b561b044-d429-fc51-11f0-b868f811d858/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/00/3a/7e/003a7e1f-4bd2-6609-cecb-d71dcf6f8c88/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/a8/31/b8/a831b8cb-4fd4-c731-8750-f10ab78b74d5/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/26/b0/91/26b09105-e141-fb49-c0fd-2fd0165055c6/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/bd/1c/53/bd1c53f8-9aae-f2f3-909c-77c67d95ad08/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/1a/b5/41/1ab541cc-03b6-a226-f003-6112994996c1/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/c7/0b/d2/c70bd23b-4524-4e09-7ef6-2f2f725a9585/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/a4/5b/29/a45b293a-db19-f831-df5e-21fa0c44b240/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/6b/cf/51/6bcf51be-1a35-e032-07ac-38f73607bc1c/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/4b/ef/f6/4beff6d0-a6e3-782b-2ec8-92fdb16d6a97/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/32/b3/6f/32b36fa8-054c-d6e0-d3bc-ae5cf566231f/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/5c/93/9e/5c939ee1-589b-448a-04b3-22c20adfaf29/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/ee/fb/23/eefb23c3-6e5a-23ec-6db1-92ab6ec42408/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/e2/c5/00/e2c5007f-d80e-e4ee-66f0-9d0b02f176c2/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/f4/6a/61/f46a61bf-49fa-1e4f-35fe-1a005ed40ab1/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/f4/6a/61/f46a61bf-49fa-1e4f-35fe-1a005ed40ab1/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/f4/6a/61/f46a61bf-49fa-1e4f-35fe-1a005ed40ab1/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/readdle-inc/id285035419?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 4.5, "trackCensoredName": "Email - Spark by Readdle", "languageCodesISO2A": [ "EN", "FR", "DE", "IT", "JA", "PT", "RU", "ZH", "ES" ], "fileSizeBytes": "132473856", "sellerUrl": "http://sparkmailapp.com", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 111, "trackViewUrl": "https://itunes.apple.com/au/app/email-spark-by-readdle/id997102246?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-12T09:10:45Z", "releaseNotes": "Today's update brings you a handful of important fixes and performance improvements.\nLet's dive into the release note: \n## Fixed\n- Odd scenario when snoozed emails due to unstable Internet connection returned to your Inbox earlier than they should. This should no longer happen, we promise.\n## Improved\n- Contact suggestions in a composer. Thank you to everyone for your feedback on contact duplicates, naming issues and irrelevant suggestions in the email composer. We invested a ton of time in identifying and improving those. Suggestions will now also prioritize real people you contacted over automated services.\n\nWe hope you love the update as much as we do! \nKeep the feedback coming at rdsupport@readdle.com and stay tuned for news and exciting features.", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6002" ], "releaseDate": "2015-05-29T11:07:57Z", "currency": "AUD", "wrapperType": "software", "version": "2.1.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 285035419, "artistName": "Readdle Inc.", "genres": [ "Productivity", "Utilities" ], "price": 0.00, "description": "Spark is the best personal email client and a revolutionary email for teams. You will love your email again! \n\n\"Best of the App Store\" - Apple\n\"It's a combination of polish, simplicity, and depth\" - FastCompany\n\"You can create an email experience that works for you\" - TechCrunch\n\n**Beautiful and Intelligent Email App**\nWe are building the future of email. Modern design, fast, intuitive, collaborative, seeing what’s important, automation and truly personal experience that you love - this is what Spark stands for.\n\n**Farewell to Busy Inbox**\nSmart Inbox lets you quickly see what's important in your inbox and clean up the rest. All new emails are smartly categorized into Personal, Notifications and Newsletters.\n\n**Discuss email privately**\nInvite teammates to discuss specific emails and threads. Ask questions, get answers, and keep everyone in the loop.\n\n**Create email together**\nFor the first time ever, collaborate with your teammates using real-time editor to compose professional emails.\n\n**Schedule emails to be sent later**\nSchedule emails to be sent when your recipient is most likely to read them. It works even if your device is turned off.\n\n**Snooze That One For Later**\nSnooze an email and get back to it when the time is right. Snoozing works across all your Apple devices.\n\n**Find Any Email In An Instant**\nPowerful, natural language search makes it easy to find that email you're looking for. Just search the way you think and let Spark do the rest.\n\n**Get Notified About Important Emails Only**\nSmart Notifications filter out the noise, letting you know when an email is important, saving you from notification overload.\n\n**Powerful Integrations**\nIntegrate Spark into your workflow and take productivity to the next level. Supports Dropbox, Box, iCloud Drive, and more.\n\n**Built-in calendar**\nA full-featured calendar works right in your email to help you always be on top of your schedule. Create events easily using natural language.\n\n**Create links to email**\nCreate secure links to a specific email or conversation. Share the link on Slack, Skype, CRM, or any other medium so your team can see it and collaborate around it.\n\n**Sign Off With A Swipe**\nBefore you send an email, quickly swipe to choose the right signature for the occasion.\n\n**Email with Emotion**\nQuick Replies get the point across with just a tap. Love, like or acknowledge an email in an instant.\n\n**Email Never Looked This Good**\nThat terrible mess in your inbox is now replaced it with a beautiful, threaded message design.\n\n**A Truly Personal Experience**\nCustomize Spark to work as you do. You decide which swipes do what, what cards are shown, and how many emails you want to see.\nYou’ll love your email again!\n \nIf you need us, you can always find us at rdsupport@readdle.com", "minimumOsVersion": "11.0", "primaryGenreName": "Productivity", "bundleId": "com.readdle.smartemail", "trackName": "Email - Spark by Readdle", "trackId": 997102246, "sellerName": "Readdle Inc.", "averageUserRating": 4.5, "userRatingCount": 2993 }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple30/v4/04/48/53/044853ef-ab01-5f7d-a048-7326776e9179/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple60/v4/06/ab/f3/06abf357-30b7-d3f4-c5c0-5762582aa638/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple20/v4/69/ef/f9/69eff9c3-3368-9c16-c9cf-fcf9d45e9806/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple18/v4/85/05/bb/8505bba4-bc30-2cbe-1a3e-ddda4ea2be9c/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple20/v4/10/ab/f6/10abf68d-f37a-e32e-2178-428e5c588b4a/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple4/v4/e1/12/ba/e112ba92-f938-b351-060f-470d81d87b0a/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple4/v4/e1/12/ba/e112ba92-f938-b351-060f-470d81d87b0a/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple4/v4/e1/12/ba/e112ba92-f938-b351-060f-470d81d87b0a/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/pablo-episcopo/id1081953530?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "averageUserRatingForCurrentVersion": 3.5, "trackCensoredName": "Recordify - Quickly send audio messages to Slack", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "9746432", "sellerUrl": "https://www.recordify.io", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 3, "trackViewUrl": "https://itunes.apple.com/au/app/recordify-quickly-send-audio-messages-to-slack/id1081953531?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2016-05-09T13:32:44Z", "releaseNotes": "As easy and fast as always. But better.\n\n• With multi-account feature you can login to all your teams.\n• Send voice to Private Groups and user through Direct Message. \n• Rating Reminder integrated on this version. We’re not gonna spam you, promise!\n• A few small bug fixes and performance Improvements.", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6002" ], "releaseDate": "2016-03-14T12:14:06Z", "currency": "AUD", "wrapperType": "software", "version": "1.1", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1081953530, "artistName": "Pablo Episcopo", "genres": [ "Productivity", "Utilities" ], "price": 0.00, "description": "Give Slack superpowers! Send voice messages instantly.\n\nRecordify is a refined, well-crafted, decidedly straightforward single feature app. Boost your productivity and experience how minimalist visuals hide simplicity reimagined.\n\nThree simple rules:\n\n• Hold to Record.\n• Drag to Cancel.\n• Release to Send.\n\nWe love feedback! So email us at feedback@recordify.io\n\nFollow us on our Twitter @recordifyio", "minimumOsVersion": "9.0", "primaryGenreName": "Productivity", "bundleId": "com.recordifyio", "trackName": "Recordify - Quickly send audio messages to Slack", "trackId": 1081953531, "sellerName": "Pablo Episcopo" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple62/v4/a6/d7/ce/a6d7cecd-1ff4-0936-4015-fc3011cc1fe7/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple41/v4/1d/e6/41/1de6416a-c1d1-6c92-c5f7-aa6c6d774324/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple71/v4/ec/c4/67/ecc46760-b60b-f7cc-4da0-ecd35374a7cd/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple62/v4/40/0e/5b/400e5b6e-b6e3-fbec-defb-9ed6c43a953b/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple62/v4/ec/f7/77/ecf777e6-6053-5a8e-0ec0-9b29bfb78550/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple122/v4/d5/57/61/d5576193-9c94-0b87-2406-df8a737cd819/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple122/v4/d5/57/61/d5576193-9c94-0b87-2406-df8a737cd819/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple122/v4/d5/57/61/d5576193-9c94-0b87-2406-df8a737cd819/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/companyons/id661578187?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "averageUserRatingForCurrentVersion": 3.5, "trackCensoredName": "Kyber for Slack | Project Management, Todo & Task", "languageCodesISO2A": [ "EN", "FR", "DE", "IT", "PT", "RU", "ES", "TR" ], "fileSizeBytes": "38853632", "sellerUrl": "http://kyber.me", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 2, "trackViewUrl": "https://itunes.apple.com/au/app/kyber-for-slack-project-management-todo-task/id898004872?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-01-19T22:10:36Z", "releaseNotes": "Calendar sync fix.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2014-07-25T01:38:27Z", "currency": "AUD", "wrapperType": "software", "version": "1.2.19", "isVppDeviceBasedLicensingEnabled": true, "artistId": 661578187, "artistName": "Companyons", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Make things happen with the people you care about! For teams, families, couples, friends. And for Slack: http://kyber.me/slack\n\nFeatured by Apple as \"Best New App\", Kyber is a fantastic new app that integrates messaging with your calendars, reminders, to-dos, maps to get more done together and make your life so much easier. Kyber also lets you keep everything you have going on in your personal or work life under control: finally one single place to view your daily calendars, reminders, and todos combined together.\n\nKyber can be added to your Slack team to turn messages into actions or integrated with IFTTT to extend it to any other apps like Gmail, Evernote, etc. Learn more at http://kyber.me/slack and http://kyber.me/IFTTT\n\nExciting way to use Kyber\n====================\nUse Kyber to:\n• keep your family organized with shared events and reminders for daily chores\n• make your team or business highly efficient with tasks easily assigned to each other and meetings instantly set up\n• take the pain out of organizing fun activities with your friends with smart messaging\n• manage your day with all your personal and shared tasks in one place.\n\nKyber is the simplest to use: just type what to do as you would speak it and add few selected emojis to magically:\n• send reminders to others going off at specified time\n• check and update calendars while typing an invite\n• search for a place or address and add a map to your message\n• add a checklist to track items (grocery shopping anyone?)\n• ask somebody to do something for you and track it until is done\n• easily organize something with others\n• create personal tasks with time, location, checklist, notes\n\nSlack and IFTTT\n============\nYou can use Kyber along with Slack to access your calendars, reminders, to-dos from your desktop and easily assign tasks to co-workers or schedule meetings with natural language. Kyber is also powered by IFTTT to integrate your workflows with hundreds of apps such as Gmail, Evernote, Weather and much more.\nLearn more at http://kyber.me/slack and http://kyber.me/ifttt.\n\nFew examples\n===========\nImagine to send a message like “Let’s meet for coffee at 5:30 PM at Starbucks” and...\n• Know in advance if you or the other person are free at 5:30 PM to minimize the “Are you free?” back and forth\n• Automatically add the event to each other calendars and later get reminded about it\n• Allow the other person to update the event just replying “Let’s do 4:30 PM” or “I rather do Peet’s Coffee\"\n• Check maps and direction with a tap when it’s time to leave\n• Chat in the context of the specific event\n\nOr another message that says: “Can you pick up grocery 5 PM Whole Foods? bread, milk, eggs” and...\n• Add it to recipient to-do list so it can be tracked (and get done)\n• Have an alert going off at 5 PM to remind the recipient\n• Add a checklist that can be edited by both and then checked off while shopping\n• Message while going through the list\n• Automatically get notified when the task is done\n• Or... easily follow up if not done yet", "minimumOsVersion": "8.0", "primaryGenreName": "Business", "bundleId": "com.companyons.loop", "trackName": "Kyber for Slack | Project Management, Todo & Task", "trackId": 898004872, "sellerName": "Companyons, Inc." }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple1/v4/9d/ba/f7/9dbaf7fa-3401-3533-685e-18d0de5e2042/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple1/v4/e9/66/29/e96629ea-b02a-6497-b47e-4d72270f6f90/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple5/v4/4e/b2/9b/4eb29bef-f7da-c028-c989-aec7cf0dd06f/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple7/v4/c0/dc/14/c0dc1420-48fd-ec99-a850-7952b41c15fc/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple1/v4/69/fe/04/69fe0423-5670-8b98-7cef-1524a8695b30/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple7/v4/1d/36/b1/1d36b1d8-9dda-e89c-9d0b-3b0edb317959/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple7/v4/1d/36/b1/1d36b1d8-9dda-e89c-9d0b-3b0edb317959/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple7/v4/1d/36/b1/1d36b1d8-9dda-e89c-9d0b-3b0edb317959/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/hooloop-corporation/id889892100?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone4-iPhone4", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "averageUserRatingForCurrentVersion": 1.0, "trackCensoredName": "Hooloop Memo - Voice for Slack", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "9134080", "sellerUrl": "https://www.hooloop.com", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/hooloop-memo-voice-for-slack/id967109730?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2015-08-06T01:26:57Z", "releaseNotes": "Don't show deleted members in the members list anymore.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2015-04-16T17:46:35Z", "currency": "AUD", "wrapperType": "software", "version": "1.3.2", "isVppDeviceBasedLicensingEnabled": true, "artistId": 889892100, "artistName": "Hooloop Corporation", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Slack is a fantastic platform for teams. And if you are part of a team that holds daily standup/scrum meetings, you've heard this one before \"let's take the questions and comments offline!\" With Hooloop Memo you can easily record your daily standup as voice messages, automatically post them to Slack, and keep the conversation going. \n\nEver wanted to leave a quick message to your Slack team without typing? Now you can! Record a voice message and send it to any of your Slack channels.\n\nHooloop Memo is based on the Hooloop voice communication service. Hooloop voice messages can be accessed on the mobile Slack app, the Slack app for Mac, and the Slack web app. \n\n______◢◤ FEATURES ◥◣______\n\n▪ Easy, simple and fast\n▪ Sign in using your Slack accounts\n▪ Chose from any of your channels, private groups or team members\n▪ Messages are posted to Slack within seconds\n\nPlease note that Hooloop Memo is not affiliated, associated, endorsed by, or in any way officially connected with Slack.", "minimumOsVersion": "7.0", "primaryGenreName": "Business", "bundleId": "com.hooloop.dementor", "trackName": "Hooloop Memo - Voice for Slack", "trackId": 967109730, "sellerName": "Hooloop Corporation" }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/24/b9/7a/24b97a13-262c-95b7-f8bb-771306e54efd/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/ae/8b/7f/ae8b7fd4-6385-b92d-2652-5affd858ac2b/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/bf/99/53/bf99534e-042c-6edd-f264-0e65b80b3027/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/87/38/87/873887d3-86da-575e-8bcc-33e9546e0a93/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/60/d1/b0/60d1b054-e110-5604-3aa9-ef43ebc9a333/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/58/f2/1d/58f21d26-2a43-e827-3daf-dac98fd639b7/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/88/ff/c2/88ffc238-3834-03c6-8525-6b688b71b134/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/88/ff/c2/88ffc238-3834-03c6-8525-6b688b71b134/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/88/ff/c2/88ffc238-3834-03c6-8525-6b688b71b134/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/constflash/id883373065?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Widget for Slack Lite", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "5275648", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/widget-for-slack-lite/id1183240030?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-10-31T00:34:10Z", "releaseNotes": "bug fixed\nquick answers", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2016-12-08T06:11:17Z", "currency": "AUD", "wrapperType": "software", "version": "1.5", "isVppDeviceBasedLicensingEnabled": true, "artistId": 883373065, "artistName": "ConstFlash", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "Now you can access all important information about your Slack's teams from your lock screen or any app. Add Widgets for Slack to the Notification Center - and you will get quick access to Slack's functions.\n\nWith this app you can see all unread messages, channels and people. \nEasy navigation through sections will provide you quick access to helpful information.\n\nYou can jump directly from this widget to any channel of Slack's official app.", "minimumOsVersion": "10.0", "primaryGenreName": "Productivity", "bundleId": "com.constflash.widgetslackfree", "trackName": "Widget for Slack Lite", "trackId": 1183240030, "sellerName": "ConstFlash LTD" }, { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/be/9d/90/be9d9061-df65-7f41-8156-fa7912b0ce82/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/cf/df/4d/cfdf4d7d-1f28-7c0f-81a1-7a4a3f66744a/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/2c/d2/84/2cd2843d-c656-2a6a-cc7d-e8dcf73f880d/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/2a/cb/2c/2acb2ced-8099-4d93-8bdd-757afc52d64f/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/11/fb/9b/11fb9b58-9144-c138-b782-60dca84ce1a1/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/8e/68/f9/8e68f980-5bc9-3209-a5d2-d013e0e99d2d/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/8e/68/f9/8e68f980-5bc9-3209-a5d2-d013e0e99d2d/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/8e/68/f9/8e68f980-5bc9-3209-a5d2-d013e0e99d2d/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/tmall-com/id518966504?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "千牛–卖家移动工作台", "languageCodesISO2A": [ "ZH", "EN", "ZH", "ZH" ], "fileSizeBytes": "227956736", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/%E5%8D%83%E7%89%9B-%E5%8D%96%E5%AE%B6%E7%A7%BB%E5%8A%A8%E5%B7%A5%E4%BD%9C%E5%8F%B0/id590217303?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-17T09:50:41Z", "releaseNotes": "We update the app regularly so we can make it better for you:\n[New Features] Alibaba workbench rights center, more choices for you\n[Optimization] Optimize the performance and user experiences", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2013-01-24T18:12:35Z", "currency": "AUD", "wrapperType": "software", "version": "7.0.50", "isVppDeviceBasedLicensingEnabled": true, "artistId": 518966504, "artistName": "Tmall.com", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "----With Alibaba Workbench, you can handle anything----\n\n√ A mobile seller workbench that is an official product of Alibaba\n\n√ Provides services for businesses such as Alibaba.com ,Taobao, 1688, offline stores, , etc., and is an essential item for any business\n\n√ Dedicated to providing solutions for businesses and increasing operational efficiency.\n\n----Basic Description----\n\nAlibaba Workbench is an official product of Alibaba that serves as a dedicated e-commerce store operation, business management and information mobile tool for sellers from China and all over the world. Through Alibaba Workbench, you can easily manage store merchandise and orders, check shop data and messages, process quotes, and take hold of business opportunities anytime, anywhere, allowing you to best manage your scattered time and better interact with potential buyers.\n\n----Main Features----\n\n[Workbench] Provides core operational data and a personalized operation page that includes plugins for things like products, order transactions, member management, inquiries and requests for quotation (RFQ). Shop owners can add or delete plugins freely to build their own personalized workbench.\n\n[Messages] Notification messages for the first time receiving goods, orders, business opportunities and campaigns.\n\n[Chat] Inquiries from buyers can be answered in the blink of an eye. Alibaba Workbench supports a computer and mobile device being logged into the same account at the same time so that no transactions will be missed. There are also several ways to chat such as voice and video chat, and you can also check the read/unread status of messages and quickly understand what the buyer wants.\n\n[Services] Provides e-commerce services to businesses and increases their operational efficiency.\n\n[Headlines] Abundant and up-to-date e-commerce news with diverse content such as official laws, marketing strategies, high-quality products and live videos.\n\n \n\n----Contact Us----\n\nIf you encounter any problems while using the workbench, please contact us at:\n\n- Online assistance: Go to Alibaba Workbench > My > Settings > Questions and Feedback\n\n- WANGWANG Community: Join the WANGWANG Community at 841497597, the password is qianniu1234\n\n- Alibaba.com International Business Contact Telephone: (+86) 400-826-1688", "minimumOsVersion": "8.0", "primaryGenreName": "Business", "bundleId": "com.taobao.sellerplatform", "trackName": "千牛–卖家移动工作台", "trackId": 590217303, "sellerName": "Zhejiang Taobao Mall Technology Co,Ltd.", "averageUserRating": 2.0, "userRatingCount": 13 }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/e0/28/bf/e028bf1b-4cea-6e62-7bf7-fed9f69c1d71/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/27/6f/84/276f84bf-a6b7-c22d-715c-0a0f99882dec/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/3f/23/93/3f2393a8-d54a-3605-3821-1b718b86c629/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/a5/80/fa/a580fab5-1292-2234-e133-d454cfcec2dd/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/a5/80/fa/a580fab5-1292-2234-e133-d454cfcec2dd/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/a5/80/fa/a580fab5-1292-2234-e133-d454cfcec2dd/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/yusuke-murata/id704570686?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "MultiTeam for Slack - Multiple Team Communitation", "languageCodesISO2A": [ "EN", "JA" ], "fileSizeBytes": "53404672", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/multiteam-for-slack-multiple-team-communitation/id1091407464?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-07-18T00:08:18Z", "releaseNotes": "- Show unread count to icon badge\n- Fix channel order", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6005" ], "releaseDate": "2016-04-14T19:11:46Z", "currency": "AUD", "wrapperType": "software", "version": "1.1.8", "isVppDeviceBasedLicensingEnabled": true, "artistId": 704570686, "artistName": "Yusuke Murata", "genres": [ "Productivity", "Social Networking" ], "price": 0.00, "description": "MultiTeam is a Slack (team communication service) client for multiple team users.\nYou can easily switch channels across different teams.\n\n- Sign in to up to 3 teams for free, and unlimited teams after in-app purchase. \n- Sort channels and direct messages in all teams by latest message timestamp as standard messenger apps.\n- Send \"like\" emoji with one tap!\n\nIf you have any feature requests or bug reports, please feel free to contact @MultiTeamSlack via twitter in English or Japanese.", "minimumOsVersion": "10.0", "primaryGenreName": "Productivity", "bundleId": "com.muratayusuke.slackmsg", "trackName": "MultiTeam for Slack - Multiple Team Communitation", "trackId": 1091407464, "sellerName": "Yusuke Murata" }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/d7/35/57/d7355752-3882-0a20-a713-b4354365a5e0/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/ad/53/ad/ad53ad21-bc9e-d158-f4b9-f7e5228d15c0/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/e4/bc/c7/e4bcc7f1-5b36-c48d-8e22-3f9e0aa29d3c/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/86/5c/75/865c75d7-a997-1e10-8a32-f131cf8ee075/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/e2/14/56/e2145655-1266-ac98-8cee-8ab104dc0dcc/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple124/v4/d4/d5/4a/d4d54a3b-4c07-a331-2bc6-e66a5c73f824/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/ca/28/b4/ca28b442-9eaa-fb8e-566a-f90f7e9ab92a/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/23/97/87/239787ec-bb33-dca4-6b8e-08262470faae/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/e8/ef/57/e8ef5715-db7c-4553-0cb2-3e2adc6dd044/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/bf/b9/a5/bfb9a5d4-86e6-d155-87cd-1f53e1b79f14/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/7e/74/b7/7e74b70d-a7d2-f641-f6bd-1ea5e2a5a20b/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/7f/a0/e3/7fa0e342-d001-fb6c-0458-022a655c1666/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/7f/a0/e3/7fa0e342-d001-fb6c-0458-022a655c1666/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/7f/a0/e3/7fa0e342-d001-fb6c-0458-022a655c1666/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/bloop-s-r-l/id389546852?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Airmail - Your Mail With You", "languageCodesISO2A": [ "AR", "CA", "HR", "CS", "DA", "NL", "EN", "FR", "DE", "HE", "HU", "IT", "JA", "KO", "NB", "NN", "PL", "PT", "RO", "RU", "ZH", "SK", "ES", "SV", "ZH", "TR", "UK" ], "fileSizeBytes": "127112192", "sellerUrl": "http://airmailapp.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/airmail-your-mail-with-you/id993160329?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2019-01-03T21:23:18Z", "releaseNotes": "- Bugfix", "primaryGenreId": 6007, "formattedPrice": "$7.99", "genreIds": [ "6007", "6002" ], "releaseDate": "2016-02-01T05:28:19Z", "currency": "AUD", "wrapperType": "software", "version": "1.8.14", "isVppDeviceBasedLicensingEnabled": true, "artistId": 389546852, "artistName": "Bloop S.R.L.", "genres": [ "Productivity", "Utilities" ], "price": 7.99, "description": "Airmail is a powerful mail client for Mac, now available for iPhone and iPad.\n\nDesigned for the latest generation iOS, it supports 3D Touch, fast document previewing, high quality PDF creation, and native integration with other apps and services for a frictionless workflow. \n\nWorkflow customization is at the core, with a rich feature set like snooze, interactive push notifications, and full inbox sync. \n\niCloud sync provides a fully ubiquitous experience so that all your accounts and app preferences are synced.\n\nBASIC\n- Support for Gmail, Exchange EWS, IMAP and POP3\n- Push notifications, with VIP, custom actions, full body preview and custom sounds\n- Apple Watch app with glance and interactive notifications\n- Customizable swipes\n- Threads and single messages\n- Snooze messages\n- Bulk editing \n- iCloud sync between Mac and iOS\n- Drafts\n- Aliases\n- Multiple signatures \n- Unified inbox\n- Horizontal layout\n- 19 languages\n\nADVANCED\n- 3D Touch quick access\n- 3D Touch Peek and Pop\n- Spotlight search for documents and messages\n- Share composer extension\n- iCloud sync for labels, preferences and accounts across Mac and iOS\n- Handoff between Mac and iOS\n- Notifcation Based on Locations \n**** \"Continued use of GPS running in the background can dramatically decrease battery life.\"\n\nSEARCH AND FILTERS\n- Online search\n- Filter by Unread, Starred, Conversation, Today and Smart\n- Quick access to the messages of one sender\n- Quick per account single folder access \n\nLABELS AND FOLDERS\n- Full label access\n- Per single labels sync\n- Quick access to recent labels\n- Favorite labels\n- Full label creation and editing\n- Document view with rich preview\n- Unread, Today, Conversations and Contacts\n\nCOMPOSER\n- HTML rich composer\n- Attachment resizing\n- Document import from Dropbox, Google Drive and much more\n- Signature swipes\n- Composer extension \n- Online drafts\n- Send and Archive\n\nOPERATIONS\n- Undo actions\n- Move mail between accounts\n- Multiple signature\n- Operations view\n- Attachments view\n- Contacts view\n- Mark as unread on open\n\nCONTACTS\n- VIP\n- Google Directory Search\n- Exchange Global Address List\n- Contacts Group messages\n- Auto CC/BCC\n\nVISUAL\n- Profile icons\n- Highlight subject\n- Account icons\n- Account colors\n- Preview message lines\n- Remote images\n\nACTIONS\n- Archive \n- Trash\n- Snooze defer messages\n- Move and Labels\n- Mark as Unread\n- Mark as Starred\n- Mark as Spam\n- To Do, Memo, Done \n- Send to Calendar\n- Send to iOS Extension\n- Create a searchable PDF \n- Print \n- Bounce \n- Redirect\n- Transfer to a different account\n- Universal link Mac/iOS\n- Add to sender to VIP\n- Empty Trash and Spam\n- Mark entire mailbox as read\n- Archive all messages\n\nINTEGRATIONS\n\nAttachments:\n- Google Drive\n- Droplr \n- Box.com \n- OneDrive\n- Dropbox\n\nOpen Links in:\n- Safari\n- Chrome\n- Firefox\n- iCab\n- Mercury\n- Safari in-app\n\nSend to Apps and Service:\n- Calendars Invites\n- Apple Calendar\n- Apple Reminder\n- Omnifocus\n- Todoist\n- Wunderlist\n- Fantastical 2\n- 2DO\n- Trello\n- Clear\n- Evernote\n- Appigo Todo\n- The Hit List\n- Things\n- Task\n- Editorial\n- Draft 4\n- iA Writer\n- Code Hub\n- Things\n- 1Writer\n- Delivery\n- Github \n- Swipes \n- Pocket\n- DevonThink\n\n\n- Business \nAirmail is also available on Apple B2B store with MDM and AppConfig support, please contact us for more info.\n\nURL Scheme:\nairmail://compose?subject=[subject]&from=[from]&to=[to]&cc=[cc]&bcc=[bcc]&plainBody=[plainBody]&htmlBody=[htmlBody]\n\nairmail://compose?subject=Message%20subject&to=joe%40example.com&ann%40example.com&plainBody=Message%20body\n\nAirmail does NOT store your messages on our servers.\nServer processing is very limited and performed only if users enable push notifications.\n\nThanks to all the testers on the Slack group that have been involved in the development!", "minimumOsVersion": "11.0", "primaryGenreName": "Productivity", "bundleId": "com.airmailapp.iphone", "trackName": "Airmail - Your Mail With You", "trackId": 993160329, "sellerName": "Bloop S.R.L", "averageUserRating": 3.5, "userRatingCount": 195 }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/0a/c4/a4/0ac4a47d-3682-3f9d-42e0-cdebb95fb0b7/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/1d/5e/0e/1d5e0e33-c28c-72e8-1ffd-11b1422b5c2e/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/bd/18/44/bd184436-b8cf-1ffb-896b-9ecfdbe389db/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/9e/65/b0/9e65b057-5d1d-00a9-17dc-474189a36bd1/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/23/e2/d2/23e2d2ae-11fe-4edd-50c5-d663a4c37fbf/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/47/86/06/47860628-0381-b71d-4249-1e8e2f8cf7e5/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/59/49/9e/59499ece-afe1-ead2-b73c-9de612ade99c/source/552x414bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/62/5c/e0/625ce0dd-4122-a20a-ff71-a39bbd40a397/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/76/5f/3b/765f3bd6-03c3-cc0f-b73c-4e7efb09172b/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/5a/06/ab/5a06abd1-8425-9742-49ba-e7039f7f43c0/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/83/d0/29/83d0290f-7542-7a3f-a535-7cce7d487094/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/83/d0/29/83d0290f-7542-7a3f-a535-7cce7d487094/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/83/d0/29/83d0290f-7542-7a3f-a535-7cce7d487094/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/wetransfer/id485103881?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Paste by WeTransfer", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "52582400", "sellerUrl": "http://www.fiftythree.com/paste", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/paste-by-wetransfer/id1259981327?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-06T20:18:21Z", "releaseNotes": "Search to quickly find the deck you need", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2017-11-30T02:38:49Z", "currency": "AUD", "wrapperType": "software", "version": "1.7.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 485103881, "artistName": "WeTransfer", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "Paste is where your ideas come together. From strategy decks to design proposals, Paste automatically formats screenshots, videos, and links as beautiful presentations ready to share with a simple link. It's collaboration for today’s most creative teams—fast, flexible, and visual.\n\nPRESENT ANYTHING\nDrag anything into a deck to instantly create slides from screenshots, videos, docs, and links. Add photos, files, or links to embed YouTube videos, Google Docs, or design files. Auto-layout formats everything for beautiful slides with no wasted effort. Frames are smart filters that wrap any media inside a beautiful phone, tablet, or web mockup.\n\nFOCUS ON THE BIG IDEA\nPaste helps you develop your biggest ideas. See everything and make sense of your work in Storyboard View. Create sections and arrange slides to find the flow that brings your story to life. \n\nLESS BUSYWORK, MORE TEAMWORK\nGather input without distractions. Leave a quick reaction or a lengthy comment—vote, flag or mark it “done.” Integrate with Slack to seamlessly pull your deck into your team conversations with share and comment notifications. Set up confidential decks using Slack’s private channels.\n\nALWAYS IN SYNC, INSTANTLY SHAREABLE\nCreate and review in real-time. Paste syncs to the cloud, so you always have your team's latest thinking at your fingertips. Browse, zoom in, and download every image, file, and video in full resolution. When you’re ready, present your deck onscreen, download as a PDF, or post a public view-only link to the world.\n\nPASTE ON DESKTOP\nGet all of the power of the mobile app and more on any computer by visiting pasteapp.com\n\nPRICING\nPaste is free to use for as long as you need it. Upgrade to get access to more decks, customization, and control over sharing. Learn more at fiftythree.com/paste/pricing\n\nIf you need help or want to share feedback, please contact us at support@pasteapp.com", "minimumOsVersion": "11.0", "primaryGenreName": "Productivity", "bundleId": "com.fiftythree.paste", "trackName": "Paste by WeTransfer", "trackId": 1259981327, "sellerName": "FiftyThree, Inc.", "averageUserRating": 4.0, "userRatingCount": 47 }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple69/v4/b6/27/89/b627896b-1ef2-92cd-ae8e-ef7eba3ca0e6/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple4/v4/8f/aa/26/8faa2607-82f1-2355-b668-380a8572bc76/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple2/v4/74/e2/2d/74e22dea-e110-92d6-5d1e-c95fda4b4c04/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple49/v4/94/66/81/94668130-f8c0-981f-af1e-4a258ca7f514/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple49/v4/4b/12/98/4b129841-0b22-5680-4c63-48287149a3f5/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple49/v4/4b/12/98/4b129841-0b22-5680-4c63-48287149a3f5/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple49/v4/4b/12/98/4b129841-0b22-5680-4c63-48287149a3f5/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/copper-technologies-inc/id986907147?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "OneChannel for Slack", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "14772224", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/onechannel-for-slack/id1064748228?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2016-01-12T22:07:02Z", "releaseNotes": "A few important bytes to helps us better understand how folks use OneChannel so we can make it better. We threw in a little bonus, too: select text before using the Action Extension and we'll include the quote in your post.", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6002" ], "releaseDate": "2015-12-18T18:20:12Z", "currency": "AUD", "wrapperType": "software", "version": "1.3", "isVppDeviceBasedLicensingEnabled": true, "artistId": 986907147, "artistName": "Copper Technologies, Inc.", "genres": [ "Productivity", "Utilities" ], "price": 0.00, "description": "OneChannel is the fastest way to post to Slack from anywhere on iOS. Connect to any channel or user and send to Slack in one tap.", "minimumOsVersion": "8.0", "primaryGenreName": "Productivity", "bundleId": "com.withcopper.OneChannel", "trackName": "OneChannel for Slack", "trackId": 1064748228, "sellerName": "Copper Technologies, Inc." }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/3c/f7/e4/3cf7e461-f683-ee69-2b9b-75d0d8bcc7e9/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/70/80/4a/70804a4d-1f10-4b40-784b-0e03c632b82b/source/406x228bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/2f/52/3d/2f523d17-9486-c15e-1374-4ceea9a557b0/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/91/5e/12/915e1209-9f99-8d2a-44a3-79728265c9df/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/13/85/d8/1385d820-f9ad-2c0c-b87b-7ed5046aaee7/source/552x414bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/bd/b6/4f/bdb64f70-4e9b-fcc0-02f0-515f8de55aaa/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/55/5a/49/555a49e8-9ef6-0663-f18f-c450b095a9f5/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/b6/0f/55/b60f555b-fe62-5b6e-0457-2faf7d3ad1d4/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/89/95/93/8995934e-b99c-1db6-ff56-6b7e1c221bd2/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/89/95/93/8995934e-b99c-1db6-ff56-6b7e1c221bd2/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/89/95/93/8995934e-b99c-1db6-ff56-6b7e1c221bd2/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/colloquy-project/id302000481?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Colloquy - IRC Client", "languageCodesISO2A": [ "EN", "DE", "IT", "KO" ], "fileSizeBytes": "10020864", "sellerUrl": "http://colloquy.mobi", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/colloquy-irc-client/id302000478?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-04-01T17:43:44Z", "releaseNotes": "Changes:\n- Improve reliability when registering for push notifications\n- Small performance improvements when connecting to bouncers\n\nFixes:\n- Fix issue where chat previews could disappear", "primaryGenreId": 6005, "formattedPrice": "$2.99", "genreIds": [ "6005", "6002" ], "releaseDate": "2009-01-17T07:44:49Z", "currency": "AUD", "wrapperType": "software", "version": "1.9.2", "isVppDeviceBasedLicensingEnabled": true, "artistId": 302000481, "artistName": "Colloquy Project", "genres": [ "Social Networking", "Utilities" ], "price": 2.99, "description": "Colloquy for iPad, iPhone and iPod touch puts the power of the most popular IRC client for the Mac in the palm of your hand. Built atop the Chat Core framework, Colloquy Mobile is a full featured client optimized for the on-the-go experience with iOS multitasking support.\n\nUnique Features:\n• Support for retina devices, including iPhone 6s, iPhone 6s Plus and iPad Pro.\n• Support for iOS multitasking with local notifications and split-screen support.\n• Push notifications when using a compatible push bouncer.\n• Convenient nickname and emoticon completion popups.\n• Support for all the common IRC commands with completion.\n• Organized Colloquies view that shows all your conversations and rooms at a glance.\n• Highlights messages (and optionally vibrates) when your specific words or nickname is mentioned.\n• Highly customizable interface and behavior settings within the Settings application.\n• Visual display of user information (WHOIS) for any user.\n• Full support for landscape mode in the entire application.\n• Stays connected while iPhone is locked and when SMS alerts appear.\n• Searchable room member list.\n• Support for SASL authentication (required when connecting to Freenode over the cell network.)\n• A console for every connection.\n• Support for ignoring annoying users.\n\nStandard Features:\n• Multiple message styles to choose from.\n• Fully compatible with mIRC colors and formatting.\n• Large selection of graphical emoticons.\n• Allows you to join multiple chat rooms across many different servers.\n• Automatic identification with network services (NickServ).\n• Notification of common server errors as easy to understand alerts.\n• Automatically join rooms and send commands upon connect.\n• Solid support for secure connections over SSL and TLS.\n• Full support for room and connection text encodings.\n• Full IRCv3 compatibility, including the IRCv3.2 standard.\n• Open minded and Open Source, like it should be.\n\nATTENTION:\nWe can't provide support here to people leaving reviews, as much as we want to. We also can't discuss feature requests, since there is no way to reply to you here. If you have a problem or suggestion, please visit us in #colloquy-mobile on irc.freenode.net, click the support link below, or email us at support@colloquy.mobi.", "minimumOsVersion": "10.0", "primaryGenreName": "Social Networking", "bundleId": "info.colloquy.mobile", "trackName": "Colloquy - IRC Client", "trackId": 302000478, "sellerName": "Jane Lee", "averageUserRating": 3.5, "userRatingCount": 134 }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/ea/81/e0/ea81e017-d647-8f42-702c-0a1ef609e3f3/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/54/fe/18/54fe1851-ccc0-43a3-faae-4a6a88f67ed8/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/ba/3f/46/ba3f46db-74f7-a220-33e0-68d973c5acfd/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/ca/ae/08/caae087f-1294-385d-2f78-2e7e1847de5f/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/48/4d/d9/484dd942-c72e-8cdd-54ef-dee85b614719/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/9b/bd/e8/9bbde842-506f-0999-0480-14d55b3077c8/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/67/84/a4/6784a4ba-cf72-5718-f3a7-5c4a091ec7e5/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/70/64/3d/70643dc9-a70b-8bf2-72c0-1305385c70cc/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/c5/82/cc/c582cc38-1359-6810-1921-f15621c2a90d/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/c5/82/cc/c582cc38-1359-6810-1921-f15621c2a90d/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/c5/82/cc/c582cc38-1359-6810-1921-f15621c2a90d/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/alibaba-com-hong-kong-limited/id436672032?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "AliSuppliers - App for Alibaba", "languageCodesISO2A": [ "EN", "ZH", "ZH" ], "fileSizeBytes": "224596992", "sellerUrl": "http://mobile.alibaba.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/alisuppliers-app-for-alibaba/id708064914?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2019-01-07T19:25:55Z", "releaseNotes": "1. Fixed some known issues.\n2. Other performance optimizations and experience improvements.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6024" ], "releaseDate": "2013-10-27T19:25:22Z", "currency": "AUD", "wrapperType": "software", "version": "9.6.3", "isVppDeviceBasedLicensingEnabled": true, "artistId": 436672032, "artistName": "Alibaba.com Hong Kong Limited", "genres": [ "Business", "Shopping" ], "price": 0.00, "description": "----Manage Your Business, Any Time, Anywhere----\n√ An official Alibaba product\n√ Provides services for businesses on Alibaba.com, and is an essential app for any e-commerce business\n√ Dedicated to providing solutions for businesses and increasing operational efficiency\n----Basic Description----\nThe official Alibaba Supplier app serves as a dedicated e-commerce store operation, business management, and information and communication mobile tool for businesses all over the world. With Alibaba Supplier, you can easily manage store merchandise and orders, check shop data and messages, process quotes, and take advantage of business opportunities anytime, anywhere, allowing you to best manage your valuable time and better interact with potential buyers.\n----Main Features----\n[Workbench] Provides core operational data and a personalized operation page that includes plugins for things like products, order transactions, member management, inquiries and requests for quotation (RFQ). Shop owners can add or delete plugins freely to build their own personalized workbench.\n[Messages] Notification messages for the receipt of goods, orders, business opportunities and campaigns.\n[Chat] Inquiries from buyers can be answered in the blink of an eye. Alibaba Supplier supports a computer and mobile device being logged into the same account at the same time so that no transactions will be missed. There are also several ways to chat such as voice and video chat, and you can also check the read/unread status of messages and quickly understand what the buyer wants.\n[Services] Provides e-commerce services to businesses and increases their operational efficiency.\n[Headlines] Abundant and up-to-date e-commerce news with diverse content such as official laws, marketing strategies, high-quality products and live videos.\n\n----Contact Us----\nIf you encounter any problems while using Workbench, please contact us at:\n- Online assistance: Go to Qianniu > Me > Settings > Questions and Feedback\n- PC official website: www.alibaba.com\n- Mobile phone official website: m.alibaba.com", "minimumOsVersion": "9.0", "primaryGenreName": "Business", "bundleId": "com.alibaba.icbu.app.seller", "trackName": "AliSuppliers - App for Alibaba", "trackId": 708064914, "sellerName": "Alibaba.com Hong Kong Limited" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple115/v4/f3/7d/28/f37d2835-bf88-45af-96b4-e7b915788bc7/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple125/v4/79/9e/ef/799eefbf-25a8-26d0-f79f-87064c2198dd/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple115/v4/3b/db/42/3bdb4291-b2c7-f18a-1ad7-100db460af00/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple115/v4/6e/5f/04/6e5f0428-64ed-7469-a104-93c91e18cfc3/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple125/v4/4d/e6/3b/4de63b1f-52e1-f3e0-1859-cc2bec7dee0e/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple125/v4/89/98/5a/89985a94-79e4-21eb-9a83-9fee6f1cceac/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple115/v4/a2/5f/8a/a25f8aa9-edbb-0abb-f875-92786be7d143/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple115/v4/de/50/57/de505787-6d36-eb2d-734d-7ca1ef3c0900/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple125/v4/21/0a/f6/210af6c9-64e8-43c1-acf7-08dffb459e1b/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple125/v4/24/3f/3a/243f3af5-763a-7bc0-59dc-dbaad439fff1/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/cd/a1/6e/cda16e41-a841-dc4c-7018-d2e66f47be4d/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/cd/a1/6e/cda16e41-a841-dc4c-7018-d2e66f47be4d/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/cd/a1/6e/cda16e41-a841-dc4c-7018-d2e66f47be4d/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/moxtra-inc/id551221476?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Moxtra: Business Collaboration", "languageCodesISO2A": [ "DA", "NL", "EN", "FI", "FR", "DE", "ID", "IT", "JA", "KO", "PT", "RU", "ZH", "ES", "SV", "TH", "ZH", "TR", "VI" ], "fileSizeBytes": "108129280", "sellerUrl": "http://www.moxtra.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/moxtra-business-collaboration/id590571587?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-11-16T13:04:36Z", "releaseNotes": "- Fixed bugs", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2013-01-27T08:00:00Z", "currency": "AUD", "wrapperType": "software", "version": "5.2.16", "isVppDeviceBasedLicensingEnabled": true, "artistId": 551221476, "artistName": "Moxtra, Inc.", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Moxtra: Accelerating business in the mobile world.\n\nMoxtra is a collaboration solution built to accelerate business. Present, secure feedback, get approvals on content to close business while on the go. Collaborate on documents and content across teams, with customers, partners, and colleagues. Recreate the power of face-to-face meetings with secure messaging, robust document collaboration, video conferencing, electronic signature, and more – in context. Moxtra is an secure, enterprise class service available as a white-label, private cloud, or on-premise solution.", "minimumOsVersion": "8.0", "primaryGenreName": "Business", "bundleId": "com.moxtra.moxtra", "trackName": "Moxtra: Business Collaboration", "trackId": 590571587, "sellerName": "Moxtra, Inc.", "averageUserRating": 4.0, "userRatingCount": 20 }, { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple7/v4/f9/ee/f1/f9eef16a-490a-aecb-8bee-31791513a84e/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple7/v4/34/8e/db/348edbcf-bd89-346b-cc97-ffc2009746f6/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple1/v4/01/50/b1/0150b1bd-69c1-34e8-08b4-fbc42e1ade47/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple1/v4/bf/f1/e1/bff1e1d0-c8ec-ecd9-6743-98dffa534294/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple1/v4/5c/13/30/5c133045-2c24-52f0-1b9a-6e935cafaae6/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple5/v4/c8/9a/80/c89a80de-e5fc-b37a-cb42-a77eb7829dc5/source/360x480bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple7/v4/42/b7/66/42b76637-de47-fbd5-b3c4-9ee14dd70001/source/360x480bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple1/v4/3c/e9/8f/3ce98f1d-713d-7e2e-281e-f68e814e3384/source/360x480bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple1/v4/63/e0/1a/63e01a54-0e2c-cf74-0afc-39b465e6279e/source/360x480bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple7/v4/36/73/06/367306a5-4c3f-2089-4a59-8e3e6c227a2c/source/360x480bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/3e/e7/99/3ee7999a-ad78-5790-74b2-9b4fb7c39b92/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/3e/e7/99/3ee7999a-ad78-5790-74b2-9b4fb7c39b92/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/3e/e7/99/3ee7999a-ad78-5790-74b2-9b4fb7c39b92/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/linebreak/id417602907?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 4.5, "trackCensoredName": "Annotate - Text, Emoji, Stickers and Shapes on Photos and Screenshots", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "65638400", "sellerUrl": "https://annotate.driftt.com/annotate", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 34, "trackViewUrl": "https://itunes.apple.com/au/app/annotate-text-emoji-stickers-shapes-on-photos-screenshots/id994933038?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2015-07-29T20:58:01Z", "releaseNotes": "Your feedback is life. It’s the air that we breathe, the wind at our backs, the sun on our faces after a long, cold, dark night.\n\nYou are our sunshine. You make us happy when skies are grey.\n\nThis latest release makes it easier than ever for you to share your warm thoughts and feedback whenever the mood strikes you.\n\nTap the little question mark bubble to send us feedback via Twiitter, email, or text message. Whatever works for you.\n\nLove your latest work of Annotate art? Tweet it to us @getannotate. We promise to love you back. <3\n\nPlease don’t take our sunshine away.", "primaryGenreId": 6008, "formattedPrice": "Free", "genreIds": [ "6008", "6007" ], "releaseDate": "2015-05-30T02:09:51Z", "currency": "AUD", "wrapperType": "software", "version": "1.5", "isVppDeviceBasedLicensingEnabled": true, "artistId": 417602907, "artistName": "Linebreak", "genres": [ "Photo & Video", "Productivity" ], "price": 0.00, "description": "\"This app is a must have” — David Wiltson\n\nAnnotate is the simplest way to capture, annotate and save or share photos and screenshots.\n\n\nFEATURES\n\nSnap a photo or select an image from your camera roll, then dress it up with stickers and annotate it with arrows, text, and the pen tool. Or use the pixelate tool and built-in emojis for maximum impact.\n\nAdd a caption and share it with friends on your favorite apps, including Apple Messages, Mail, Twitter, Slack, Snapchat, WhatsApp, Line, Instagram and Facebook.\n\nQuickly and easily redact parts of an image.\n\nHave fun with the 100's of emoticons built-in and ready for you to add to your photos and screenshots. Full support for landscape mode on iPad and iPhone, so rotate away!\n\n\nREVIEWS\n\n\"Amazing ideas came up as soon as i started using this app.\" — Eredis2\n\n“It’s a great little alternative to a snapchat, and let’s me describe photos to anyone.\" — Majickdave\n\n\"Intuitive and very helpful for collaboration.\" — PCampbell\n\n\"This app is a must have, you can do all annotations that you need with great quality and intuitive controls. Great! ” — David Wiltson\n\n\"Best way to mark up your screenshots and photos, great for client work or even to remind yourself later when editing photos\" - Smbnyc\n\n\nSUPPORT\n\nIf you have any questions or feedback we’d love to hear from you! Driftt offers free support, you can reach us by email at annotate@driftt.com or on Twitter @DrifttHQ. \n\nYou can also browse our FAQs and User Guides on http://use.driftt.com/.\n\nThank you!\n\nWe have lots of great plans for future versions, so please leave us feedback and rate us in the App Store!", "minimumOsVersion": "8.0", "primaryGenreName": "Photo & Video", "bundleId": "com.driftt.ios.Annotate", "trackName": "Annotate - Text, Emoji, Stickers and Shapes on Photos and Screenshots", "trackId": 994933038, "sellerName": "Linebreak SL", "averageUserRating": 4.5, "userRatingCount": 36 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/4e/be/73/4ebe7322-25da-bb3f-516b-8a65896a4a81/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/d7/6f/b4/d76fb430-6b95-84e7-5a02-1589f0e5f69d/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/f0/5f/54/f05f54f1-541e-2490-d6f8-62787832eff3/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/5b/ae/c3/5baec325-229c-6b4b-5e59-72636a54b4a0/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/9c/b7/98/9cb79895-3f62-7e16-046e-b5db2c919200/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/18/38/d9/1838d903-dfd1-e91d-9e84-3da6ade92048/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/18/38/d9/1838d903-dfd1-e91d-9e84-3da6ade92048/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple124/v4/18/38/d9/1838d903-dfd1-e91d-9e84-3da6ade92048/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/mailtime/id914281818?uo=4", "advisories": [ "Unrestricted Web Access" ], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "MailTime Email Messenger", "languageCodesISO2A": [ "AR", "CA", "HR", "CS", "DA", "NL", "EN", "FI", "FR", "DE", "EL", "HE", "HI", "HU", "ID", "IT", "JA", "KO", "MS", "NB", "PL", "PT", "RO", "RU", "ZH", "SK", "ES", "SV", "TH", "ZH", "TR", "UK", "VI" ], "fileSizeBytes": "101133312", "sellerUrl": "http://mailtime.com", "contentAdvisoryRating": "17+", "trackViewUrl": "https://itunes.apple.com/au/app/mailtime-email-messenger/id914281815?mt=8&uo=4", "trackContentRating": "17+", "currentVersionReleaseDate": "2018-12-22T00:10:50Z", "releaseNotes": "Happy Holidays!\n\nBug fixes.", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6005" ], "releaseDate": "2014-09-08T07:00:00Z", "currency": "AUD", "wrapperType": "software", "version": "3.0.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 914281818, "artistName": "MailTime", "genres": [ "Productivity", "Social Networking" ], "price": 0.00, "description": "\"Best of 2015\" by Apple\nMailTime is an open mobile messenger built with email technology. It’s email as quick and easy as texting, and messaging without forcing all your contacts to download the same app. \n\nWe reformat your cluttered email threads into clean bubble conversations and separate the important human messages in your inbox from the discounts and newsletters.\n\nMailTime supports multiple email accounts, as well as Gmail, iCloud, Yahoo, Outlook, AOL, Office 365, Mail.ru, Hotmail, QQ,163, 126, Tencent Enterprise, Google Apps Mail services. You can also attach files from Dropbox, iCloud, Google Drive, Box, and One Drive with MailTime. \n\nUPDATE: Now users can search within MailTime from any screen using the Spotlight Search. Jump into a new email with 3D Touch, Peek into emails before opening, Swipe a quick reply, or Long Press to expand email bubbles, addresses, contacts and web links with ease. \n\n------------\nFeatures\n\nEmail Messaging:\nOur content parsing engine cuts out annoying metadata to display emails in clean bubbles. View your emails as conversations, not threads!\n\nCommunicate, Don't Organize:\nOur intelligent inbox sorts out the Important humans from the newsletters, discounts, and other machine-generated mail in All Mail. Talk to people you care about, not machines!\n\nGroup Chats:\nManaging your conversations in MailTime is just like a group chat. To add, remove, or switch participants to 'cc' or 'bcc', just swipe left and change your participants' status. \n\nOne Click To-dos:\nUse the @ symbol to quickly assign tasks without leaving the inbox. For example, \"@Charlie, Please download MailTime\", will put the rest of that sentence into Charlie's Mentions list, next to the group status list.\n\nToo Long; Didn't Read:\nJust like Twitter prevents you from writing more than 140 characters, MailTime alerts you if your message is too long. You can still send them, but no guarantees that they'll be read!\n\n------------\nFAQ\n\n“What does it look like to someone who doesn't have the app?”\nTo non-MailTime users, messages appear as normal emails. If you'd like to view the original email within MailTime, you can tap on the corresponding bubble.\n\n“What about long emails and attachments?”\nWe've noticed that most people save their essays for desktop and leave quick responses for mobile, but we've got you covered. MailTime shortens all messages to fit the screen, and to see the rest, simply tap that bubble. Attachments are visible on the bottom of the bubble and similarly accessible.\n\n“Can I swipe away all my emails in MailTime?”\nWe don’t treat your inbox as a task list. That’s what Mentions are for - tag a recipient with the @ symbol to assign the rest of the sentence to that person’s task list. However, you can Mark as Read, Archive, and Delete all with easy swipes!\n------------\nAs featured in Forbes, Business Insider, CNBC, LifeHacker, Fox, and TC Disrupt Startup Battlefield 2014.\n\nWe love emails! Talk to us anytime by clicking the “Write to MailTime Team” button or send an email to support@mailtime.com\n\nFollow us on Twitter at @mailtimeapp, \nlike us on Facebook at /mailtimeapp or \nvisit our website mailtime.com\n\nHave A Good MailTime!", "minimumOsVersion": "9.0", "primaryGenreName": "Productivity", "bundleId": "com.mailtime.MailTime", "trackName": "MailTime Email Messenger", "trackId": 914281815, "sellerName": "Mobile Internet Limited", "averageUserRating": 4.0, "userRatingCount": 9 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/09/87/0a/09870a86-2748-884e-f7d0-3c812379399e/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/40/ce/b1/40ceb117-c5fc-1249-cb22-83e19d5415d1/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/1e/ee/a9/1eeea9de-a65d-6dd3-4e04-9379b1d9f38b/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/dc/57/95/dc57957d-20ec-4156-04f8-db56c681bf6b/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/55/9b/ec/559bec7b-94ae-7a1e-4b64-90769ddaf80c/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/0b/a4/f3/0ba4f3b6-de86-ba0e-767a-689d3fb26d65/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/d7/1a/64/d71a6454-429b-497d-9af4-1fd9a6d28b2f/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple62/v4/58/58/fa/5858fab3-d8f7-2c62-f736-7caca3cb8aa7/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/46/e0/52/46e052b5-c3a1-aeef-e3a0-f3e6216d65b3/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/16/f2/70/16f270b1-4668-cec9-2742-5df6abdbdb24/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/6e/e0/4e/6ee04ea5-e101-ff57-0ea1-5c1e83a9c058/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/6e/e0/4e/6ee04ea5-e101-ff57-0ea1-5c1e83a9c058/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/6e/e0/4e/6ee04ea5-e101-ff57-0ea1-5c1e83a9c058/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/slikr/id1083307348?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Spasifik Cuts Barber", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "45223936", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/spasifik-cuts-barber/id1355242933?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-07-29T23:21:37Z", "releaseNotes": "Improved alerts\nGeneral updates and improvements", "primaryGenreId": 6012, "formattedPrice": "Free", "genreIds": [ "6012" ], "releaseDate": "2018-03-09T14:58:49Z", "currency": "AUD", "wrapperType": "software", "version": "1.2", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1083307348, "artistName": "SLIKR", "genres": [ "Lifestyle" ], "price": 0.00, "description": "Spasifik Cuts Barbershop is located in Slacks Creek, Brisbane. Brisbane's original urban Barbershop for over 13 years. Join our queue online and reduce your wait time.\n\nSee the wait time for each barber\nCheck-in online\nSecure your place\nTrack progress online\nCancel online if needed\n\nWe respect your time so check-in now and beat the wait in the shop.", "minimumOsVersion": "10.0", "primaryGenreName": "Lifestyle", "bundleId": "com.slikr.spasifik", "trackName": "Spasifik Cuts Barber", "trackId": 1355242933, "sellerName": "SLIKR PTY LTD" }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/f3/d2/ee/f3d2ee2a-a819-0251-c20f-e1900cce874d/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/ba/e4/2c/bae42cd7-9a0e-b896-2703-f4d442b1a5e5/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/f7/ea/ee/f7eaee7c-6bef-1e03-e514-a4637dab25e9/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/ec/c8/f3/ecc8f322-96db-9bfb-9378-f90bb583a221/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/95/68/ff/9568ff15-e5b4-9117-2764-678c246d686e/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/b2/63/4f/b2634fed-a626-0fb5-5234-79cee7089733/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/68/81/56/6881568a-ebe1-e073-4ff0-7fe997fb75b3/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/31/2c/1b/312c1b48-b7d6-1228-2796-f627bc1b1c5d/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/c0/25/6a/c0256a0f-d705-0093-68f3-0910da8f004d/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/1a/7e/a4/1a7ea443-1a6a-bfda-62a0-975481291dd8/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/af/dc/46/afdc4638-0103-f987-e58d-2e70ba51a8c1/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/b3/da/b7/b3dab70b-b119-ccd3-b2a7-72abed7abde9/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/50/ba/58/50ba58eb-227b-3dd5-0a8d-29df486e2fdf/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/50/ba/58/50ba58eb-227b-3dd5-0a8d-29df486e2fdf/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/50/ba/58/50ba58eb-227b-3dd5-0a8d-29df486e2fdf/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/jus/id1403744826?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "JusTalk Kids - Safe Video Chat", "languageCodesISO2A": [ "AR", "EN", "FR", "DE", "HI", "ID", "JA", "KO", "PT", "RU", "ZH", "ES", "ZH", "TR", "VI" ], "fileSizeBytes": "69033984", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/justalk-kids-safe-video-chat/id1403744827?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2019-01-01T02:40:04Z", "releaseNotes": "Latest Updates:\n- Improved parental control: parents or guardians now can control kids’ access to all social features by setting passcode on the app.\n- Bug fixes and product improvements.\n\nThank you for using JusTalk Kids! If you have any questions, concerns, or suggestions, please feel free to contact with us via email: kids@justalk.com", "primaryGenreId": 6005, "formattedPrice": "Free", "genreIds": [ "6005" ], "releaseDate": "2018-08-01T10:33:28Z", "currency": "AUD", "wrapperType": "software", "version": "0.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1403744826, "artistName": "Jus", "genres": [ "Social Networking" ], "price": 0.00, "description": "JusTalk Kids is a FREE video calling and messaging app designed for kids to connect with family and close friends from their tablets or smartphones.\n\n• Safe Environment for Kids\n- Parents or guardians can control kids’ access to all social features in the app.\n- NO harassment from strangers. Kids will not receive friend requests, messages, or calls from strangers unless they add the parents-approved person first.\n- No phone number is needed. Parents and guardians help their kids to create a JusTalk Kids account by setting a JusTalk ID through the bottom of the signup page.\n- Your child’s JusTalk ID won’t be recommended to any other users on JusTalk or JusTalk Kids!\n- There are no ads or in-app purchases and the app is free to download.\n\n• More Fun for Kids\n- Doodles, stickers, photo sharing: help kids creatively express themselves and learn new things during calls!\n- Voice and video recording: save memorable childhood forever!\n- Play fun and interactive game while calling with parents or close friends.\n- Start a live video chat to help kids share their favorite moments with loved ones in voice calls.\n- Send and receive photos, instant video messages, emoji and more on chats.\n\n• Works with JusTalk\n- Your child uses JusTalk Kids to video call and message over Wi-Fi or on-the-go (EDGE/2G/3G/4G)*.\n- Parents and other family members can chat with their kids through their existing JusTalk app.\n\n• Cross-platform\nJusTalk Kids is supported to use on different operating systems and all kinds of device sizes no matter it’s a smartphone or tablet.\n\n• Private and Secure\nAll your children's personal information (including calling and messaging data) is end-to-end encrypted. It's split into multiple random path which ensures it can’t be monitored or saved by servers. \n\n*Data charges may apply. Check with your carrier for details.\n\n-----------------------------------------------------\nWe're always excited to hear from you! Please feel free to contact us via:\nEmail: kids@justalk.com\nFacebook: facebook.com/justalkkids\nTwitter: @JusTalkKids\nInstagram: @justalk_kids\n-----------------------------------------------------\n\nThe following is a more detailed description:\n\n• Best Kid-Friendly App\nJusTalk Kids is the best video chat and messenger kids and their families have been looking for! \n\n• Free Phone Calls and Kids Chat\nMake voice or lively video chats with your children wherever you are. You will never miss any important moment with your family. When you’re unable to accompany you kids, experience life events (loss of first tooth, the first school day, a birthday house party and so on) with your kids together on JusTalk Kids with no distance.\n\n• Interactive Video Features\nDuring video chat you can tell short stories to your kids by sharing their favorite pictures of story books. Parents and kids doodle together and add emoticons or stickers to spark conversation and laughter. Play games and compete with each other.\n\n• Designed for Family\nJusTalk Kids is designed as an easy-to-use, safe and educational tool for children where family and friends meet together. Make free calls and share free messages with family and kids.\n\n• High Quality Wi-Fi Calling App\nEnjoy crystal-clear call quality, video chat and free texting. Using JusTalk Kids to call free or send a text now to your kids.\n\n• Capture the Moment\nQuickly snap a photo or video and send to chats. It's not only for kids and their family, but also is a hangout for kids and their close friends.\n\n• Simple Interface\nA clean, intuitive design makes communicating faster and more fun.\n\n• Low Data Usage\nSave 40-90% of the voip or vowifi network traffic during realtime video chat with 720P HD quality. Like a fun video walkie talkie.", "minimumOsVersion": "8.0", "primaryGenreName": "Social Networking", "bundleId": "com.justalk.kids.ios", "trackName": "JusTalk Kids - Safe Video Chat", "trackId": 1403744827, "sellerName": "Ningbo Jus Internet Technology Co., Ltd.", "averageUserRating": 4.0, "userRatingCount": 9 }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/dc/60/54/dc6054be-4707-d3af-d2a4-5c7ab85b4000/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/b3/d4/28/b3d42823-a787-7b8a-218f-cf1af74b4a68/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/c1/64/96/c1649638-d404-58db-5d23-8209c434b855/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/9b/0f/85/9b0f859d-e757-43f3-fc89-9f1f1c6206e3/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/84/c8/9c/84c89c11-ba6a-1547-d9f2-2d84d88a2f62/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/e5/93/7c/e5937c2e-7ebf-62e4-65f2-bd35eede318e/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/f6/e1/d5/f6e1d52b-e0a4-6222-cddf-282ddbebb736/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/d0/bb/7c/d0bb7c6b-fb2c-dc3b-78b3-cafd3168a201/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/af/ee/c9/afeec9a1-ecee-265a-d39d-267862496006/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/2d/f1/cb/2df1cb0c-ffdb-22ea-7a4f-30ea77fa854b/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/dd/0f/1e/dd0f1ebf-ae8b-3a33-a664-03c489a2589b/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/32/05/4b/32054b85-04c9-01d5-4d5f-d084eea8b1a0/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/8c/f8/76/8cf876ce-6b5d-bc8f-fb49-0587b2ef215f/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/a7/ab/9a/a7ab9a7c-5f26-c6f8-e701-f28626e2f48c/source/576x768bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/47/3d/f9/473df90c-324c-2051-f958-e5e0d92af659/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/6f/7a/8e/6f7a8e27-5027-6ad1-4d2b-ee968eb51e69/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/57/2b/4a/572b4a12-ef3c-8527-2c7e-a5a7d9f72fb4/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/44/05/5c/44055c39-a87f-9de6-f077-9254eb147279/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/44/05/5c/44055c39-a87f-9de6-f077-9254eb147279/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/44/05/5c/44055c39-a87f-9de6-f077-9254eb147279/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/riva-fzc/id503815096?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Flock: Team Communication App", "languageCodesISO2A": [ "CA", "EN", "FR", "DE", "IT", "PT", "ES" ], "fileSizeBytes": "82672640", "sellerUrl": "http://flock.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/flock-team-communication-app/id879007584?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-19T06:13:06Z", "releaseNotes": "Presence information for team members is now more accurate than ever so you can see if a colleague is online, away or even unreachable.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2014-05-29T00:08:01Z", "currency": "AUD", "wrapperType": "software", "version": "2.34.3", "isVppDeviceBasedLicensingEnabled": true, "artistId": 503815096, "artistName": "RIVA FZC", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Flock is a messaging app for teams. Packed with tons of productivity features, Flock drives efficiency and boosts speed of execution.\n\nIt lets you connect with your team, get on video calls, manage projects with to-dos, polls and reminders and integrate your most favourite apps. All of this and more over a beautiful interface!\n\nFlock is already being loved by teams in over 25,000 organisations across the world.\n\nHere’s why you would too:\n\n* Public channels to discover and connect with like-minded individuals\n* Invite-only private channels for more focussed discussions\n* Real time direct and channel messaging, synced across devices\n* Video and audio calling with hassle free screen sharing capabilities\n* File sharing on personal chats and channels\n* Creating polls and to-dos from your desktop and mobile app\nIntegrations with your favourite tools like Trello, Twitter, Hubot and GitHub\n\nFlock is free to use for as many users and for as long as you want. You can upgrade to our paid plans for more features and increased user control.\n\nYou’re in good company.\n- Tim Hortons\n- Avendus\n- Gini and Jony\n- Ricoh\n- Victorinox\n\nAnd here’s what they have to say about Flock:\n\n“I can’t say enough kind things about Flock. Moved the team to it from Slack and couldn’t be happier.”\n- Luke Rodriguez, Modern Horrors\n\n“Flock is convenient and real time and is making communication seamless and easy. My entire team today is on Flock.”\n- Prashant Tandon, CEO and Co-Founder, 1MG\n\n“Flock has become the way our Sales Team communicates. It's fast, reliable, fun and easy to use.”\n- Bryan Morales, CIB Corporation\n\nFollow us on Facebook and Twitter @flock", "minimumOsVersion": "9.0", "primaryGenreName": "Business", "bundleId": "to.talk.goto", "trackName": "Flock: Team Communication App", "trackId": 879007584, "sellerName": "RIVA FZC" }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/3b/b6/f0/3bb6f08a-51a9-52cc-6fb9-0442d8c51d50/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/c0/1f/34/c01f34db-b784-313d-de99-0e76f7352bd2/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/57/d2/3a/57d23a2a-08d8-0c9c-0d53-0be7ece01f1d/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/a8/b4/7d/a8b47d00-6a7f-9ebf-23cf-0d6d2100daa7/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/0c/f2/ad/0cf2ad8f-5c24-3766-5e37-e7485efde6a8/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/e4/00/3b/e4003b0f-d245-5865-c851-8ed10453e0ae/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/e4/3a/c1/e43ac1fe-2dd2-ddc0-6184-ae4b0f083ce7/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/e4/3a/c1/e43ac1fe-2dd2-ddc0-6184-ae4b0f083ce7/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/e4/3a/c1/e43ac1fe-2dd2-ddc0-6184-ae4b0f083ce7/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/hopflow/id491044400?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Spike Email (Formerly Hop)", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "55273472", "sellerUrl": "https://www.spikenow.com/", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/spike-email-formerly-hop/id707452888?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-17T06:09:52Z", "releaseNotes": "* All photo attachments are now in high resolution !\n* iPad multitasking is now supported \n* Improved camera both for stills and video\n* Camera roll previews are now high quality \n* Added support for the new iPad Pro, iPhone XR & iPhone XS Max\n* resolved some annoying bugs and hangs\n\nPS. Don’t panic - it’s just a name change!\nHop is now Spike. And, the app you love is now on ALL devices: Mobile & Desktop", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2013-10-12T07:00:00Z", "currency": "AUD", "wrapperType": "software", "version": "2.6.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 491044400, "artistName": "Hopflow", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "Spike upgrades the way you work, saving you and your team time, sanity, and a lot of headache. Cutting the distractions of old-fashioned inboxes, Spike gives you the cleanest, fastest productivity tool, all from within your inbox. \nSpike is available on ANY device- access your clients, teams, messages, and all your files, email and cloud accounts, in a single place.\n\nSpike is the world’s first conversational email app. \nDesigned for everyone. Made for teams. Tools you actually need.\n\n******************************\n“Client communications have become a pleasure thanks to Spike. It’s faster, more efficient, and more fun to use.” - Mosey L. \n“Spike helps me respond faster than any other app.” - Bertrand M.\n“Once horrifying email threads are now short and focused conversations. We’re addicted to it now!” - Max S.\n“It's effortless and saves a lot of time. It’s genius.” - Jozsef J. \n \n******************************\n\n• Conversational Email • \nWe’ve simplified your inbox to help you communicate in real-time with your clients, teams, and friends.\n \n• Built-in Team Collaboration • \nGroups within your inbox - the simplest team collaboration app! No logins, no links, just everyone on the same page.\n \n• The more you know • \nStay on top of urgent messages with read receipts, real-time awareness, and snoozing.\n \n \n• Clear the Clutter • \nAutomatically organizes your inbox and gets rid of endless email threads, for maximum focus.\n \n• The Fastest, Search. Ever. •\nSearch all messages, files and contacts instantly. Spike’s Super Search is the fastest way to find and attach anything - guaranteed. (Go ahead, test us. And if you’re still not sure, we’ll challenge you to a search-off).\n \n• See Through Walls (or files) • \nVisually preview all attachments without having to open any emails or download any files. one by one.\n \n• Bulk actions = saving time • \nUnsubscribe, mass archive, and organize with unprecedented speed.\n \n• Enough with the inbox shuffle •\nAll of your work and personal email accounts in one simple, unified inbox.\nSo you can keep it together. Literally. \nconnect any Gmail, Office365 (e.g Outlook), Exchange, iCloud, Yahoo and any IMAP account. \n \n• One life, one Calendar •\nAll your calendars (Google Calendar, Facebook, Apple Calendar) right in your email, so you can give the switching back and forth a rest. \n \n• An arsenal of Expression •\nWhy use words when you can use gifs, voicenotes, emojis, share location, make voice and video calls, send a drawing, photo or video? And ALL from within your inbox...\n \nChat with us at chat@spikenow.com and tell us what you think, or send us a GIF - we love those too!\nWe’re waiting to hear from you :)", "minimumOsVersion": "10.0", "primaryGenreName": "Productivity", "bundleId": "com.pingapp.app", "trackName": "Spike Email (Formerly Hop)", "trackId": 707452888, "sellerName": "erez pilosof", "averageUserRating": 4.0, "userRatingCount": 36 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/c3/93/55/c393551f-9b45-5c67-2178-4db6dec0aaa7/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/94/ce/88/94ce88a4-7f30-4b74-02f8-fcc6f5031f98/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/8a/73/e7/8a73e768-b112-e3cb-f00b-2ea1c2edda29/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/f9/4e/53/f94e53c6-b9ce-43ee-3ae0-f29c55b3a6e4/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/19/67/bc/1967bc9d-4cf2-32f8-d13d-5bd895a03003/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/0d/3e/76/0d3e766c-d481-901b-3ad8-b50fa1b42096/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/0d/3e/76/0d3e766c-d481-901b-3ad8-b50fa1b42096/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/0d/3e/76/0d3e766c-d481-901b-3ad8-b50fa1b42096/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/beijing-kuairu-technology-co-ltd/id1384567075?uo=4", "advisories": [ "Infrequent/Mild Sexual Content and Nudity" ], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "子弹短信 - 聊天,可以再快一点", "languageCodesISO2A": [ "EN", "ZH" ], "fileSizeBytes": "125533184", "sellerUrl": "http://www.zidanduanxin.com", "contentAdvisoryRating": "12+", "trackViewUrl": "https://itunes.apple.com/au/app/%E5%AD%90%E5%BC%B9%E7%9F%AD%E4%BF%A1-%E8%81%8A%E5%A4%A9-%E5%8F%AF%E4%BB%A5%E5%86%8D%E5%BF%AB%E4%B8%80%E7%82%B9/id1384567076?mt=8&uo=4", "trackContentRating": "12+", "currentVersionReleaseDate": "2018-11-28T16:54:52Z", "releaseNotes": "本次更新:\n- 支持点击淘宝、微博、抖音分享链接跳转至相应的应用\n- 提升了支付宝付款码的识别率\n- 支持使用支付宝账户授权登录\n- 修复了其他 bug 若干,提升了应用稳定性和流畅度", "primaryGenreId": 6005, "formattedPrice": "Free", "genreIds": [ "6005", "6009" ], "releaseDate": "2018-08-20T07:35:39Z", "currency": "AUD", "wrapperType": "software", "version": "0.9.7", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1384567075, "artistName": "Beijing Kuairu Technology Co., Ltd.", "genres": [ "Social Networking", "News" ], "price": 0.00, "description": "【子弹短信,快如子弹 】\n 子弹短信是一款高效的聊天软件,语音与文字的完美结合全面提升你的沟通效率,让你的信息随心所达。\n\n 【不再繁琐,我想要的极速体验 】\n * 快捷回复功能:用户无需进入聊天页面,在 App 的消息列表页面就可以快捷回复消息;列表页面支持直接展开多条未读,可以语音或文字快速回复。\n \n 【不再鸡肋,我想要的“语音转文字”】\n 更聪明的“语音转文字”:用户可以自己选择发送信息的类型,可选格式有:语音+文本、纯语音、纯文本,选择语音+文字的话,发送语音的同时会自动转为文字并附带,同时语音识别率高达 97%,让用户在不同场景下都有高效的选择;\n \n 【 告别忙乱,「稍后处理」让你不再忘事儿】\n 既然生活和工作分不开家,那不如让效率翻倍,在任何聊天界面,用户都可以设置文字消息为稍后处理项,让聊天不再忙乱。\n \n 【 人性化的交互带来效率的更多提升】\n * 引用回复功能:任何端口都支持引用回复功能,让聊天过程中不再意义不明,拒绝低效率的沟通。\n * 与非子弹短信用户的好友也可以方便地沟通:用户给手机通讯录中的好友发送子弹短信,将自动调取手机短信权限,即使对方没有下载子弹短信,也可以很方便地回复进行聊天。\n * 历史头像和“这是谁来着?”:子弹短信的每个用户主页中都将对其好友展示曾经用过的历史头像,好友可以选择将之前任意一张历史头像设置为该用户的当前头像,免除换了头像就不“认识人”的尴尬局面;在子弹短信内点击“这是谁来着”,用户可以看到与该好友前几次的对话记录,帮助用户回想起来这是谁。", "minimumOsVersion": "9.3", "primaryGenreName": "Social Networking", "bundleId": "com.bullet.message", "trackName": "子弹短信 - 聊天,可以再快一点", "trackId": 1384567076, "sellerName": "Beijing Kuairu Technology Co., Ltd.", "averageUserRating": 2.0, "userRatingCount": 35 }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/d9/6b/dd/d96bdd1c-2c05-85ce-5568-adaac146dd82/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/d8/3e/f2/d83ef285-d35d-e1ac-2c87-c528929e155e/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/aa/93/2c/aa932cd5-e544-7256-9f2f-a8864f45ae4c/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple124/v4/90/94/62/9094626f-5ade-cf6a-bf0a-7ae9b721e825/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/3f/97/97/3f979701-925a-bf39-b7f9-37e117fb1491/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/d4/1d/8a/d41d8a9f-54fb-5a18-b130-b10e1601ee10/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/13/ae/b5/13aeb56f-4f61-1b6e-e964-af032f4af0b9/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/46/99/7e/46997efc-6831-38c1-be44-1b31fba04b2e/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple114/v4/13/05/89/13058926-7cd1-0859-2528-36a7812e8425/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/c9/43/85/c943854d-7554-e9fe-499b-efbff4b05d7f/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple114/v4/5a/21/04/5a210448-787f-5722-b3b3-d4333bdfdc01/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple124/v4/37/f1/84/37f184f0-4a21-c692-4524-9f8333bf03b8/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/f8/b4/4a/f8b44ad0-20c4-2093-260a-035254e506c2/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/f8/b4/4a/f8b44ad0-20c4-2093-260a-035254e506c2/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple114/v4/f8/b4/4a/f8b44ad0-20c4-2093-260a-035254e506c2/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/ringcentral-inc/id293305987?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "RingCentral", "languageCodesISO2A": [ "EN", "FR", "DE", "IT", "JA", "PT", "ZH", "ES", "ZH" ], "fileSizeBytes": "415329280", "sellerUrl": "http://www.ringcentral.com/teams/overview.html", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/ringcentral/id715886894?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2019-01-02T07:16:55Z", "releaseNotes": "• Quick Contacts – Keep the people you communicate with most close by. RingCentral subscribers can add contacts to the new Quick Contacts carousel in the Contacts tab.\n• Screen Sharing – Broadcast your entire screen in a RingCentral meeting using Screen Recording in the iOS Control Center. You must have RingCentral Meetings Embedded set as your video service and be using iOS 11 or higher.\n• Conversation Notification Preferences – Want to get notified of all new messages in a conversation? Or never get notified? Set team or direct message specific notification preferences from the conversation settings screen.\n• RingCentral Admin Enhancements – Company admins can now access the RingCentral Analytics Portal and RingCentral Call Logs from the RingCentral app.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2013-10-23T12:02:45Z", "currency": "AUD", "wrapperType": "software", "version": "5.10.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 293305987, "artistName": "RingCentral, Inc", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "RingCentral is the leading all-in-one voice, team messaging, and video conferencing solution. Unlock your team’s potential and reduce email clutter with integrated task management, file sharing, and calendaring.\n \nHere’s how RingCentral helps teams to get stuff done:\n \n• Message real-time one-on-one or with a team—from anywhere and on any device.\n• Collaborate live with HD video, screen sharing, and group video meetings.\n• Assign tasks and events to stay productive and accountable.\n• Share files, photos, links, and notes.\n• Keep on top of your most important communications with message filters, mention indicators, and new message counts.\n• Integrate with third-party apps such as Zendesk, Trello, Asana, and JIRA.\n\nHave a RingCentral Office subscription? Use the app to take your business number anywhere you go for calls, voice messages, text messages, and faxes.\n \nSign in with your RingCentral Office account to do even more:\n• Make secure phone calls over Wi-Fi without using your carrier minutes.\n• Show your RingCentral business number as your caller ID when you make calls or send text messages.\n• Initiate calls directly from a private message.\n• Make local calls to your home country while traveling internationally over Wi-Fi.\n• Use the app to keep all your business voicemails and faxes separate from your personal messages.\n \nA RingCentral Office subscription is required for certain product features. Features will vary by product and plan. A free subscription is available with limited capabilities.", "minimumOsVersion": "10.0", "primaryGenreName": "Business", "bundleId": "com.glip.mobile", "trackName": "RingCentral", "trackId": 715886894, "sellerName": "RingCentral, Inc", "averageUserRating": 4.5, "userRatingCount": 13 }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/9b/2b/6a/9b2b6a84-68ec-b49c-a8d3-0a72c6963ae0/source/406x228bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/d4/67/43/d467431b-5517-99ab-fc38-520da1b9ce15/source/406x228bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/18/2f/4e/182f4e14-3fcd-d352-e129-ee8f0b2d410a/source/406x228bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/c6/fc/09/c6fc0929-4968-f317-5c06-cc074af57b38/source/406x228bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/21/6c/61/216c61c2-ff16-d524-12d0-64978ab616cd/source/406x228bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/d7/79/03/d77903be-3b7e-9d6c-169b-20c262946655/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/3b/95/66/3b956633-8690-75a7-02a0-a9b2ba924300/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/cf/33/77/cf3377ce-bce3-7e85-d67c-5e493db1e241/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/51/28/b3/5128b35d-85fb-ce9d-14ff-cc82c3675d1c/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/8d/d8/cd/8dd8cd02-a11a-0b79-08c1-3ddfb5f5d3c2/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/a8/71/b2/a871b214-8792-2e62-0f7a-adaad4d2cd8c/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/a8/71/b2/a871b214-8792-2e62-0f7a-adaad4d2cd8c/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/a8/71/b2/a871b214-8792-2e62-0f7a-adaad4d2cd8c/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/huang-zhiqiang/id556750720?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone3GS-iPhone-3GS", "iPhone4-iPhone4", "iPodTouchFourthGen-iPodTouchFourthGen", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Baby Zoo Hospital", "languageCodesISO2A": [ "CS", "NL", "EN", "FR", "DE", "IT", "JA", "KO", "PL", "PT", "RU", "ZH", "ES", "SV", "ZH", "TR" ], "fileSizeBytes": "34650112", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/baby-zoo-hospital/id593413141?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-12-28T20:35:57Z", "releaseNotes": "Update for iOS 11", "primaryGenreId": 6014, "formattedPrice": "Free", "genreIds": [ "6014", "7003", "6017", "7015" ], "releaseDate": "2013-01-21T19:45:43Z", "currency": "AUD", "wrapperType": "software", "version": "1.0.1", "isVppDeviceBasedLicensingEnabled": true, "artistId": 556750720, "artistName": "HUANG ZHIQIANG", "genres": [ "Games", "Arcade", "Education", "Simulation" ], "price": 0.00, "description": "Many cubs need care and coziness.You will work in the zoo hospital for cubs. Give them food and play with them. It's important to do everything in time! Your inmates will be the happiest animals in the zoo!", "minimumOsVersion": "6.0", "primaryGenreName": "Games", "bundleId": "com.outsourcingflash.babyZooHospital", "trackName": "Baby Zoo Hospital", "trackId": 593413141, "sellerName": "HUANG ZHIQIANG" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple115/v4/f3/ff/62/f3ff6252-5a90-1faa-feff-0b373f4eb1ff/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple125/v4/61/08/fa/6108fadd-2164-4250-2c7e-4e993275d7fc/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple125/v4/06/0f/fa/060ffa9d-04a0-087c-a662-58855bed159a/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple115/v4/fc/dd/1a/fcdd1af1-3e89-f6a2-c865-314225ad1e78/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple125/v4/3b/f4/86/3bf48647-fb0e-3d1f-b9f5-c31231c8e334/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple115/v4/d5/73/86/d57386ba-3c10-342a-9f0c-3fb7c8890f9d/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple125/v4/19/75/18/19751853-e934-c273-e849-61cfe4d8c049/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple115/v4/02/6a/4c/026a4c46-0164-3c01-16d3-2bd54b8ce2af/source/576x768bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple115/v4/f0/8c/62/f08c6257-f420-cb94-2734-bb4c039008a5/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple125/v4/31/5f/64/315f647a-0a62-4916-313b-567485102725/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/2f/da/a8/2fdaa883-64d4-7a92-aa54-57f3ca75df6e/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/2f/da/a8/2fdaa883-64d4-7a92-aa54-57f3ca75df6e/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple114/v4/2f/da/a8/2fdaa883-64d4-7a92-aa54-57f3ca75df6e/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/redbooth-inc/id465462661?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Redbooth", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "78306304", "sellerUrl": "https://redbooth.com/", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/redbooth/id793346089?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2019-01-02T21:01:04Z", "releaseNotes": "- See your assignees at a glance on tasks\n- Misc bug fixes", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2014-01-20T16:30:24Z", "currency": "AUD", "wrapperType": "software", "version": "8.35.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 465462661, "artistName": "Redbooth, Inc.", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "WHAT IS REDBOOTH\nRedbooth is an easy to use project management software available for teams to stay organized and get work done. Redbooth allows teams to manage an unlimited number of projects in collaborative workspaces that combine tasks, files and feedback into a centralized, searchable, and in-sync experience; it is the perfect workflow management system! Redbooth teams are more productive because they can easily work together on their favorite device or platform. \n \nSTART FAST\n- Create an account directly through the iOS app\n- Easily set up dedicated workspaces for each project or task you want to manage\n- Super intuitive interface for creating and assigning new tasks\n- Just the right level of functionality for busy teams\n \nUPDATE ANYWHERE\n- View and organize your work from anywhere \n- Create tasks, conversations or update projects anytime\n- Add due dates, assignees or comments to any task\n- Update tasks as work is completed or notify others about changes\n- Everything is automatically saved and synced\n \nTRACK EVERYTHING\n- See your favorite workspaces and task management lists\n- Assess the progress of shared projects and spot dependencies early\n- Visualize progress as you complete projects\n \nSTAY CONNECTED\n- Get notified of important updates\n- Speed up feedback with integrated messaging tools\n- Notification settings are fully customizable\n- Use Redbooth conversations to chat within the app\n \nPRICING\n-Free: 2 users and 2 workspaces for teams getting started with project management\n-Professional: From $9/mo: subtasks, reporting, and guest users for growing teams\n-Business: From $15/mo: assignable subtasks and priority support for large teams\n \nCOMPARE\nOther tools like Basecamp, Trello, Wrike, Asana, Aha!, and Microsoft Project can’t come close to the ease of use of Redbooth, which is built specifically for busy teams who don’t have a lot of time to spare.", "minimumOsVersion": "10.3", "primaryGenreName": "Business", "bundleId": "com.teambox.Teambox", "trackName": "Redbooth", "trackId": 793346089, "sellerName": "Redbooth, Inc.", "averageUserRating": 4.5, "userRatingCount": 56 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/99/e2/05/99e20536-c3c8-6e92-2a01-a6f0f1702c9e/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/4e/49/52/4e4952f8-142b-f288-1d52-4b72592743c4/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/9c/69/94/9c6994ee-7aa0-e21c-4eca-161d4d689773/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/6a/41/0d/6a410da3-cfaf-d9a7-dc21-a439e2dd60a2/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/a5/ba/d1/a5bad1b6-d078-06be-6561-6449a613c96a/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/66/ce/aa/66ceaac0-e550-084f-6e62-576575cb013d/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/66/ce/aa/66ceaac0-e550-084f-6e62-576575cb013d/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/66/ce/aa/66ceaac0-e550-084f-6e62-576575cb013d/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/guy-gubi/id332505557?mt=8&uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "averageUserRatingForCurrentVersion": 4.5, "trackCensoredName": "Favorites Widget Pro", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "20047872", "sellerUrl": "http://guygubi.wix.com/favoriteswidget", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 20, "trackViewUrl": "https://itunes.apple.com/au/app/favorites-widget-pro/id909578530?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-04-27T23:34:02Z", "releaseNotes": "iMessage is now supported with the free version", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6002" ], "releaseDate": "2014-09-18T22:13:38Z", "currency": "AUD", "wrapperType": "software", "version": "4.1", "isVppDeviceBasedLicensingEnabled": true, "artistId": 332505557, "artistName": "Guy Gubi", "genres": [ "Productivity", "Utilities" ], "price": 0.00, "description": "Featured by Lifehacker and Drippler as essential widget!\n\nCall and text your favorite contacts directly from the Widgets screen! \nThis Widget support calling, message, WhatsApp, Slack, Telegram, Facebook Messenger, email and FaceTime.\n\nYou can also organize contacts in groups (e.g. Family, Friends, Work) and access them directly from the widget.\n\nQuickly order Uber or Lyft to your favorite places from the widget.\nThe widget show you price comparison between Uber to Lyft so can literally save money!\n\nThis is an app with a powerful widget that will dramatically improve everyday use of your iPhone. While currently contacting your friends can be an annoyingly long process because you must open apps and search through contact after contact, with Favorites Widget just swipe right from the lock screen, home screen, or pull down the widgets screen from within any app and get immediate access to your favorites. \n\nSlack support! \n- Quickly open your channels and Slack contacts from the widget\n- Multiple teams\n- Public channels, private channels and direct messages\n\nFeatures:\n● Call & Text from the Notification Center\n● Groups\n● Unlimited contacts and groups\n● 3D Touch in the widget for quick call\n● Call & Message \n● WhatsApp\n● Telegram\n● Slack\n● Facebook Messenger\n● Email\n● FaceTime & FaceTime Audio\n● Uber and Lyft integration + price comparison\n● Full support for iPhone 7 / 7+", "minimumOsVersion": "8.0", "primaryGenreName": "Productivity", "bundleId": "com.gubi.Favorites-Widget", "trackName": "Favorites Widget Pro", "trackId": 909578530, "sellerName": "Guy Gubi", "averageUserRating": 4.5, "userRatingCount": 56 }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/6e/f9/10/6ef910ca-9e1f-ce53-f277-b08b1ca2e4db/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/58/f8/f7/58f8f79a-a070-34aa-cac2-95fcbf091a2e/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/c4/7c/22/c47c228a-e526-fc3d-f514-c6891c3bbe60/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/f9/7f/e7/f97fe7c8-55e7-ba7c-45a2-a7d143b5c20c/source/552x414bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/af/66/37/af6637be-7f03-eb82-d7bb-a8f91008025e/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/8a/68/46/8a68466c-c8db-deff-a2c5-ae1001636c65/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/ea/d1/08/ead10869-203c-aea7-dcdc-c85830f19d19/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/ea/d1/08/ead10869-203c-aea7-dcdc-c85830f19d19/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/ea/d1/08/ead10869-203c-aea7-dcdc-c85830f19d19/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/slack-technologies-inc/id453420243?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Frontiers by Slack", "languageCodesISO2A": [ "CA", "NL", "EN", "FR", "DE", "HE", "IT", "PL", "PT", "RO", "RU", "ZH", "ES", "ZH", "TR" ], "fileSizeBytes": "126481408", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/frontiers-by-slack/id1433968558?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-09-02T01:59:26Z", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6006" ], "releaseDate": "2018-09-02T01:59:26Z", "currency": "AUD", "wrapperType": "software", "version": "1.1", "isVppDeviceBasedLicensingEnabled": true, "artistId": 453420243, "artistName": "Slack Technologies, Inc.", "genres": [ "Business", "Reference" ], "price": 0.00, "description": "Welcome to Frontiers, a conference by Slack! The Frontiers app is your personalized guide. Use it to manage your session schedule, see where the next keynote is happening, and more. After Frontiers, it’ll help you review session materials, or make connections. Whenever (and however) you use it, we’re really looking forward to seeing you soon.\n\nMore things you can do:\n- Explore the conference agenda for all tracks, keynotes and networking events\n- Navigate the conference: locate sessions, sponsors, gathering places, and more\n- Email yourself sponsored conference literature\n- Stay in contact with conference organizers and sponsors", "minimumOsVersion": "10.0", "primaryGenreName": "Business", "bundleId": "com.slack.frontiers", "trackName": "Frontiers by Slack", "trackId": 1433968558, "sellerName": "Slack Technologies, Inc." }, { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple49/v4/9d/ee/29/9dee29f7-2e43-5e51-7aa6-a69db02e2444/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple49/v4/f3/be/01/f3be0111-0283-9426-e15a-62c7d877a4ba/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple49/v4/24/4e/9c/244e9c83-809c-14ee-da9f-203d47a67d4f/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple49/v4/4f/9c/d3/4f9cd3d5-e92c-191d-69e7-5e3c7fd95fe0/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple49/v4/d9/9b/01/d99b0126-f9d9-e687-e41a-1963b2282991/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple49/v4/8a/df/d3/8adfd3f8-d435-07de-68e3-9d9d7bd5b257/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple49/v4/9b/50/7d/9b507d71-2b41-889b-1fcf-632cc8963a67/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple49/v4/91/d1/c1/91d1c166-9077-d5cd-f0de-a266c4624d8a/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple49/v4/62/cd/fb/62cdfb8e-b44d-95fa-706a-c485b4582985/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/02/bc/6b/02bc6b9f-de0a-fd10-0ca6-6ff0eade505e/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/02/bc/6b/02bc6b9f-de0a-fd10-0ca6-6ff0eade505e/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/02/bc/6b/02bc6b9f-de0a-fd10-0ca6-6ff0eade505e/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/tappforce/id497894754?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "BOARD for JIRA - Scrum Kanban", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "34895872", "sellerUrl": "http://tappforce.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/board-for-jira-scrum-kanban/id934196108?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-10-06T21:46:26Z", "releaseNotes": "Bug Fixes", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2015-09-02T23:52:09Z", "currency": "AUD", "wrapperType": "software", "version": "1.6.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 497894754, "artistName": "Tappforce", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "** Over 1,000 customers ship on time with JIRA Boards **\n\nManage your daily standup & scrum teams with JIRA Boards. \n \n- Visualize your team's tickets on a beautiful, fully native (ew, web apps) kanban or agile board (Portrait AND Landscape). Available on both iPhone & iPad. Your first board is always free, forever.\n\n- Assign tickets and transition them through your JIRA workflow. Just drag tickets with your finger, it's that easy.\n\n- Switch between multiple boards and multiple JIRA accounts\n\n- JIRA Board works with JIRA Cloud and JIRA Server.\n\n- Automatically login with 1Password!\n\n- Perfect for scrum masters, tech leads and managers that practice agile development.\n\nALL FEATURES\n\n- Scrum and Kanban agile boards supported, including custom workflows\n- Set assignees, components, and labels\n- Comment on tickets\n- Compose new tickets\n- Rank tickets\n- Transition tickets\n- Share tickets to HipChat & Slack\n- Login with multiple accounts\n- Login quickly with 1Password\n- Works with JIRA 6.0 and above\n- Search for tickets via Spotlight\n\nIN APP PURCHASES\n\nAll subscriptions unlock unlimited JIRA boards, and come included with a 1 month FREE trial. If you choose to subscribe you will be charged a price according to your country. The price will be shown before you complete the payment. The subscription renews every month or every year depending on your selection, and auto-renews every month or every year unless auto-renew is turned off 24 hours before end of the current subscription period. Your iTunes Account will be automatically charged within 24 hours prior to the end of the current period and you will be charged for one month at a time, or one year at a time depending on your subscription selection. You can turn off auto-renew at anytime from from your iTunes account settings.\n\n- Monthly Subscription: Unlock Unlimited Boards for $4.99/mo, automatically renewed monthly until canceled.\n\n- One Time Unlimited Purchase: Unlock Unlimited Boards for $99.99 one time only. All boards will be yours forever.\n\nPrivacy Policy: http://unbouncepages.com/tappforce-privacy-policy/\n\nTerms of Service: http://unbouncepages.com/tappforce-tos/", "minimumOsVersion": "9.0", "primaryGenreName": "Productivity", "bundleId": "com.tappforce.JIRAforce", "trackName": "BOARD for JIRA - Scrum Kanban", "trackId": 934196108, "sellerName": "TAPPFORCE LLC" }, { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/4a/9d/8d/4a9d8da8-db9b-37c4-244b-07f9d495f610/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/49/90/a1/4990a13d-9183-804a-4937-62fb7463d09d/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/0b/26/f8/0b26f813-ca19-a6a4-7ea7-d48f1acb79df/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/aa/c2/aa/aac2aac3-e4bc-5860-0e14-a1a1ea10f2b5/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/ca/ad/b7/caadb7ff-1d93-3754-063a-d355b5611eb9/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/45/82/c1/4582c173-caea-6ad1-0d42-c735e6c7d3db/source/576x768bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/c7/36/cf/c736cfe2-1527-6d47-3c38-d895c39d8c15/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/9b/04/a8/9b04a84f-d2e8-eb8f-350b-cfc68204f6fe/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/9b/04/a8/9b04a84f-d2e8-eb8f-350b-cfc68204f6fe/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple114/v4/9b/04/a8/9b04a84f-d2e8-eb8f-350b-cfc68204f6fe/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/ca-flowdock/id1224635136?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Flowdock", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "65267712", "sellerUrl": "http://www.flowdock.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/flowdock/id528568363?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-19T18:51:35Z", "releaseNotes": "What's New:\n\n- Introducing Quick Flows/1-1 Finder! You can swiftly select a flow or 1-1 chat without having to scroll through an extensive list.\n- Introducing Search! Search all your flow's content, including chat messages, uploaded files, emails and other team inbox items. We recommend it.\n- Introducing 1-1 Search! Search all your 1-1 chat content including files and links. \n- You can now view attachments shared in flows and 1-1 chats. \n\nBug Fixes:\n- A sharp uptick in crashes that no longer happen. Because we fixed a handful of them", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2012-05-25T15:46:33Z", "currency": "AUD", "wrapperType": "software", "version": "4.1.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1224635136, "artistName": "CA Flowdock", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "Official iPhone/iPad app of Flowdock\n\nFlowdock is the center of gravity for team communication that helps teams make their regular work a by-product of collaboration through organized chat and a shared integration inbox.\n \nIt replaces IM or IRC chat in your team's workflow and frees your mailbox from automated emails. With integrations to over 80+ tools, you’ll always stay up-to-date with what your team is doing.\n \nFlowdock allows you to:\n· Converse with your teams in Flows and organize your conversations by threads\n· Have private conversations with your team mates when needed with 1-1s\n· Stay on top of your updates across flows and 1-1s with in app notifications and customize the notifications as needed\n· Be expressive with emojis across flows and 1-1s\n· Collaborate with your team members by sharing files across flows and 1-1s\n· Build your own ongoing knowledge base with the power of hash tags\n· Effectively search for content that you need with help of hash tags that you maintain\n· View all your integrations in one place with shared integration inbox and converse with your teams on the inbox items to effectively get your work done\n· Get attention from only people that matter with @@subgroups\n \nFlowdock integrates with your favorite tools, including Trello, Git & GitHub, Pivotal Tracker, Zendesk, Atlassian JIRA, Confluence, Bamboo, Capistrano, Heroku, Redmine, FogBugz, Basecamp, BitBucket, Kiln, Mercurial, Nagios, Pingdom, Hudson / Jenkins and many other project management, issue tracking, wiki, version control, monitoring, deployment & continuous integration systems and services. \n \nNOTE: To use the Flowdock app, you need to have a Flowdock account available at: http://www.flowdock.com \n \nFor feedback or feature suggestions, check out our Uservoice page at http://flowdock.uservoice.com\nFor support, reach out to our support team at Team-Flowdock-Support@ca.com", "minimumOsVersion": "10.0", "primaryGenreName": "Productivity", "bundleId": "com.flowdock.dalton", "trackName": "Flowdock", "trackId": 528568363, "sellerName": "CA INC", "averageUserRating": 2.5, "userRatingCount": 8 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/fe/4d/b0/fe4db00d-b740-b20f-1d01-c7d8bb3e32d0/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/21/dd/76/21dd7627-2c3a-1df9-d27a-e68db29464df/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/c1/36/38/c13638e4-02a8-6f33-1c97-c6385f47ba60/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/9d/07/6b/9d076b8b-935d-450c-fe46-6eef16683fca/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/db/5b/c6/db5bc65f-a445-2439-8e21-0fa7caf13fc1/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/56/d7/10/56d7104b-716d-f429-edcd-bd9afaa4443c/source/576x768bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/ee/9e/a3/ee9ea373-89b6-7563-fb70-a5894343f27c/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/0e/a3/b4/0ea3b429-fc63-ada6-bf51-746bdbd2d875/source/576x768bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/11/95/4a/11954a96-5307-4d03-114a-4745a3a71a08/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/d9/8e/50/d98e5078-dce5-b684-22f1-0423203e2741/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/d9/8e/50/d98e5078-dce5-b684-22f1-0423203e2741/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/d9/8e/50/d98e5078-dce5-b684-22f1-0423203e2741/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/vector-creations-limited/id1154157774?uo=4", "advisories": [ "Unrestricted Web Access" ], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 2.5, "trackCensoredName": "Riot.im", "languageCodesISO2A": [ "SQ", "EU", "BG", "CA", "NL", "EN", "FR", "DE", "HU", "IS", "JA", "RU", "ZH", "ES", "ZH", "VI" ], "fileSizeBytes": "86716416", "sellerUrl": "http://riot.im", "contentAdvisoryRating": "17+", "userRatingCountForCurrentVersion": 2, "trackViewUrl": "https://itunes.apple.com/au/app/riot-im/id1083446067?mt=8&uo=4", "trackContentRating": "17+", "currentVersionReleaseDate": "2018-12-13T09:54:09Z", "releaseNotes": "This new version supports the consent of matrix servers terms of service (including GDPR) in the registration flow.\nIt also contains fixes for the \"Empty room\" bug, the registration issue on iOS 10, etc.", "primaryGenreId": 6005, "formattedPrice": "Free", "genreIds": [ "6005", "6002" ], "releaseDate": "2016-05-05T22:48:51Z", "currency": "AUD", "wrapperType": "software", "version": "0.7.8", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1154157774, "artistName": "Vector Creations Limited", "genres": [ "Social Networking", "Utilities" ], "price": 0.00, "description": "Welcome to Riot.im: a new world of open communication!\n\nRiot.im is a simple and elegant collaboration environment that gathers your different conversations and app integrations into one single app.\n\nBuilt around group chatrooms, Riot.im lets you share messages, images, videos and files - interact with your tools and access all your different communities under one roof. One single identity and place for all your teams: no need to switch accounts, work and chat with people from different organisations in public or private rooms: from professional projects to school trips, Riot.im will become the center of all your discussions!\n\nFeatures include:\n\n • Instantly share messages, images, videos and files of any kind within groups of any size\n • See who's reading your messages with read receipts\n • Email notifications of missed messages and invites\n • Voice and video calling and conferencing \n • End-to-end encryption using Olm (https://matrix.org/git/olm)\n • Communicate with users anywhere in the Matrix.org ecosystem - not just Riot.im users! Including bridged apps and networks like Slack, IRC and Gitter (more coming soon!)\n • Discover and invite users by email address\n • Participate in guest-accessible public rooms\n • Highly scalable - supports hundreds of rooms and thousands of users\n • Fully synchronised message history across multiple devices and browsers\n • Finely configurable notification settings, synchronised over all devices\n • Infinite searchable chat history\n • Interact with bots and integrated third party applications like GitHub, Jira and Jenkins (more to come soon!)\n • Permalinks to messages\n • Full message search\n • Excellent support for all iOS device sizes and orientations\n\nFor developers:\n • Riot.im is a Matrix client - built on the Matrix.org open standard and ecosystem, providing interoperability with all other Matrix compatible apps, servers and integrations\n • Entirely open sourced under the permissive Apache License - get the code from https://github.com/vector-im/riot-ios. Pull requests welcome!\n • Trivially extensible via the open Matrix Client-Server API (http://matrix.org/docs/spec)\n • Run your own server! You can use the default matrix.org server or run your own Matrix home server (e.g. http://matrix.org/docs/projects/server/synapse.html)\n\nComing soon:\n • Add your own integrations, bridges and bots!\n • Screen sharing\n • Login as multiple users at the same time\n\nThe web version of Riot.im is available at https://riot.im/app/\n\nRiot.im. Break through.", "minimumOsVersion": "9.0", "primaryGenreName": "Social Networking", "bundleId": "im.vector.app", "trackName": "Riot.im", "trackId": 1083446067, "sellerName": "Vector Creations Limited" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple18/v4/eb/a8/1f/eba81fc3-05a4-ede4-abbe-5c334b488635/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple60/v4/05/53/c9/0553c9fe-5f08-a3e7-4b54-708a888b7651/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple30/v4/d0/eb/a8/d0eba84b-416c-0174-7805-7ccf1188443d/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple60/v4/a1/4d/92/a14d9264-c070-fdeb-90dd-ca87660dc184/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple20/v4/d8/d0/c8/d8d0c85a-008f-034f-3e3b-9913a9b4c234/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple62/v4/9e/18/d0/9e18d0b6-08d8-6a20-65b6-4c2a31848ed5/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple62/v4/9e/18/d0/9e18d0b6-08d8-6a20-65b6-4c2a31848ed5/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple62/v4/9e/18/d0/9e18d0b6-08d8-6a20-65b6-4c2a31848ed5/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/imo-network/id525351458?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone4-iPhone4", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "imo班聊-移动办公软件、简单高效的团队沟通平台", "languageCodesISO2A": [ "EN", "ZH" ], "fileSizeBytes": "138503168", "sellerUrl": "http://www.workchat.com/", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/imo%E7%8F%AD%E8%81%8A-%E7%A7%BB%E5%8A%A8%E5%8A%9E%E5%85%AC%E8%BD%AF%E4%BB%B6-%E7%AE%80%E5%8D%95%E9%AB%98%E6%95%88%E7%9A%84%E5%9B%A2%E9%98%9F%E6%B2%9F%E9%80%9A%E5%B9%B3%E5%8F%B0/id525351455?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2016-09-01T05:51:57Z", "releaseNotes": "[新增] \n- 公费电话:免费赠送通话时长,让员工沟通无压力; \n- 企业用车:员工打车,企业买单,出行无负担; \n- 新手指南:30秒掌握产品价值点。 \n\n[优化] \n- 语音消息更清晰;\n- 支持组织架构选人,更符合工作场景; \n- 工作台性能优化。", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2012-06-04T12:32:57Z", "currency": "AUD", "wrapperType": "software", "version": "7.0.12", "isVppDeviceBasedLicensingEnabled": true, "artistId": 525351458, "artistName": "imo Network", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "班聊,中国第一个桌面与移动一体化的企业办公沟通协同平台!\n\n※※※让班聊为工作点赞※※※\n\n【让工作效率飞起来】\n管理人员,一天90%的时间需要用来沟通工作,可浪费了多少时间在找人?\n还在一一发邮件?岁月静好,这样浪费,你本来有更多时间可以约会? \nBut——有这样一款神器 | imo班聊\n史上最强的聊天唤醒神器,一秒召集人群开启线上会议;3秒响应紧急审批即刻搞定,什么,手机摇一摇就能打卡?\n用班聊,高效办公,从此妈妈再也不用担心我因为加班,没有时间约会了!\n\n【纯净的办公环境】\n还在用微信、qq办公?\n老板、上司、 同事、客户,发朋友圈/说说, 你手抖吗?\n八卦、搞笑、鸡汤,晒幸福,欲罢不能,怎么有心工作?\nBut——有这样一款神器 | imo班聊\n 在班聊,只聊工作的事!纯纯的办公环境,没有最纯,只有更纯,噢耶\\(^o^)/\n更有私有云,给企业独立服务器部署,企业信息都锁在家中,比银行还安全! \n\n【瞬间提高执行力】\n沟通工作不落地,纯粹就是瞎扯淡,领导敦敦教导+口头任务,早已淹没在聊天记录的深处?\n一句语音60秒,第50秒有个关键内容没听清楚怎么办?再听一遍50秒?\nBut——有这样一款神器 | imo班聊\n一边聊天,一边还能将领导的话直接转成任务,落地执行,语音也能点击暂停。\n高效&执行,班聊绝不缺一而行!\n\n【知识管理】\n神马?在群里一页一页查找领导说了啥? \n想找个以前发过的文件?翻到手断,还是找不到 \nBut——有这样一款神器 | imo班聊\n自动将聊天文件分类管理,图片、文件、链接,比你钱包里的money还清楚。\n\n【这都是实力】\n##2010年度中国行业信息化突出贡献奖##\n##2012中国年度微创新企业100强##\n##2013上海市高新技术企业##\n##2104年度中国行业信息化最佳解决方案奖##\n##2015年度中国大协同联盟副理事长单位##\n##2015年度上海“五星级诚信创建企业”##\n\n【联系我们】\n官方网站:http://www.workchat.com/\n官方微博:关注“imo班聊” \n官方电话:4009206575\n微信公众号:imoffice_com\nQQ群:297055286\n客 服 QQ:3065718494\n投诉建议: fankui@workchat.com", "minimumOsVersion": "7.0", "primaryGenreName": "Productivity", "bundleId": "com.imoffice.i-m-office", "trackName": "imo班聊-移动办公软件、简单高效的团队沟通平台", "trackId": 525351455, "sellerName": "imo Network" }, { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple69/v4/9b/b1/75/9bb17557-7e57-ac2c-e206-3aec6ea035ee/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple2/v4/aa/bb/11/aabb1141-4199-1a76-4f6e-ba179787e6c0/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple49/v4/62/d6/1a/62d61a61-cbd2-38b1-bc1c-c0b5fa3628ba/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple7/v4/d5/db/9a/d5db9ae3-eec0-9317-6a07-4c2ece23a382/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple49/v4/64/61/6a/64616a36-20c3-4a08-c7a1-00dd8c58a803/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple49/v4/ff/03/b9/ff03b99d-5d41-a16d-eaf9-4239dbd4936b/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple49/v4/a9/5d/59/a95d59fd-c880-5101-80fd-c5cb22cf462e/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple82/v4/0c/c2/41/0cc241af-c1d7-c2e3-5c56-2b8ca7540d8d/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple82/v4/0c/c2/41/0cc241af-c1d7-c2e3-5c56-2b8ca7540d8d/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple82/v4/0c/c2/41/0cc241af-c1d7-c2e3-5c56-2b8ca7540d8d/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/tappforce/id497894754?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Tappsana for Asana - Offline Team Collaboration", "languageCodesISO2A": [ "CA", "CS", "DA", "NL", "EN", "FI", "FR", "DE", "EL", "HE", "HU", "ID", "IT", "JA", "KO", "NB", "PL", "PT", "RO", "RU", "ZH", "SK", "ES", "SV", "ZH", "TR" ], "fileSizeBytes": "18473984", "sellerUrl": "http://tappforce.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/tappsana-for-asana-offline-team-collaboration/id684973303?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2016-12-31T23:09:36Z", "releaseNotes": "Bug Fixes\nEnhancements", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6000" ], "releaseDate": "2013-09-19T03:28:38Z", "currency": "AUD", "wrapperType": "software", "version": "3.1.2", "isVppDeviceBasedLicensingEnabled": true, "artistId": 497894754, "artistName": "Tappforce", "genres": [ "Productivity", "Business" ], "price": 0.00, "description": "Tappsana is a full featured Asana client for the iPad, iPhone and iPod Touch that works offline and faster than ever.\n\nView all of your Asana projects and assign tasks to your team members, whether you’re online, in an airplane or running late at the subway. \n\nKEY FEATURES\n\nOFFLINE MODE\n\n- Create projects and tasks while you’re offline. The next time you’re back online, everything syncs with Asana automatically.\n\nENHANCED FOR NEW DEVICES\n\n- Support for iPhone 6S and iPhone 6S Plus\n\nENHANCED FOR IPAD\n\n- Triple pane UI lets you get your work done quickly. Even on iPad Pro\n\nOTHER ASANA FEATURES\n\n- Quickly add Sub Tasks\n- Sort by Assignee, Due Date or Manually,\n- Tagging Support", "minimumOsVersion": "8.0", "primaryGenreName": "Productivity", "bundleId": "com.tappforce.Tappsana", "trackName": "Tappsana for Asana - Offline Team Collaboration", "trackId": 684973303, "sellerName": "TAPPFORCE LLC" }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple117/v4/11/33/08/113308af-09f0-5c8c-815e-6e96f53fcef1/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple127/v4/7a/3d/5d/7a3d5d77-bfc1-08b5-8864-cbd7d36c6c7a/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple127/v4/3a/eb/ee/3aebeeb3-7eef-97ab-3ad7-caabbbd778b1/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple117/v4/8d/f0/1e/8df01e91-57a0-41ab-3c33-857ac924e242/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple127/v4/fc/6f/f6/fc6ff668-5ceb-78b2-e66e-cba2a2ea66f2/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple117/v4/58/9a/62/589a6225-5c46-e568-c6b7-0a042f10ae40/source/552x414bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple117/v4/d7/79/3f/d7793fb6-7176-53eb-d716-97693cee488c/source/552x414bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple127/v4/1c/7f/12/1c7f12c8-7d98-0ece-209a-1b834df08a76/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple117/v4/9a/8e/50/9a8e50fa-a8f0-006c-3ee6-2fc4cd5a6a75/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple117/v4/b1/11/40/b1114098-cad8-cf47-7fbc-b97db2e16796/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/e6/de/c5/e6dec595-5cf7-8e40-5f35-8ca949c04a9e/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/e6/de/c5/e6dec595-5cf7-8e40-5f35-8ca949c04a9e/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple124/v4/e6/de/c5/e6dec595-5cf7-8e40-5f35-8ca949c04a9e/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/simply-good-software/id385251756?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Pyrus", "languageCodesISO2A": [ "EN", "RU" ], "fileSizeBytes": "91484160", "sellerUrl": "http://pyrus.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/pyrus/id385251753?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-29T12:38:45Z", "releaseNotes": "Fixed errors that some users encountered when filling out forms.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2010-08-07T04:45:48Z", "currency": "AUD", "wrapperType": "software", "version": "4.152", "isVppDeviceBasedLicensingEnabled": true, "artistId": 385251756, "artistName": "Simply Good Software", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Pyrus is the team communication tool for your entire business. It incorporates real-time messaging, task delegation, and approval flows. Finally, all your tasks and conversations are in one easy-to-use interface. \n\n- Communicate: every task is a message thread working towards a specific goal.\n- Delegate: every task has a single person responsible for it at any given moment.\n- Organize: hide a task from your inbox when you don’t need to act, or snooze it when you want a friendly reminder later. \n- Track: a powerful search capability makes it easy to find the details you need.\n- Anywhere: Pyrus syncs seamlessly across all your devices and even works offline.\n\nFEATURE HIGHLIGHTS\n- Swipe right to hide a task from your inbox and stand by, swipe left to archive it and mark it as complete.\n- Forward any email to x@pyrus.com to turn it into a task.\n- Attach documents, photos, or files from cloud storage like Box, Dropbox, and Google Drive.\n- Use subtasks to split a large task into a list of action items.\n- Get a notification whenever your action or input is requested.\n- Share images and documents from other apps.\n\nPyrus is free to use for an unlimited number of people, offering upgradeable plans for extended usage, increased storage, unlimited API calls, and custom data access policies.\n - Login with your G+ account \n- Invite colleagues from Address Book \n- Work with outsourcers and subcontractors. \n\n*** Notifications \n\n- Badge on app icon shows the number of unread tasks in your Inbox \n- You receive push notification when something important happens with your tasks in Pyrus\n\nPyrus works offline and seamlessly syncs in background. No frozen UI, no duplicates.", "minimumOsVersion": "9.0", "primaryGenreName": "Business", "bundleId": "net.papirus.iphoneclient", "trackName": "Pyrus", "trackId": 385251753, "sellerName": "Simply Good Software, Inc" }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/72/19/99/7219991b-a71f-4be7-ee95-5dd9fd5e3e23/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/b4/f4/02/b4f402c6-bb6b-cdc7-9ce0-ee8e355433ee/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/9a/9e/d8/9a9ed891-12fd-10be-b81a-3370bc3e9c2c/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/af/d9/51/afd95186-f943-014f-b364-8cb55d9730c6/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/26/82/71/26827115-57a9-d2ba-66a3-b710766f0e42/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/26/2f/de/262fde6d-184a-3ca5-8473-0347c5186476/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/4d/62/a4/4d62a483-dd08-085d-901e-2c84f947d3b7/source/552x414bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/be/b6/01/beb6019a-cc4f-3351-5b81-2dbbce183f35/source/552x414bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/20/80/5f/20805f6d-4033-9cf2-b89e-e9b8b1565f84/source/552x414bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/48/d8/6d/48d86ded-123e-0e5f-47ec-95fd69c19398/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/83/a6/e8/83a6e845-cf7b-4498-16bd-6a502b6d61fa/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/bb/cb/ad/bbcbadbc-85ba-14d5-2daa-928fee0af97b/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/bb/cb/ad/bbcbadbc-85ba-14d5-2daa-928fee0af97b/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/bb/cb/ad/bbcbadbc-85ba-14d5-2daa-928fee0af97b/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/rocket-chat-technologies-corp/id1148477217?uo=4", "advisories": [ "Infrequent/Mild Mature/Suggestive Themes" ], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Rocket.Chat", "languageCodesISO2A": [ "CS", "EN", "FR", "DE", "EL", "IT", "JA", "PL", "PT", "RU", "ES" ], "fileSizeBytes": "32531456", "sellerUrl": "https://rocket.chat", "contentAdvisoryRating": "9+", "userRatingCountForCurrentVersion": 2, "trackViewUrl": "https://itunes.apple.com/au/app/rocket-chat/id1148741252?mt=8&uo=4", "trackContentRating": "9+", "currentVersionReleaseDate": "2018-12-17T08:39:45Z", "releaseNotes": "- Italian language support;\n- All message components were rewritten, with a much better UI and reading experience;\n- Dynamic Type support on the messages list;\n- Lots of performance improvements on the messages list;\n- Many bug fixes;", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6005" ], "releaseDate": "2017-03-08T22:45:04Z", "currency": "AUD", "wrapperType": "software", "version": "3.2.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1148477217, "artistName": "Rocket.Chat Technologies Corp.", "genres": [ "Business", "Social Networking" ], "price": 0.00, "description": "Rocket.Chat is a free and open source team chat collaboration platform that allows users to communicate securely in real-time across devices on web, desktop or mobile and to customize their interface with a range of plugins, themes and integrations with other key software. \n\nBy opting for Rocket.Chat, users also benefit from free audio and video conferencing, guest access, screen and file sharing, LiveChat, LDAP Group Sync, two-factor authentication (2FA), E2E encryption, SSO, dozens of OAuth providers and unlimited users, guests, channels, messages, searches and files. Users can set up Rocket.Chat on cloud or by hosting their own servers on-premises.\n\nWith more than 700 developer-contributors and over 17k stars on Github, Rocket.Chat has the largest and most active community of chat developers in the open source communication sector.\n\nWhen you choose Rocket.Chat, you join a passionate community who help to grow the platform with us!\n\nKEY FEATURES:\n\n* Free Open Source Software\n* Hassle free MIT license\n* BYOS (bring your own server)\n* Multiple Rooms\n* Direct Messages\n* Private Groups\n* Public Channels\n* Desktop and Mobile Notifications\n* Edit and Delete Sent Messages\n* Mentions\n* Avatars\n* Markdown\n* Emojis\n* Choose between 3 themes: Light, Dark, Black \n* Sort conversations alphabetically or group by activity, unread or favourites\n* Transcripts / History\n* File Upload / Sharing\n* I18n - [Internationalization with Lingohub]\n* Hubot Friendly - [Hubot Integration Project]\n* Media Embeds\n* Link Previews\n* LDAP Authentication\n* REST-full APIs\n* Remote Locations Video Monitoring\n* Native Cross-Platform Desktop Application\n\nNEWS:\n\nFeatured on: Hacker News, Wired, Product Hunt, JavaScript Weekly, WWWhatsNew, ClasesDePeriodismo\n\nGET IT NOW:\n\n* Learn more and install: https://rocket.chat\n* ONE-CLICK-DEPLOYMENT – See instructions on our GitHub repository: https://github.com/RocketChat", "minimumOsVersion": "11.0", "primaryGenreName": "Business", "bundleId": "chat.rocket.ios", "trackName": "Rocket.Chat", "trackId": 1148741252, "sellerName": "Rocket.Chat Technologies Corp.", "averageUserRating": 3.5, "userRatingCount": 40 }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple62/v4/04/8f/b3/048fb35b-eeab-b98b-11cc-c6e16e2c5254/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple71/v4/52/cc/38/52cc38d2-5d87-f1c8-ea5e-ddfb4624f6d1/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple62/v4/8a/f3/4c/8af34cf6-dd73-dcb1-13cb-483822d5eb93/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple71/v4/b1/ba/c6/b1bac625-3b6e-fb5b-28ea-98025e612074/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple62/v4/49/c3/b6/49c3b6c4-570d-38dd-aa1f-d265c8abdf12/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/79/82/6c/79826cd6-c70e-a19e-871a-b348d3209c01/source/60x60bb.jpg", "artworkUrl512": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/79/82/6c/79826cd6-c70e-a19e-871a-b348d3209c01/source/512x512bb.jpg", "artworkUrl100": "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/79/82/6c/79826cd6-c70e-a19e-871a-b348d3209c01/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/truelancer/id1142111620?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Search Jobs & Hire Freelancer", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "26347520", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 2, "trackViewUrl": "https://itunes.apple.com/au/app/search-jobs-hire-freelancer/id1142111951?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-09-20T00:40:33Z", "releaseNotes": "- New Phone Verification .\n- Bug Fixes.\n- Performance Enhancement", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2016-10-04T17:01:13Z", "currency": "AUD", "wrapperType": "software", "version": "5.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1142111620, "artistName": "Truelancer", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Truelancer Mobile App is one of the Best Freelance App to Hire Top Freelancers.\nTruelancer is a good Job Search App to start your Career. \nYou can Search Jobs & Hire Freelancers for work.\n\nStart Earning doing Online Jobs & get Online work from home Jobs using Truelancer Mobile App.\n\nHire Developers, Designers, Virtual Assistants and get work. \n\nStart Earning by doing Part-time Jobs from Home. \n\nBecome a Freelancer & Get Online Freelance Jobs\n\nGet the freelancer app on Iphone, this is one of the trending freelance apps. \n\nUsing Truelancer app you can get curated gigs like fiverr app.\n\n\nLooking for Jobs?\n- Create a Free Account & Complete your Profile.\n- Search Jobs / Freelance Projects and Apply.\n- Get Freelance Jobs & Earn.\n- Payment Security using Safe Deposit.\n- List Services & Sell 24x7\n\nGet new Freelance Work Opportunity\n\nLooking to Hire?\n- Post a Project, Its Free!\n- Buy great value professional Services.\n- Find professional Freelancers.\n- Pay only once Satisfied.\n- Shortlist and Curate your virtual workforce.\n- Have a Creative Design Project? Post a Contest (Logo Design, Business Card Design, Banner Design etc)\n- Buy Gigs\n\nFacing Issue or Want to share feedback - write us at mobile@truelancer.com\n\nOutsource work and save money.\n\nHere you can use free resume builder to make a free resume. You have best resume builder app when you use Truelancer app.\n\nEmployers can transfer money to freelancers and use Truelancer to send money online.\n\nUse Search Jobs & Hire Freelancer Mobile App by Truelancer for free for as long as you want. Upgrade to a paid plan for added benefits like increased earning, more jobs, unlimited skills and verified profile.\n\nStudents and Freshers can find Online Jobs and many part time jobs and earn money online.\n\nLinkedIn Recruiter can Hire Talented & Skilled Freelancers here on Truelancer Mobile App.\n\nFreelancers can Earn Money and withdraw it using TransferWise, Paypal, Payoneer, Paytm, Skrill, Payza, Bank Account and xoom.\n\nYou can use Udacity to Learn Programming, Coursera to learn online courses and Truelancer to find work and earn money\n\nIf you are using Slack, Trello, Asana, HipChat, Prezi to manage your Teams you should use Truelancer to Hire & work with Freelancers and Remote Work Force.\n\n\nTruelancer ® , Truelancer.com ® are Copyright © of Truelancer", "minimumOsVersion": "8.1", "primaryGenreName": "Business", "bundleId": "com.truelancer.app", "trackName": "Search Jobs & Hire Freelancer", "trackId": 1142111951, "sellerName": "TRUELANCER INTERNET PRIVATE LIMITED" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple22/v4/34/a7/4b/34a74bbe-8eb6-b6a8-5f0b-2b04dd20a57e/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple62/v4/ff/1d/5c/ff1d5c13-fdc6-7e05-0981-78068652f972/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple41/v4/31/5f/bf/315fbf27-de45-f083-523b-b186330a5147/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple22/v4/22/14/6e/22146e78-db33-39b6-2eee-18c2bb5490b6/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple62/v4/0c/8a/76/0c8a7672-604d-bd9a-718c-c7d169076dfe/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple62/v4/0d/84/46/0d844666-8329-0c5a-fdbf-44188054c4a3/source/576x768bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple125/v4/4d/0f/c4/4d0fc4cb-79ba-e894-c4ad-00ecb3efd08c/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple125/v4/4d/0f/c4/4d0fc4cb-79ba-e894-c4ad-00ecb3efd08c/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple125/v4/4d/0f/c4/4d0fc4cb-79ba-e894-c4ad-00ecb3efd08c/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/mubiquo/id353965340?uo=4", "advisories": [ "Unrestricted Web Access" ], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Loopy Messenger - Professional", "languageCodesISO2A": [ "AR", "CA", "ZH", "NL", "EN", "FR", "DE", "HI", "HU", "IT", "JA", "KO", "PT", "RU", "ES" ], "fileSizeBytes": "145562624", "sellerUrl": "http://loopy.cc", "contentAdvisoryRating": "17+", "trackViewUrl": "https://itunes.apple.com/au/app/loopy-messenger-professional/id860456941?mt=8&uo=4", "trackContentRating": "17+", "currentVersionReleaseDate": "2018-07-11T10:35:56Z", "releaseNotes": "- Loopy Store. Single section with all the Loopy features you can buy under Preferences menu.\n- Major UI/UX bugfixing. Fixed issues related to Stickers and Gestures.", "primaryGenreId": 6005, "formattedPrice": "Free", "genreIds": [ "6005", "6007" ], "releaseDate": "2014-04-24T14:48:09Z", "currency": "AUD", "wrapperType": "software", "version": "6.1", "isVppDeviceBasedLicensingEnabled": true, "artistId": 353965340, "artistName": "MUBIQUO", "genres": [ "Social Networking", "Productivity" ], "price": 0.00, "description": "With +2M downloads Loopy is the most downloaded Telegram client on iPhone after Telegram. Loopy's mission is to create the best possible Telegram experience for professional communications.\n\nOver the superfast, multi-device and secure Telegram protocol for text messages and _Calls_ Loopy empowers teams and users with true mobile real-time collaboration.\n\nTeams text information that normally becomes ephemeral. In contrast to standard messengers, Loopy enables teams to make documents out of any conversation converting information into corporate knowledge.\n\nShare anything instantly, notes, to-dos, tasks, goals, documents, video and audio messages, open compressed documents (.ZIP, .RAR,...), even you can check email coming from your chat group members without quiting the app !\n\nSafe time with color tag chats and set secure fingerprint access among other security features like secret chats.\n\nLoopy has been the first messenger to provide:\n\n- Email client\n- .ZIP & .RAR support\n- Face/Fingerprint ID security\n- Audio Player (any audio format like ogg, flac, opus...)\n- One-Tap-Front-Cam Video Messages\n- Realtime Collaboration Lists\n- Realtime Collaboration Memos\n- File Sharing Container\n- iCloud Drive\n- Color Tags\n- Ribbon based access to Documents and Media views for top ease of use.\n\nIMPORTANT, IF YOU USE TELEGRAM, YOU DON'T HAVE TO RE-INVITE ANYBODY. YOU WILL FIND YOUR TELEGRAM CONTACTS AND CHATS IN Loopy !!\n\nGroup chats with up to 10000 members, sharing videos and documents of any type, send multiple photos from the web, and forward any media you receive in an instant. All your messages are in the cloud, so you can easily access them from ***any of your devices***.\n\nFor those interested in maximum privacy, Face/Fingerprint ID and Secret Chats are supported, featuring end-to-end encryption to ensure that a message can only be read by its intended recipient. When it comes to Secret Chats, nothing is logged on Telegram servers and you can automatically program the messages to self-destruct from both devices so there is never any record of it. \n\n*** Why Switch to Loopy ? *** \n\nYou get all the advantages of the official Telegram client + Value Added functionality that lets you create content from conversations (Memos, Lists...), open .ZIP or .RAR compressed files, reproduce formats like Ogg Vorbis, Flac or Opus among many other features. \n\nTelegram protocol is: \n\nFAST: The fastest messaging system on the market because it uses a distributed infrastructure with data centers positioned around the globe to connect users to the closest possible server. \n\nSECURE: It is Telegram mission to provide the best security among mass messengers. Telegram is based on the MTProto protocol that is built upon time-tested algorithms to make security compatible with high speed delivery and reliability on weak connections.\n\nCLOUD STORAGE: Seamlessly sync across all your devices, so you can always securely access your data. Your message history is stored for free in the Telegram cloud. Never lose your data again!\n\nGROUP CHAT & SHARING: You can form large group chats and broadcast lists of up to 10000 members, quickly share large videos, documents (.doc, .ppt, .zip, etc.), and send an unlimited amount of photos to your friends. \n\nRELIABLE: Built to deliver your messages in the minimum bytes possible, this is the most reliable messaging system ever made. It works even on the weakest mobile connections. \n\nPRIVACY: Telegram takes your privacy very seriously and will never give third parties access to your data!\n___\nLoopy Messenger might contain ads in your chatlist that you can deactivate.", "minimumOsVersion": "9.3.5", "primaryGenreName": "Social Networking", "bundleId": "com.mubiquo.telegram", "trackName": "Loopy Messenger - Professional", "trackId": 860456941, "sellerName": "MUBIQUO APPS SL" }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/cb/9e/0d/cb9e0d26-9962-bb9b-51b6-e39988c78690/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/fd/4f/46/fd4f46e3-e974-429d-885d-89be815145d3/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/a0/0a/16/a00a16c1-96ea-76c7-0fb6-05c3bb9b18fe/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/98/7f/a0/987fa0d6-7927-25d8-663d-f19c5a995820/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/c3/e4/0a/c3e40a73-91ec-5ed7-34e9-d8dc6452ed3e/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/56/88/70/568870f6-b33f-17cc-1f5e-de71db0d9c3f/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/56/88/70/568870f6-b33f-17cc-1f5e-de71db0d9c3f/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/56/88/70/568870f6-b33f-17cc-1f5e-de71db0d9c3f/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/storymatik-software-unipessoal-lda/id891398405?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "Storyo: Pic jointer for videos", "languageCodesISO2A": [ "EN", "FR", "DE", "IT", "PT", "RU", "ES" ], "fileSizeBytes": "139730944", "sellerUrl": "http://storyoapp.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/storyo-pic-jointer-for-videos/id891398402?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-12-06T01:03:18Z", "releaseNotes": "2.2 version is here and contains a bunch of new features.\n\nWe heard your feedback, and Storyo 2.2 brings new themes, Google Photos integration, and more editing options.\nWe also fixed bugs and improved the loading speed and automatic selection.\n\nWe hope that Storyo helps you share video memories of the best moments of your life.\n\nNew:\n+ Two new styles to choose from: \"Book\" and \"Takes\" are the brand new styles available to create unforgettable memories. Both styles require iOS11 and \"Takes\" is only available to iPhone 6s or newer models users.\n+ Google Photos integration: You can now connect your Google Photos account to add your backed up photos to Storyo.\n+ Edit options: Now you can add Title slides to better tell your story. We have also added fine-tuning tools that help you reposition photos and easily add more photos to your story. Facebook and Shutterstock resources are now easier to add.", "primaryGenreId": 6008, "formattedPrice": "Free", "genreIds": [ "6008", "6016" ], "releaseDate": "2014-07-03T09:15:14Z", "currency": "AUD", "wrapperType": "software", "version": "2.2.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 891398405, "artistName": "Storymatik Software, Unipessoal, Lda", "genres": [ "Photo & Video", "Entertainment" ], "price": 0.00, "description": "An amazing visual storyteller for documenting unforgettable moments.\n \nCreate memorable group videos in seconds by inviting friends and family to share their photos through Storyo and watch the magic unfold.\n \nCongrats 5*\n“Super easy for birthdays and weddings, to create recap films to share on social media, LIKE!! Congrats, keep up”\n \nAwesome 5*\n“This Animoto type of app is amazing. This app turns your photos and memories into greatest journals of your life. easy and fast to create videos just we have to select a timeframe.so simply it is fun and worth the download”\n \nFeatured on The Next Web: “Accurate and meaningful video stories made in seconds.”\nFeatured on iPhone Ticker: “Better than Apple “Memories”\nFeatured on Apps400: “The app is amazingly wonderful since it turns photos into fantastic simple videos which can be edited by the user as they please.”\n \nStoryo gathers the best photos from everyone who shared in the fun, unlocks amazing details, and pulls together related Facebook posts and facts to create a compelling group video memory – ready for everyone to edit or enjoy as is.\n \nIf you like sharing video collages, travel videos, and journals, you’ll love telling the bigger story with Storyo.\n \nHere’s why:\n \n--- IT’S EVEN EASIER TO TURN PHOTOS INTO VIDEO MEMORIES ---\n \nCREATE your own video memory with 3 simple taps. Just select the photos you like from start to finish and press PLAY. Storyo turns your favorite memories into videos, automatically, from your collection of photos.\n \nChoose between your most cherished memories - a friends’ night out, a two-week vacation or even an entire year - and leave the rest to Storyo: In just a few seconds, you’ll have a beautiful ready-made Storyo with titles, captions, and maps to bring them come to life.\n \nIf you are not completely happy with the result you can now fully edit your story by adding more photos, maps, facebook posts and many others.\n \n--- GROUP VIDEO MEMORIES ---\n \nHad an amazing time with friends and now thousands of photos dumped on that Whatsapp group? Only Storyo gives you the ability to create unique group video memories with friends and family in an easy way.\n \nBy joining photos and all perspectives from everyone that shared one moment, Storyo helps you deliver a better story of your life most magical moments.\n \n--- HOW GROUP STORYTELLING WORKS ---\n \nEasily invite your friends to be a part of your story.\n \nOnce they accept, Storyo gathers everyone’s best photos from that really great time and incorporates them into your video memory.\n \nThe more perspectives you include, the easier it is to recapture the best of any experience.\n \n--- DISCOVER NEW STORIES IN YOUR CAMERA ROLL ---\n \nSome of our best moments have been long forgotten on our Camera Roll. Based on photo timelines, Storyo analyses your camera roll to find the stories behind your photos.\n \nIt recognizes that amazing trip, a holiday gathering, a fantastic evening, or the old photos of your pet.\n \nSo every time you open Storyo, you’ll find a collection of suggested memories under the section ‘Timeline’.\n \n--- FACEBOOK POSTS AND WEATHER INFO ---\n \nLog in with Facebook and Storyo will mix in posts that best express the emotions of that day. And to remind you how hot, cold or perfect it was, Storyo can include the weather.\n \n--- EXCLUSIVE TO 2.0 - SHUTTERSTOCK VIDEOS ---\n \nAdd a professional touch to your stories with stunning videos from a specially curated collection by Shutterstock.\nStoryo 2.0 automatically suggests video clips that seem best suited. Pick the one you like best and the next thing you know, it’s a part of your video.\n \n------\nHave feedback?\n \nConnect with us:\n> E-mail: hello@storyoapp.com\n> Facebook: https://www.facebook.com/storyoapp\n> Twitter: https://twitter.com/storyoapp\n> Website: http://storyoapp.com/", "minimumOsVersion": "10.0", "primaryGenreName": "Photo & Video", "bundleId": "com.StoryMatik.Storyo", "trackName": "Storyo: Pic jointer for videos", "trackId": 891398402, "sellerName": "STORYMATIK SOFTWARE, UNIPESSOAL, LDA" }, { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple111/v4/7f/7a/f3/7f7af305-5939-c073-17e1-eb9d840719d8/source/406x228bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple111/v4/6b/09/4c/6b094cc5-39f5-d378-1422-8cab7a8a5e6e/source/406x228bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple111/v4/9e/38/92/9e389208-1588-bc8e-642b-29b8196139c3/source/406x228bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple82/v4/f2/5b/09/f25b0934-ebac-f3e5-35f1-7a41229bdcbd/source/406x228bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple111/v4/02/39/38/02393865-4b44-2593-f1ff-5eb72d186dc7/source/406x228bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple122/v4/8a/35/ff/8a35ff54-df04-addf-ff8c-ba394a24b202/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple122/v4/0a/35/c1/0a35c1e5-2f84-7251-19f8-e9249eb32b22/source/552x414bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple122/v4/01/ba/28/01ba28a5-ede8-44ee-514c-2d2e5b7f9bf6/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple122/v4/98/57/f4/9857f412-7564-913f-2e7d-fcea148876f0/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple122/v4/d1/45/6e/d1456e8b-0465-eaec-5541-66f0210db3e1/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple111/v4/1b/99/3a/1b993ab0-2f82-ef23-3c3f-9a4cdad9796e/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple111/v4/1b/99/3a/1b993ab0-2f82-ef23-3c3f-9a4cdad9796e/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple111/v4/1b/99/3a/1b993ab0-2f82-ef23-3c3f-9a4cdad9796e/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/blair-barnes/id1130836011?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone3GS-iPhone-3GS", "iPhone4-iPhone4", "iPodTouchFourthGen-iPodTouchFourthGen", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Hawaii Shopaholic —Shopping, Dress Up & Makeover", "languageCodesISO2A": [ "CS", "NL", "EN", "FR", "DE", "IT", "JA", "KO", "PL", "PT", "RU", "ZH", "ES", "SV", "ZH", "TR" ], "fileSizeBytes": "47202304", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/hawaii-shopaholic-shopping-dress-up-makeover/id1143396573?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-01-04T08:43:23Z", "releaseNotes": "Performance improved.", "primaryGenreId": 6014, "formattedPrice": "Free", "genreIds": [ "6014", "7014", "7015", "6016" ], "releaseDate": "2016-09-07T14:05:00Z", "currency": "AUD", "wrapperType": "software", "version": "1.0.3", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1130836011, "artistName": "Blair Barnes", "genres": [ "Games", "Role-Playing", "Simulation", "Entertainment" ], "price": 0.00, "description": "Stroll along a beautiful Hawaii beach and browse the shops for clothes, hairstyles and accessories! You have a limited budget but don't worry, if you run out of money you can always earn more!\nBrowse these bodacious beach-side boutiques! You'll be sayin' \"Aloha\" in no time!", "minimumOsVersion": "6.0", "primaryGenreName": "Games", "bundleId": "424.shopaholichawaii", "trackName": "Hawaii Shopaholic —Shopping, Dress Up & Makeover", "trackId": 1143396573, "sellerName": "Blair Barnes" }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple3/v4/2e/bf/e3/2ebfe32d-b0dc-52fa-1039-760142245a9c/source/640x1136bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple3/v4/4f/d0/4f/4fd04fe5-818d-66c1-b57c-45767a8ff9de/source/640x1136bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple1/v4/6c/c3/71/6cc37155-daaa-19bc-761f-07e857aa01f1/source/640x1136bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/39/6f/c1/396fc1e1-0e93-11ad-5cb1-69049b06722f/source/640x1136bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple1/v4/e2/5c/15/e25c1548-4dfe-721c-97a9-2be0658d8111/source/640x1136bb.jpg" ], "ipadScreenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple1/v4/62/54/3c/62543cf6-ca64-b346-9a5d-2ddb846f703a/source/360x480bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple3/v4/71/b7/0c/71b70cb7-93ea-f6b9-46ea-7c80cddc13b1/source/360x480bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple1/v4/d5/af/44/d5af44f1-8ce6-f6dc-5cbd-81625a3467d0/source/360x480bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple1/v4/f0/80/e7/f080e7ee-bdf9-5566-f359-86ee9a1771e1/source/360x480bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple3/v4/37/3c/c8/373cc802-c1ff-0c48-75e0-688a35e06b3f/source/360x480bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/c8/5d/51/c85d5131-527a-80c9-3a16-793d5cdd74c5/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/c8/5d/51/c85d5131-527a-80c9-3a16-793d5cdd74c5/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple5/v4/c8/5d/51/c85d5131-527a-80c9-3a16-793d5cdd74c5/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/wu-xiaopeng/id918018655?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone3GS-iPhone-3GS", "iPadWifi-iPadWifi", "iPad3G-iPad3G", "iPodTouchThirdGen-iPodTouchThirdGen", "iPhone4-iPhone4", "iPodTouchFourthGen-iPodTouchFourthGen", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 2.5, "trackCensoredName": "Animated Gif Camera Lite - Whats Funny yik Animated Gifs Creator App", "languageCodesISO2A": [ "NL", "EN", "FR", "DE", "IT", "JA", "KO", "ZH", "ES", "ZH" ], "fileSizeBytes": "6000640", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 3, "trackViewUrl": "https://itunes.apple.com/au/app/animated-gif-camera-lite-whats-funny-yik-animated-gifs/id960663264?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2015-05-11T23:18:28Z", "releaseNotes": "- Bug Fix", "primaryGenreId": 6007, "formattedPrice": "Free", "genreIds": [ "6007", "6002" ], "releaseDate": "2015-03-06T08:32:49Z", "currency": "AUD", "wrapperType": "software", "version": "2.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 918018655, "artistName": "Wu Xiaopeng", "genres": [ "Productivity", "Utilities" ], "price": 0.00, "description": "You will love GifMaker !!\n\nCreate beautiful animated gif photos from videos or photo album with the app and instantly share to the world, your friends or just yourself. \n\n\n********* Features *********\n- Make GIFs from videos\n- Make GIFs from photos in albums\n- Adjust speed of the gif photo\n- Save GIFs automated\n\nIf you are looking for a great GIF making tool, check out our \"GifMaker\" app which makes awesome gifs and shares to your friends !!", "minimumOsVersion": "5.1.1", "primaryGenreName": "Productivity", "bundleId": "com.xiaopeng.GifFree", "trackName": "Animated Gif Camera Lite - Whats Funny yik Animated Gifs Creator App", "trackId": 960663264, "sellerName": "Wu Xiaopeng" }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple20/v4/95/b4/12/95b412fe-1d9f-dd56-021a-28d8947817e1/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple18/v4/ab/dc/d1/abdcd151-533d-cfb5-cf00-4ab9025d9e5a/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple18/v4/91/7a/b3/917ab390-12ac-dffd-ecc5-d83830cfa261/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple18/v4/91/7a/b3/917ab390-12ac-dffd-ecc5-d83830cfa261/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple18/v4/91/7a/b3/917ab390-12ac-dffd-ecc5-d83830cfa261/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/hironori-tsumuraya/id1061365194?uo=4", "advisories": [ "Unrestricted Web Access" ], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "Unread Checker for Slack", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "30683136", "contentAdvisoryRating": "17+", "trackViewUrl": "https://itunes.apple.com/au/app/unread-checker-for-slack/id1134597935?mt=8&uo=4", "trackContentRating": "17+", "currentVersionReleaseDate": "2016-07-19T00:54:19Z", "releaseNotes": "This update is signed with Apple’s latest signing certificate. No new features are included.", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2016-07-19T00:54:19Z", "currency": "AUD", "wrapperType": "software", "version": "1.0.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1061365194, "artistName": "Hironori Tsumuraya", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "\"Unread Checker for Slack\" is fast and easy slack client! \n\nNo need switch teams. \nNo need switch channels.\nYou can check slack messages at one list as timeline!", "minimumOsVersion": "9.0", "primaryGenreName": "Business", "bundleId": "tsumuchan.slack-unread-checker", "trackName": "Unread Checker for Slack", "trackId": 1134597935, "sellerName": "Hironori Tsumuraya" }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple/v4/e5/69/1d/e5691d09-7073-c34d-1b4d-e0d9132a9efa/source/320x180bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple4/v4/29/f3/62/29f362ee-e0e0-cb21-7150-f046de80dfb8/source/320x180bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple4/v4/61/de/34/61de345c-3938-ac43-c6b2-694a56d3a05a/source/320x180bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple4/v4/41/a8/16/41a816dd-316e-e265-3241-4598b34b2f50/source/480x360bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple/v4/cc/da/ff/ccdaff7d-ccdc-6b8a-5d5d-6b8c303139ce/source/480x360bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple/v4/d6/17/a8/d617a851-4756-34a3-e6bd-df772bfdb6db/source/480x360bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple/v4/c0/0f/cf/c00fcfe8-c784-b8c2-0229-b2c02c80cc40/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple/v4/c0/0f/cf/c00fcfe8-c784-b8c2-0229-b2c02c80cc40/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple/v4/c0/0f/cf/c00fcfe8-c784-b8c2-0229-b2c02c80cc40/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/melanie-thomas/id775584887?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone3GS-iPhone-3GS", "iPadWifi-iPadWifi", "iPad3G-iPad3G", "iPodTouchThirdGen-iPodTouchThirdGen", "iPhone4-iPhone4", "iPodTouchFourthGen-iPodTouchFourthGen", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "No Homework!", "languageCodesISO2A": [ "CS", "NL", "EN", "FR", "DE", "IT", "JA", "KO", "PL", "PT", "RU", "ZH", "ES", "SV", "ZH", "TR" ], "fileSizeBytes": "14389248", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 2, "trackViewUrl": "https://itunes.apple.com/au/app/no-homework/id896097193?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2014-07-13T04:51:43Z", "primaryGenreId": 6014, "formattedPrice": "Free", "genreIds": [ "6014", "7009", "7014" ], "releaseDate": "2014-07-13T04:51:43Z", "currency": "AUD", "wrapperType": "software", "version": "1.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 775584887, "artistName": "melanie thomas", "genres": [ "Games", "Family", "Role-Playing" ], "price": 0.00, "description": "It's summer holiday now! James is supposed to be doing his homework! But he doesn't want to--he wants to goof off! And besides, how could his mother be against her watering her flowers, and taking care of her other responsibilities? It's not all about homework, you know?", "minimumOsVersion": "4.3", "primaryGenreName": "Games", "bundleId": "com.melaniethomas.NoHomework", "trackName": "No Homework!", "trackId": 896097193, "sellerName": "melanie thomas" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/bd/6b/83/bd6b8301-cc32-59a6-2d4a-580095cd8b34/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/07/92/5a/07925af2-4d26-cf3f-dc89-f82e5665313b/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/9f/c9/92/9fc9928c-da8b-c7fb-9a9f-ca3a1f5f10db/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/16/64/ee/1664eed2-8b23-f424-3eb2-80f4304697fa/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/da/06/e1/da06e18a-82a5-a343-34c9-b5c67130bbb4/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/c2/7e/33/c27e33ac-8479-07c3-f3f5-5778460d3596/source/552x414bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/ca/9c/c7/ca9cc781-923d-37d2-aaf8-43cf0435c9ce/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/af/28/93/af289361-0d0a-5377-94cd-55fa738fb65d/source/552x414bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/08/83/16/08831622-b575-ca2d-6845-216529e8696b/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/49/5f/c4/495fc413-ec22-328a-770a-c11d56c3d923/source/60x60bb.jpg", "artworkUrl512": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/49/5f/c4/495fc413-ec22-328a-770a-c11d56c3d923/source/512x512bb.jpg", "artworkUrl100": "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/49/5f/c4/495fc413-ec22-328a-770a-c11d56c3d923/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/broadsoft/id301242750?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "trackCensoredName": "Team-One", "languageCodesISO2A": [ "NL", "EN", "FR", "DE", "IT", "JA", "KO", "ZH", "ES" ], "fileSizeBytes": "122109952", "sellerUrl": "http://www.team-one.com", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/team-one/id721334515?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-11-21T00:42:27Z", "releaseNotes": "• Bug fixes and significant performance enhancements", "primaryGenreId": 6000, "formattedPrice": "Free", "genreIds": [ "6000", "6007" ], "releaseDate": "2013-10-15T10:26:31Z", "currency": "AUD", "wrapperType": "software", "version": "4.0.1", "isVppDeviceBasedLicensingEnabled": true, "artistId": 301242750, "artistName": "BroadSoft", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Team-One is more than messaging; it’s teamwork made simple. \n\nOf course, you can chat but there’s so much more. Say goodbye to endless switching between apps and hello to a simpler way to work. Team-One’s persistent workspace model puts everything your teams need for better collaboration in one place. \n\u2028\nWith Team-One, easily manage all your work in one place so you can be productive from anywhere, anytime, and on any device to stay in sync with your teams. Team-One supports your day from beginning to end.\n\nKEY FEATURES INCLUDE \n\t• Group and private chat \n\t• Persistent workspaces\n\t• Click-to-call*\n\t• Task management \n\t• Easy drag & drop file sharing \n\t• Note taking\n\t• Live meetings & screen sharing \n\t• Powerful search \n\t• Email & calendar integrations \n\t• APIs & Bots\n\t• Integrations to many popular business apps including Dropbox, Google Drive, Salesforce, Jira, Marketo, Zendesk and more to assure that you have all the tools you need to collaborate \n\n*Requires separately purchased services \n\u2028\nExperience how Team-One can make teamwork simple. \n\u2028\n•• Highlights •• \nAwarded by Frost & Sullivan in 2014 for Product Innovation and again in 2016 for Product Leadership in the Mobile Employee Collaboration Solutions space.", "minimumOsVersion": "10.0", "primaryGenreName": "Business", "bundleId": "net.intellinote.app.v2", "trackName": "Team-One", "trackId": 721334515, "sellerName": "BroadSoft" }, { "screenshotUrls": [ "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/06/0d/59/060d59c4-58fe-a0c8-8998-afc676fe8b2a/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple128/v4/04/8d/61/048d61a3-d3a7-7579-affa-c206be86e7d2/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/b6/04/8c/b6048cdf-b6ea-993c-fb9d-21ef7fd33e5b/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/b0/4b/35/b04b355d-8878-ce6a-ec54-4f47a9e8e8eb/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/39/c5/12/39c512d1-6ced-fbe5-7f50-b62f067caba8/source/392x696bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/15/8d/0b/158d0b62-57d1-1a98-ea05-0c7b58b42b42/source/552x414bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/29/52/59/29525966-13de-671e-d14b-33c1e1ec97e4/source/60x60bb.jpg", "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/29/52/59/29525966-13de-671e-d14b-33c1e1ec97e4/source/512x512bb.jpg", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/29/52/59/29525966-13de-671e-d14b-33c1e1ec97e4/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/constflash/id883373065?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Widgets for Slack", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "8570880", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/widgets-for-slack/id1177337550?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-10-27T00:04:27Z", "releaseNotes": "bug fixed", "primaryGenreId": 6007, "formattedPrice": "$5.99", "genreIds": [ "6007", "6000" ], "releaseDate": "2016-12-01T07:26:59Z", "currency": "AUD", "wrapperType": "software", "version": "1.5", "isVppDeviceBasedLicensingEnabled": true, "artistId": 883373065, "artistName": "ConstFlash", "genres": [ "Productivity", "Business" ], "price": 5.99, "description": "Now you can access all important information about your Slack's teams from your lock screen or any app. Add Widgets for Slack to the Notification Center - and you will get quick access to Slack's functions.\n\nWith this app you can see all unread messages, channels and people. \nEasy navigation through sections will provide you quick access to helpful information.\n\nYou can jump directly from this widget to any channel of Slack's official app.\n\nYou can add up to 6 teams and control your business with ease.", "minimumOsVersion": "10.0", "primaryGenreName": "Productivity", "bundleId": "com.constflash.widgetslack", "trackName": "Widgets for Slack", "trackId": 1177337550, "sellerName": "ConstFlash LTD" }, { "screenshotUrls": [ "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/04/84/95/048495b3-535b-dde1-3317-2f4ee60fd248/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/dc/3f/6c/dc3f6c78-c925-2903-769e-3914a8ab53f7/source/392x696bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple128/v4/e4/f3/cb/e4f3cb92-cd10-df5c-beb8-e087f6180ce0/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/4b/b3/00/4bb30029-2c5f-88cc-6688-fc380c22f649/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/4b/b3/00/4bb30029-2c5f-88cc-6688-fc380c22f649/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple118/v4/4b/b3/00/4bb30029-2c5f-88cc-6688-fc380c22f649/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/sharath-prabhal/id1054565696?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5-iPhone5", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "Status Switcher for Slack", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "18738176", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/status-switcher-for-slack/id1234039479?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-04-08T20:18:41Z", "releaseNotes": "Ability to disable custom emojis:\nIf your organization contains a massive number(50k+) of custom emojis, the app may not be able to handle it. In that case, please disable custom emojis using the hamburger menu in the top left.", "primaryGenreId": 6002, "formattedPrice": "Free", "genreIds": [ "6002", "6005" ], "releaseDate": "2017-05-12T04:00:11Z", "currency": "AUD", "wrapperType": "software", "version": "1.0.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 1054565696, "artistName": "Sharath Prabhal", "genres": [ "Utilities", "Social Networking" ], "price": 0.00, "description": "Do you use Slack's new status feature? Ever wondered how many taps it actually takes to update your status? It's 7, and it takes around 30 seconds! Now, you can update your status with just 1 tap, without even unlocking your phone! Perfect for anyone who uses Slack on the go. \n\n* Sign in with Slack\n* Setup your top four status status message along with emojis\n* Update, view and clear current status message from iOS Today Widget\n* Set status without even unlocking your phone!\n\nIf your organization contains a massive number(50k+) of custom emojis, the app may not be able to handle it. In that case, please disable custom emojis using the hamburger menu in the top left.", "minimumOsVersion": "10.0", "primaryGenreName": "Utilities", "bundleId": "com.sharath.slackStatusSwitcher", "trackName": "Status Switcher for Slack", "trackId": 1234039479, "sellerName": "Sharath Prabhal" }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/ef/77/09/ef770995-26b8-51be-b1d1-bbfc73fd0c18/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/26/78/e9/2678e94f-c8dd-f6bc-6de2-f508e5a9fa5d/source/392x696bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple118/v4/11/aa/96/11aa96d4-9e2b-2bea-5d0b-b207e0c9bb1a/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/0d/e9/4c/0de94ca7-ca80-283d-b439-0c38f9b96037/source/392x696bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/57/ec/a0/57eca0a7-eb75-7a77-692e-0dbabe81a03d/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/95/d9/22/95d92242-4259-5a40-3a85-2cf642a40c5e/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/95/d9/22/95d92242-4259-5a40-3a85-2cf642a40c5e/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple128/v4/95/d9/22/95d92242-4259-5a40-3a85-2cf642a40c5e/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/riding-horses-with-kathy-slack-llc/id573318806?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "Riding Horses", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "26295296", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/riding-horses/id573318803?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2017-08-14T15:02:21Z", "releaseNotes": "• New Hunter / Jumper lesson collection\n• Bug fixes and performance enhancements", "primaryGenreId": 6004, "formattedPrice": "Free", "genreIds": [ "6004", "6017" ], "releaseDate": "2013-04-02T04:33:24Z", "currency": "AUD", "wrapperType": "software", "version": "1.2", "isVppDeviceBasedLicensingEnabled": true, "artistId": 573318806, "artistName": "Riding Horses with Kathy Slack, LLC", "genres": [ "Sports", "Education" ], "price": 0.00, "description": "Riding Horses with Kathy Slack puts your trainer in your pocket.\n\nRHWKS is a learn-while-you ride collection of Western horsemanship lessons. Each lesson goes with you to your arena and focuses on fundamental skill sets that every rider can master. Riders can purchase lessons based on their needs to truly customize their experience and training. \n\nRide along to Kathy’s instruction using your iPhone or iPod and you’ll have access to this unique and successful teaching method. With a choice of audio/video lessons or audio with still photography, riders are given the tools they need to improve their skills and confidence in their own arena or backyard. \n\nKathy Slack is the owner of two riding facilities in Austin, Texas where she teaches, trains and rides. Since 1972, Kathy’s teaching enthusiasm and talent has produced a consistent stream of successful riders, many who have won national and world titles. You can find Kathy serving as a board member of the USTPA, competing and coaching at events around the country, or on the web at www.ridinghorses.com\n\nStart Learning Today!\n\nApplication Features:\n• Video lessons\n• Audio only lessons plus still photography for easy reference\n• Ability to play and pause lessons\n• Audio controls available from lock screen for ease of use during riding\n• Audio lessons are broken up into Objectives for convenient review\n• In-App purchase options allow users to customize their collection\n• High quality video and photography taken in Austin, Texas\n\nOver 50,000 people have already discovered Kathy’s unique and successful training methods. Give Riding Horses with Kathy Slack a try today and enjoy the ease and flexibility of having your trainer in your pocket.", "minimumOsVersion": "9.0", "primaryGenreName": "Sports", "bundleId": "com.ridinghorses.ridinghorses", "trackName": "Riding Horses", "trackId": 573318803, "sellerName": "Riding Horses with Kathy Slack LLC" }, { "screenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/9d/ee/4a/9dee4ab5-3d3a-8a0c-96e1-c503925c39e5/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/e4/16/80/e4168008-db95-9072-de51-f5c0a541d7dd/source/392x696bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/dd/f7/b7/ddf7b761-8924-baa5-51ef-a9bc11e93530/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/8e/e8/13/8ee81383-603b-17ee-fe7d-2ac9ed8a8a6d/source/392x696bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple118/v4/be/dd/da/beddda53-03a8-4e2b-0637-6af639445876/source/392x696bb.jpg" ], "ipadScreenshotUrls": [], "appletvScreenshotUrls": [], "artworkUrl60": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/34/4c/d9/344cd995-97da-e82b-3ee4-8c666e09598b/source/60x60bb.jpg", "artworkUrl512": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/34/4c/d9/344cd995-97da-e82b-3ee4-8c666e09598b/source/512x512bb.jpg", "artworkUrl100": "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/34/4c/d9/344cd995-97da-e82b-3ee4-8c666e09598b/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/blowbend-jp/id535422149?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [], "trackCensoredName": "Guitar Tuner TN-1G", "languageCodesISO2A": [ "EN", "DE", "ID", "IT", "JA", "ES" ], "fileSizeBytes": "76964864", "contentAdvisoryRating": "4+", "trackViewUrl": "https://itunes.apple.com/au/app/guitar-tuner-tn-1g/id754125890?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2018-12-12T05:06:56Z", "releaseNotes": "• Fixed an issue when launching the app.\nI value your feedback, so if you have something to share then email me at tn1g.i@blowbend.jp. You can find a link to the mail in help.", "primaryGenreId": 6011, "formattedPrice": "Free", "genreIds": [ "6011", "6002" ], "releaseDate": "2013-12-01T13:53:56Z", "currency": "AUD", "wrapperType": "software", "version": "3.1.4", "isVppDeviceBasedLicensingEnabled": true, "artistId": 535422149, "artistName": "Blowbend.jp", "genres": [ "Music", "Utilities" ], "price": 0.00, "description": "It's very easy! Fine tuning for all guitarists.\n\nTN-1G is tuning meter only for guitarists.\nIt detects the pitch of notes that picked up by a microphone, and it displays the difference from the right pitch. It's easy to use, but high-precision, high-performance, and supports alternative tuning sets of more than 50.\n\n◆It's really easy to use.\n\n◆High-precision, high-performance.\n\n◆Supported tuning-sets:\n\n ・Standard, Half Step Down, Full Step Down, 1&1/2 Steps Down, Drop-D, Drop-D(half step down), Double Drop-D, Drop-G, Drop-C, Drop-C&G, Drop-C#, Drop-C(another set), Drop-B, Drop-A#, Drop-A, Open D, Open E, Open E7, Open Dm, Open Em, Open Em7, Open Dmaj7, Open Dadd9, Open G, Open A, Open A(another set), Open Gm, Open Am, Open Am7, Open G7, Open Gmaj7, G6, Dsus(DADGAD), Open C, C6, Open F, F6, Taro Patch, G Wahine, Leonard's C, C Major(Atta's C), Keola's C, Mauna Loa, Old Mauna Loa, Dmaj7 Wahine, F Wahine, Double Slack F, Modal G(Gsus), Open C(another set), Open C#, Open Cm, C Spanish and Perfect 4th\n\n◆It has a simple drum machine function.\n ・The rhythm pattern that you made, you can send to your friend by AirDrop or email.\n ・And also the data is a common use with TN-1B(bass tuner), you can share the rhythm pattern with the bassist of your band.\n\n◆You can change reference pitch-A between 438Hz and 445Hz.\n\n◆You can change note notation language.\n ・English(C, C#, D, E… , B)\n ・Italian(Do, Do#, Re, Mi… , Si)\n ・German(C, Cis, D, E… , H)\n ・French(Ut, Ut#, Ré, Mi… , Si)\n ・Chinese(C, 升C, D, E… , B)\n\n◆TN-1G has a tuning fork that emits a pitch corresponding to each string.", "minimumOsVersion": "11.0", "primaryGenreName": "Music", "bundleId": "jp.blowbend.ios.music.TN1G", "trackName": "Guitar Tuner TN-1G", "trackId": 754125890, "sellerName": "HIDEHISA YOKOYAMA" }, { "screenshotUrls": [ "https://is2-ssl.mzstatic.com/image/thumb/Purple4/v4/cc/d2/a3/ccd2a339-7a05-0617-f8fb-edebb83d734a/source/320x180bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple6/v4/10/fa/8d/10fa8d93-a0bd-29c5-842d-084550c75b7c/source/320x180bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple/v4/d8/a6/78/d8a67817-c130-640e-b291-316adec7d276/source/320x180bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple4/v4/59/74/bc/5974bc0d-64e3-bbd5-a704-60237093278d/source/320x180bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple4/v4/4a/05/3b/4a053b48-e1bf-dc9f-c761-5b4b781abef7/source/320x180bb.jpg" ], "ipadScreenshotUrls": [ "https://is1-ssl.mzstatic.com/image/thumb/Purple/v4/bf/c9/21/bfc921a3-19bc-85a2-7bcd-8e2b7f880d02/source/480x360bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple6/v4/1a/a7/a9/1aa7a9e2-5209-8d46-b925-18b5d1176c91/source/480x360bb.jpg", "https://is2-ssl.mzstatic.com/image/thumb/Purple/v4/3b/07/e6/3b07e6e9-e439-593a-ec05-13e59584e1c6/source/480x360bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple/v4/15/1a/c3/151ac38d-6883-e28e-d3bc-55f9692fd77b/source/480x360bb.jpg", "https://is3-ssl.mzstatic.com/image/thumb/Purple4/v4/a4/cc/18/a4cc185d-5685-ed2e-d4e5-f3cc4e7ae95d/source/480x360bb.jpg" ], "appletvScreenshotUrls": [], "artworkUrl60": "https://is2-ssl.mzstatic.com/image/thumb/Purple4/v4/18/ea/12/18ea12b7-81c1-c937-f17f-a37884ab264a/source/60x60bb.jpg", "artworkUrl512": "https://is2-ssl.mzstatic.com/image/thumb/Purple4/v4/18/ea/12/18ea12b7-81c1-c937-f17f-a37884ab264a/source/512x512bb.jpg", "artworkUrl100": "https://is2-ssl.mzstatic.com/image/thumb/Purple4/v4/18/ea/12/18ea12b7-81c1-c937-f17f-a37884ab264a/source/100x100bb.jpg", "artistViewUrl": "https://itunes.apple.com/au/developer/kory-foster/id730234819?uo=4", "advisories": [], "isGameCenterEnabled": false, "kind": "software", "supportedDevices": [ "iPhone3GS-iPhone-3GS", "iPadWifi-iPadWifi", "iPad3G-iPad3G", "iPodTouchThirdGen-iPodTouchThirdGen", "iPhone4-iPhone4", "iPodTouchFourthGen-iPodTouchFourthGen", "iPad2Wifi-iPad2Wifi", "iPad23G-iPad23G", "iPhone4S-iPhone4S", "iPadThirdGen-iPadThirdGen", "iPadThirdGen4G-iPadThirdGen4G", "iPhone5-iPhone5", "iPodTouchFifthGen-iPodTouchFifthGen", "iPadFourthGen-iPadFourthGen", "iPadFourthGen4G-iPadFourthGen4G", "iPadMini-iPadMini", "iPadMini4G-iPadMini4G", "iPhone5c-iPhone5c", "iPhone5s-iPhone5s", "iPadAir-iPadAir", "iPadAirCellular-iPadAirCellular", "iPadMiniRetina-iPadMiniRetina", "iPadMiniRetinaCellular-iPadMiniRetinaCellular", "iPhone6-iPhone6", "iPhone6Plus-iPhone6Plus", "iPadAir2-iPadAir2", "iPadAir2Cellular-iPadAir2Cellular", "iPadMini3-iPadMini3", "iPadMini3Cellular-iPadMini3Cellular", "iPodTouchSixthGen-iPodTouchSixthGen", "iPhone6s-iPhone6s", "iPhone6sPlus-iPhone6sPlus", "iPadMini4-iPadMini4", "iPadMini4Cellular-iPadMini4Cellular", "iPadPro-iPadPro", "iPadProCellular-iPadProCellular", "iPadPro97-iPadPro97", "iPadPro97Cellular-iPadPro97Cellular", "iPhoneSE-iPhoneSE", "iPhone7-iPhone7", "iPhone7Plus-iPhone7Plus", "iPad611-iPad611", "iPad612-iPad612", "iPad71-iPad71", "iPad72-iPad72", "iPad73-iPad73", "iPad74-iPad74", "iPhone8-iPhone8", "iPhone8Plus-iPhone8Plus", "iPhoneX-iPhoneX", "iPad75-iPad75", "iPad76-iPad76", "iPhoneXS-iPhoneXS", "iPhoneXSMax-iPhoneXSMax", "iPhoneXR-iPhoneXR", "iPad812-iPad812", "iPad834-iPad834", "iPad856-iPad856", "iPad878-iPad878" ], "features": [ "iosUniversal" ], "averageUserRatingForCurrentVersion": 5.0, "trackCensoredName": "Thanksgiving Slacking", "languageCodesISO2A": [ "EN" ], "fileSizeBytes": "2781184", "contentAdvisoryRating": "4+", "userRatingCountForCurrentVersion": 1, "trackViewUrl": "https://itunes.apple.com/au/app/thanksgiving-slacking/id738956061?mt=8&uo=4", "trackContentRating": "4+", "currentVersionReleaseDate": "2013-11-12T04:43:10Z", "primaryGenreId": 6014, "formattedPrice": "Free", "genreIds": [ "6014", "7009", "7018" ], "releaseDate": "2013-11-12T04:43:10Z", "currency": "AUD", "wrapperType": "software", "version": "1.0", "isVppDeviceBasedLicensingEnabled": true, "artistId": 730234819, "artistName": "kory foster", "genres": [ "Games", "Family", "Trivia" ], "price": 0.00, "description": "Today we give thanks for everything wonderful in our lives. Sarah has to help prepare for her thanksgiving dinner, but can she behave or will she be caught by her mom?\n\nUse your finger to complete the activities. If your mom comes to check on you, click the close button to get back to work!", "minimumOsVersion": "4.3", "primaryGenreName": "Games", "bundleId": "com.koryfoster.ThanksgivingSlacking", "trackName": "Thanksgiving Slacking", "trackId": 738956061, "sellerName": "kory foster" } ] } ================================================ FILE: testdata/itunes/mas-search-slack.json ================================================ { "resultCount": 1, "results": [ { "screenshotUrls": [ "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/8d/07/74/8d0774c5-90aa-611c-3701-35f6158fb77e/source/800x500bb.jpg", "https://is1-ssl.mzstatic.com/image/thumb/Purple128/v4/6d/86/a7/6d86a74d-5c45-1a61-7828-ff3251360271/source/800x500bb.jpg", "https://is4-ssl.mzstatic.com/image/thumb/Purple118/v4/18/c1/38/18c138de-5bf2-586b-34de-c81e05568ea3/source/800x500bb.jpg", "https://is5-ssl.mzstatic.com/image/thumb/Purple118/v4/4a/92/50/4a925081-61d0-ff32-eb2a-4b57db03aaab/source/800x500bb.jpg" ], "artworkUrl512": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/84/94/74/84947420-25dc-35e2-2761-9fa2b583c0a5/source/512x512bb.png", "artistViewUrl": "https://itunes.apple.com/us/developer/slack-technologies-inc/id453420243?mt=12&uo=4", "artworkUrl60": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/84/94/74/84947420-25dc-35e2-2761-9fa2b583c0a5/source/60x60bb.png", "artworkUrl100": "https://is4-ssl.mzstatic.com/image/thumb/Purple128/v4/84/94/74/84947420-25dc-35e2-2761-9fa2b583c0a5/source/100x100bb.png", "kind": "mac-software", "averageUserRatingForCurrentVersion": 4.0, "languageCodesISO2A": [ "EN", "FR", "DE", "JA", "ES" ], "fileSizeBytes": "74398324", "sellerUrl": "https://slack.com", "userRatingCountForCurrentVersion": 117, "trackContentRating": "4+", "trackCensoredName": "Slack", "trackViewUrl": "https://itunes.apple.com/us/app/slack/id803453959?mt=12&uo=4", "contentAdvisoryRating": "4+", "formattedPrice": "Free", "releaseNotes": "All updates are important, of course. This one contains security updates, and as we know, they’re the most important kind of all.", "currentVersionReleaseDate": "2018-10-02T23:28:05Z", "releaseDate": "2014-01-23T02:46:20Z", "sellerName": "Slack Technologies, Inc.", "primaryGenreId": 12001, "isVppDeviceBasedLicensingEnabled": true, "currency": "USD", "wrapperType": "software", "version": "3.3.3", "artistId": 453420243, "artistName": "Slack Technologies, Inc.", "genres": [ "Business", "Productivity" ], "price": 0.00, "description": "Slack brings team communication and collaboration into one place so you can get more work done, whether you belong to a large enterprise or a small business. Check off your to-do list and move your projects forward by bringing the right people, conversations, tools, and information you need together. Slack is available on any device, so you can find and access your team and your work, whether you’re at your desk or on the go.\n\nUse Slack to: \n• Communicate with your team and organize your conversations by topics, projects, or anything else that matters to your work\n• Message or call any person or group within your team\n• Share and edit documents and collaborate with the right people all in Slack \n• Integrate into your workflow, the tools and services you already use including Google Drive, Salesforce, Dropbox, Asana, Twitter, Zendesk, and more\n• Easily search a central knowledge base that automatically indexes and archives your team’s past conversations and files\n• Customize your notifications so you stay focused on what matters\n\nScientifically proven (or at least rumored) to make your working life simpler, more pleasant, and more productive. We hope you’ll give Slack a try.\n\nStop by and learn more at: https://slack.com/", "primaryGenreName": "Business", "genreIds": [ "12001", "12014" ], "bundleId": "com.tinyspeck.slackmacgap", "minimumOsVersion": "10.9", "trackId": 803453959, "trackName": "Slack", "averageUserRating": 4.0, "userRatingCount": 1477 } ] } ================================================ FILE: tests/__init__.py ================================================ JSON_API_HEADERS = { 'Content-Type': 'application/vnd.api+json', 'Accept': 'application/vnd.api+json' } ================================================ FILE: tests/alembic_test.ini ================================================ # A generic, single database configuration. [alembic] # path to migration scripts script_location = %(here)s/../commandment/alembic sqlalchemy.url = sqlite:///:memory: # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = INFO handlers = console qualname = [logger_sqlalchemy] level = WARNING handlers = qualname = sqlalchemy.engine [logger_alembic] level = WARNING handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: tests/api/__init__.py ================================================ ================================================ FILE: tests/api/conftest.py ================================================ import pytest import os from tests.conftest import * from commandment.models import Device from sqlalchemy.orm.session import Session TEST_DIR = os.path.realpath(os.path.dirname(__file__)) TEST_DATA_DIR = os.path.realpath(TEST_DIR + '/../../testdata') @pytest.fixture(scope='function') def device(session: Session): """Create a fixture device which is referenced in all of the fake MDM responses by its UDID.""" d = Device( udid='00000000-1111-2222-3333-444455556666', device_name='commandment-mdmclient' ) session.add(d) session.commit() ================================================ FILE: tests/api/test_devices.py ================================================ import pytest import os import json import sqlalchemy from flask import Response from tests.client import MDMClient from commandment.models import Command @pytest.mark.usefixtures("device") class TestDevicesAPI: def test_patch_device_name(self, client: MDMClient, session): """Patching the device name should enqueue a Rename MDM command.""" request_json = json.dumps({ "data": { "type": "devices", "id": "1", "attributes": { "device_name": "new name" } }, "jsonapi": { "version": "1.0" } }) response: Response = client.patch("/api/v1/devices/1", data=request_json, content_type="application/vnd.api+json") assert response.status_code == 200 try: cmd: Command = session.query(Command).filter(Command.request_type == 'Settings').one() except sqlalchemy.orm.exc.NoResultFound: assert False, "The API has created a new Settings Command to send to the device" device = json.loads(response.data) assert device['data']['attributes']['device_name'] != "new name", "Device rename is still pending, API should reflect old name" @pytest.mark.skip def test_patch_hostname(self, client: MDMClient, session): """Patching the hostname should enqueue a Rename MDM command.""" request_json = json.dumps({ "data": { "type": "devices", "id": "1", "attributes": { "hostname": "new name" } }, "jsonapi": { "version": "1.0" } }) response: Response = client.patch("/api/v1/devices/1", data=request_json, content_type="application/vnd.api+json") assert response.status_code == 200 try: cmd: Command = session.query(Command).filter(Command.request_type == 'Settings').one() except sqlalchemy.orm.exc.NoResultFound: assert False, "The API has created a new Settings Command to send to the device" device = json.loads(response.data) assert device['data']['attributes']['hostname'] != "new name", "Device rename is still pending, API should reflect old name" def test_patch_hostname_ios(self): """Patching an iOS device hostname should return 400 bad request.""" pass @pytest.mark.skip def test_patch_device_name_reverted(self, client: MDMClient, session): """Patching the device name twice (change, then back to its original name) should remove the queued Settings command.""" request_json = json.dumps({ "data": { "type": "devices", "id": "1", "attributes": { "device_name": "new name" } }, "jsonapi": { "version": "1.0" } }) request_two_json = json.dumps({ "data": { "type": "devices", "id": "1", "attributes": { "device_name": "commandment-mdmclient" } }, "jsonapi": { "version": "1.0" } }) response: Response = client.patch("/api/v1/devices/1", data=request_json, content_type="application/vnd.api+json") assert response.status_code == 200 second_response: Response = client.patch("/api/v1/devices/1", data=request_two_json, content_type="application/vnd.api+json") assert second_response.status_code == 200 settings_commands = session.query(Command).filter(Command.request_type == 'Settings').count() assert settings_commands == 1 def test_patch_device_name_coalesced(self, client: MDMClient, session): """Multiple device name changes should be coalesced into a single Settings command.""" pass ================================================ FILE: tests/client.py ================================================ from flask.testing import FlaskClient class MDMClient(FlaskClient): """MDMClient is a superset of the flask testing client meant to perform higher level operations similar to the native mdmclient binary. Attributes: _private_key (rsa.RSAPrivateKey): RSA Private Key for the simulated client. _certificate (x509.Certificate): X.509 Certificate for the simulated client. """ def __init__(self, *args, **kwargs): self._private_key = kwargs.get('private_key', None) self._certificate = kwargs.get('certificate', None) super(MDMClient, self).__init__(*args, **kwargs) # self.environ_base['HTTP_MDM_SIGNATURE'] = b'Tk9UUkVBTA==' ================================================ FILE: tests/conftest.py ================================================ import pytest import os from flask import Flask from typing import Generator from commandment import create_app from commandment.models import db as _db from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import scoped_session import sqlalchemy from tests.client import MDMClient from alembic.command import upgrade from alembic.config import Config # For testing, every test uses an in-memory database with migrations that are applied in the fixture setup phase. # This ensures every test is fully isolated. TEST_DATABASE_URI = 'sqlite:///:memory:' TEST_DIR = os.path.realpath(os.path.dirname(__file__)) ALEMBIC_CONFIG = os.path.realpath(TEST_DIR + '/alembic_test.ini') TEST_APP_CONFIG = os.path.realpath(TEST_DIR + '/../travis-ci-settings.cfg') @pytest.yield_fixture(scope='function') def app() -> Generator[Flask, None, None]: """Flask Application Fixture""" a = create_app(TEST_APP_CONFIG) a.config['TESTING'] = True a.config['SQLALCHEMY_DATABASE_URI'] = TEST_DATABASE_URI ctx = a.test_request_context() ctx.push() yield a ctx.pop() @pytest.yield_fixture(scope='function') def db(app: Flask) -> Generator[SQLAlchemy, None, None]: """Flask-SQLAlchemy Fixture""" _db.app = app #_db.create_all() yield _db # _db.drop_all() @pytest.yield_fixture(scope='function') def session(db: SQLAlchemy) -> Generator[scoped_session, None, None]: """SQLAlchemy session Fixture""" connection: sqlalchemy.engine.base.Connection = db.engine.connect() with db.app.app_context(): config = Config(ALEMBIC_CONFIG) # Issues with running upgrade() in a fixture with SQLite in-memory db: # https://github.com/miguelgrinberg/Flask-Migrate/issues/153 # # Basically: Alembic always spawns a new connection from upgrade() unless you specify a connection # in config.attributes['connection'] config.attributes['connection'] = connection upgrade(config, 'head') connection.execute("SELECT * FROM devices") # transaction = connection.begin() options = dict(bind=connection) session = db.create_scoped_session(options=options) db.session = session yield session # transaction.rollback() session.remove() @pytest.fixture(scope='function') def client(app: Flask) -> MDMClient: """Flask test client""" app.test_client_class = MDMClient test_client = app.test_client() return test_client ================================================ FILE: tests/dep/__init__.py ================================================ ================================================ FILE: tests/dep/conftest.py ================================================ import pytest import requests import os.path from commandment.dep import SetupAssistantStep from commandment.dep.dep import DEP from commandment.dep.models import DEPProfile from commandment.models import Device from sqlalchemy.orm.session import Session SIMULATOR_URL = 'http://localhost:8080' @pytest.fixture def simulator_token() -> dict: res = requests.get('{}/token'.format(SIMULATOR_URL)) return res.json() @pytest.fixture def live_token() -> str: dep_token_path = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata', 'deptoken.json') with open(dep_token_path, 'rb') as fd: content = fd.read() return content.decode('utf8') @pytest.fixture def live_device() -> str: return os.environ.get('DEP_DEVICE_UUID') @pytest.fixture def live_dep_profile() -> str: return os.environ.get('DEP_PROFILE_UUID') @pytest.fixture def dep(simulator_token: dict) -> DEP: d = DEP( consumer_key=simulator_token['consumer_key'], consumer_secret=simulator_token['consumer_secret'], access_token=simulator_token['access_token'], access_secret=simulator_token['access_secret'], url=SIMULATOR_URL, ) return d @pytest.fixture def dep_live(live_token: str): return DEP.from_token(live_token) @pytest.fixture def dep_profile() -> dict: p = { 'profile_name': 'Fixture Profile', 'url': 'https://localhost:5433', 'allow_pairing': True, 'is_supervised': True, 'is_multi_user': False, 'is_mandatory': False, 'await_device_configured': False, 'is_mdm_removable': True, 'support_phone_number': '12345678', 'auto_advance_setup': False, 'support_email_address': 'test@localhost', 'org_magic': 'COMMANDMENT-TEST-FIXTURE', 'skip_setup_items': [ SetupAssistantStep.AppleID, ], 'department': 'Commandment Dept', 'devices': [], } return p @pytest.fixture def dep_profile_committed(dep_profile: dict, session: Session): dp = DEPProfile(**dep_profile) session.add(dp) session.commit() @pytest.fixture(scope='function') def device(session: Session): """Create a fixture device which is referenced in all of the fake MDM responses by its UDID.""" d = Device( udid='00000000-1111-2222-3333-444455556666', device_name='commandment-mdmclient' ) session.add(d) session.commit() ================================================ FILE: tests/dep/test_dep.py ================================================ import pytest from commandment.dep.dep import DEP @pytest.mark.depsim class TestDEP: def test_account(self, dep: DEP): dep.fetch_token() account = dep.account() assert account is not None def test_fetch_devices(self, dep: DEP): dep.fetch_token() devices = dep.fetch_devices() assert len(devices) == 500 # def test_device_details(self, dep: DEP): # dep.fetch_token() # device_details = dep.device_detail() def test_fetch_cursor(self, dep: DEP): dep.fetch_token() for page in dep.devices(): print(len(page)) for d in page: print(d) ================================================ FILE: tests/dep/test_dep_app.py ================================================ import pytest import os import json import sqlalchemy from flask import Response from commandment.dep.models import DEPProfile from commandment.models import Device from tests.client import MDMClient @pytest.mark.dep @pytest.mark.usefixtures("device", "dep_profile_committed") class TestDEPAPI: def test_post_dep_profile_relationship(self, client: MDMClient, session): """Test assignment of DEP Profile to device via relationship URL: /api/v1/devices//relationships/dep_profiles""" request_json = json.dumps({ "data": { "type": "dep_profiles", "id": "1", }, "jsonapi": { "version": "1.0" } }) response: Response = client.patch("/api/v1/devices/1/relationships/dep_profile", data=request_json, content_type="application/vnd.api+json") print(response.data) assert response.status_code == 200 d: Device = session.query(Device).filter(Device.id == 1).one() assert d.dep_profile_id is not None ================================================ FILE: tests/dep/test_dep_failures.py ================================================ import pytest from commandment.dep.dep import DEP from commandment.dep.errors import DEPServiceError @pytest.mark.depsim class TestDEPFailures: # NOTE: ensure that this is in exactly the same order as your DEPsim config. @pytest.mark.parametrize("expected_status,expected_text", [ (400, ""), (403, "ACCESS_DENIED"), (403, "T_C_NOT_SIGNED"), (405, ""), (401, "UNAUTHORIZED"), (429, "TOO_MANY_REQUESTS"), ]) def test_token_failure(self, dep: DEP, expected_status: int, expected_text: str): try: dep.fetch_token() except DEPServiceError as e: assert e.response.status_code == expected_status assert e.text == expected_text @pytest.mark.parametrize("expected_status,expected_text", [ (403, "ACCESS_DENIED"), (401, "UNAUTHORIZED"), ]) def test_account_failure(self, dep: DEP, expected_status: int, expected_text: str): try: dep.fetch_token() dep.account() except DEPServiceError as e: assert e.response.status_code == expected_status assert e.text == expected_text ================================================ FILE: tests/dep/test_dep_live.py ================================================ import pytest from commandment.dep.dep import DEP @pytest.mark.dep class TestDEPLive: def test_account(self, dep_live: DEP): dep_live.fetch_token() account = dep_live.account() assert account is not None assert 'server_name' in account assert 'server_uuid' in account assert 'facilitator_id' in account assert 'admin_id' in account assert 'org_name' in account assert 'org_email' in account assert 'org_phone' in account assert 'org_address' in account # X-Server-Protocol 3 assert 'org_id' in account assert 'org_id_hash' in account assert 'org_type' in account assert 'org_version' in account def test_fetch_devices(self, dep_live: DEP): dep_live.fetch_token() devices = dep_live.fetch_devices() assert 'cursor' in devices assert 'devices' in devices assert 'fetched_until' in devices assert 'more_to_follow' in devices def test_device_details(self, dep_live: DEP, live_device: str): dep_live.fetch_token() device_details = dep_live.device_detail(live_device) print(device_details) # def test_fetch_cursor(self, dep: DEP): # dep.fetch_token() # for page in dep.devices(): # print(len(page)) # for d in page: # print(d) # def test_define_profile(self, dep_live: DEP, dep_profile: dict): # token = dep_live.fetch_token() # result = dep_live.define_profile(dep_profile) # assert 'profile_uuid' in result # assert 'devices' in result # # # print(result['profile_uuid']) def test_get_profile(self, dep_live: DEP, live_dep_profile: str): dep_live.fetch_token() profiles = dep_live.profile(live_dep_profile) print(profiles) ================================================ FILE: tests/dep/test_smime.py ================================================ import pytest import os.path import os from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization, hashes from commandment.dep import smime DEP_TOKEN_SMIME_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata', 'dep_smime.p7m') DEP_TOKEN_KEY_PATH = os.path.join(os.path.dirname(__file__), '..', '..', 'testdata', 'dep_key.pem') class TestDepSmime: def test_decrypt(self): with open(DEP_TOKEN_SMIME_PATH, 'rb') as fd: message = fd.read() with open(DEP_TOKEN_KEY_PATH, 'rb') as fd: pem_key = fd.read() pk = serialization.load_pem_private_key( pem_key, backend=default_backend(), password=None, ) result = smime.decrypt(message, pk) print(result) ================================================ FILE: tests/mdm/__init__.py ================================================ ================================================ FILE: tests/mdm/conftest.py ================================================ import pytest import os from tests.conftest import * from commandment.models import Device from sqlalchemy.orm.session import Session TEST_DIR = os.path.realpath(os.path.dirname(__file__)) TEST_DATA_DIR = os.path.realpath(TEST_DIR + '/../../testdata') @pytest.fixture(scope='function') def device(session: Session): """Create a fixture device which is referenced in all of the fake MDM responses by its UDID.""" d = Device( udid='00000000-1111-2222-3333-444455556666', device_name='commandment-mdmclient' ) session.add(d) session.commit() @pytest.fixture() def authenticate_request() -> str: with open(os.path.join(TEST_DATA_DIR, 'Authenticate/10.12.2.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture() def tokenupdate_request() -> str: with open(os.path.join(TEST_DATA_DIR, 'TokenUpdate/10.12.2.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture() def tokenupdate_user_request() -> str: with open(os.path.join(TEST_DATA_DIR, 'TokenUpdate/10.12.2-user.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture() def checkout_request() -> str: with open(os.path.join(TEST_DATA_DIR, 'CheckOut/10.11.x.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture() def available_os_updates_request() -> str: with open(os.path.join(TEST_DATA_DIR, 'AvailableOSUpdates/10.12.5.xml'), 'r') as fd: plist_data = fd.read() return plist_data ================================================ FILE: tests/mdm/test_available_os_updates.py ================================================ import pytest import os import plistlib from flask import Response from tests.client import MDMClient from commandment.mdm import CommandStatus from commandment.models import Command, Device TEST_DIR = os.path.realpath(os.path.dirname(__file__)) @pytest.fixture(scope='function') def available_os_updates_command(session): """Creates an AvailableOSUpdates command that has been sent to the fixture device so that it can be marked acknowledged when the fake response comes in.""" c = Command( uuid='00000000-1111-2222-3333-444455556666', request_type='AvailableOSUpdates', status=CommandStatus.Sent.value, parameters={}, ) session.add(c) session.commit() @pytest.mark.usefixtures("device", "available_os_updates_command") class TestAvailableOSUpdates: def test_available_os_updates_response(self, client: MDMClient, available_os_updates_request: str, session): response: Response = client.put('/mdm', data=available_os_updates_request, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 d: Device = session.query(Device).filter(Device.udid == '00000000-1111-2222-3333-444455556666').one() updates = d.available_os_updates plist = plistlib.loads(available_os_updates_request.encode('utf8')) assert len(updates) == len(plist['AvailableOSUpdates']) ================================================ FILE: tests/mdm/test_certificate_list.py ================================================ import pytest import os from flask import Response from tests.client import MDMClient from commandment.mdm import CommandStatus from commandment.models import Command, Device TEST_DIR = os.path.realpath(os.path.dirname(__file__)) @pytest.fixture() def certificate_list_response(): with open(os.path.join(TEST_DIR, '../../testdata/CertificateList/10.11.x.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture(scope='function') def certificate_list_command(session): c = Command( uuid='00000000-1111-2222-3333-444455556666', request_type='CertificateList', status=CommandStatus.Sent.value, parameters={}, ) session.add(c) session.commit() @pytest.mark.usefixtures("device", "certificate_list_command") class TestCertificateList: def test_certificate_list_response(self, client: MDMClient, certificate_list_response: str, session): response: Response = client.put('/mdm', data=certificate_list_response, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 d = session.query(Device).filter(Device.udid == '00000000-1111-2222-3333-444455556666').one() ic = d.installed_certificates assert len(ic) == 2 ================================================ FILE: tests/mdm/test_checkin.py ================================================ import pytest from flask import Response from tests.client import MDMClient @pytest.mark.usefixtures("device") class TestCheckin: def test_authenticate(self, client: MDMClient, authenticate_request: str): """Basic test: Authenticate""" response: Response = client.put('/checkin', data=authenticate_request, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 def test_tokenupdate(self, client: MDMClient, tokenupdate_request: str): """Test a client attempting to update its token after being unenrolled is forced to unenroll via code 410.""" response: Response = client.put('/checkin', data=tokenupdate_request, content_type='text/xml') assert response.status_code != 200 assert response.status_code == 410 # def test_user_tokenupdate(self, client: MDMClient, tokenupdate_user_request: str): # """Test a TokenUpdate message on the user channel.""" # response: Response = client.put('/checkin', data=tokenupdate_user_request, content_type='text/xml') # assert response.status_code != 410 # assert response.status_code == 200 def test_checkout(self, client: MDMClient, checkout_request: str): """Test a CheckOut message""" response: Response = client.put('/checkin', data=checkout_request, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 ================================================ FILE: tests/mdm/test_device_information.py ================================================ import pytest import os from flask import Response from tests.client import MDMClient TEST_DIR = os.path.realpath(os.path.dirname(__file__)) @pytest.fixture() def device_information_response(): with open(os.path.join(TEST_DIR, '../../testdata/DeviceInformation/10.11.x.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.mark.usefixtures("device") class TestDeviceInformation: def test_device_information_response(self, client: MDMClient, device_information_response: str): response: Response = client.put('/mdm', data=device_information_response, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 ================================================ FILE: tests/mdm/test_installed_application_list.py ================================================ import pytest import os from flask import Response from tests.client import MDMClient from commandment.mdm import CommandStatus from commandment.models import Command, Device TEST_DIR = os.path.realpath(os.path.dirname(__file__)) @pytest.fixture() def installed_application_list_response(): with open(os.path.join(TEST_DIR, '../../testdata/InstalledApplicationList/10.11.x.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture(scope='function') def installed_application_list_command(session): c = Command( uuid='00000000-1111-2222-3333-444455556666', request_type='InstalledApplicationList', status=CommandStatus.Sent.value, parameters={}, ) session.add(c) session.commit() @pytest.mark.usefixtures("device", "installed_application_list_command") class TestInstalledApplicationList: def test_installed_application_list_response(self, client: MDMClient, installed_application_list_response: str, session): response: Response = client.put('/mdm', data=installed_application_list_response, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 d: Device = session.query(Device).filter(Device.udid == '00000000-1111-2222-3333-444455556666').one() ia = d.installed_applications assert len(ia) == 3 ================================================ FILE: tests/mdm/test_profile_list.py ================================================ import pytest import os from flask import Response from tests.client import MDMClient TEST_DIR = os.path.realpath(os.path.dirname(__file__)) @pytest.fixture() def profile_list_response() -> str: with open(os.path.join(TEST_DIR, '../../testdata/ProfileList/10.11.x.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.mark.usefixtures("device") class TestProfileList: def test_profile_list_response(self, client: MDMClient, profile_list_response: str): response: Response = client.put('/mdm', data=profile_list_response, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 ================================================ FILE: tests/mdm/test_security_info.py ================================================ import pytest import os from flask import Response from commandment.mdm import CommandStatus from tests.client import MDMClient from commandment.models import Command, Device TEST_DIR = os.path.realpath(os.path.dirname(__file__)) @pytest.fixture() def security_info_response(): with open(os.path.join(TEST_DIR, '../../testdata/SecurityInfo/10.11.x.xml'), 'r') as fd: plist_data = fd.read() return plist_data @pytest.fixture(scope='function') def security_info_command(session): c = Command( uuid='00000000-1111-2222-3333-444455556666', request_type='SecurityInfo', status=CommandStatus.Sent.value, parameters={}, ) session.add(c) session.commit() @pytest.mark.usefixtures("device", "security_info_command") class TestSecurityInfo: def test_security_info_response(self, client: MDMClient, security_info_response: str, session): response: Response = client.put('/mdm', data=security_info_response, content_type='text/xml') assert response.status_code != 410 assert response.status_code == 200 cmd = session.query(Command).filter(Command.uuid == '00000000-1111-2222-3333-444455556666').one() assert CommandStatus(cmd.status) == CommandStatus.Acknowledged d = session.query(Device).filter(Device.udid == '00000000-1111-2222-3333-444455556666').one() assert not d.fde_enabled ================================================ FILE: tests/pkg/__init__.py ================================================ ================================================ FILE: tests/pki/__init__.py ================================================ ================================================ FILE: tests/pki/conftest.py ================================================ import pytest import datetime from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend @pytest.fixture def private_key() -> rsa.RSAPrivateKey: key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) return key @pytest.fixture def csr(private_key: rsa.RSAPrivateKey) -> x509.CertificateSigningRequest: b = x509.CertificateSigningRequestBuilder() req = b.subject_name(x509.Name([ x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"CA"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Commandment"), x509.NameAttribute(NameOID.COMMON_NAME, u"Commandment"), ])).sign(private_key, hashes.SHA256(), default_backend()) return req @pytest.fixture def certificate(private_key: rsa.RSAPrivateKey) -> x509.Certificate: b = x509.CertificateBuilder() name = x509.Name([ x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"CA"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Commandment"), x509.NameAttribute(NameOID.COMMON_NAME, u"CA-CERTIFICATE"), ]) cer = b.subject_name(name).issuer_name(name).public_key( private_key.public_key() ).serial_number(1).not_valid_before( datetime.datetime.utcnow() ).not_valid_after( datetime.datetime.utcnow() + datetime.timedelta(days=10) ).add_extension( x509.BasicConstraints(ca=False, path_length=None), True ).sign(private_key, hashes.SHA256(), default_backend()) return cer @pytest.fixture def ca_certificate(private_key: rsa.RSAPrivateKey) -> x509.Certificate: b = x509.CertificateBuilder() name = x509.Name([ x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"CA"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Commandment"), x509.NameAttribute(NameOID.COMMON_NAME, u"CA-CERTIFICATE"), ]) cert = b.serial_number(1).issuer_name( name ).subject_name( name ).public_key( private_key.public_key() ).not_valid_before( datetime.datetime.utcnow() ).not_valid_after( datetime.datetime.utcnow() + datetime.timedelta(days=10) ).add_extension( x509.BasicConstraints(ca=True, path_length=None), True ).sign(private_key, hashes.SHA256(), default_backend()) return cert ================================================ FILE: tests/pki/test_ca.py ================================================ ================================================ FILE: tests/pki/test_models.py ================================================ import pytest import os.path import logging from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import rsa from commandment.pki.models import RSAPrivateKey, CACertificate logger = logging.getLogger(__name__) class TestModels: def test_rsa_privatekey_from_crypto(self, private_key: rsa.RSAPrivateKeyWithSerialization, session): m = RSAPrivateKey.from_crypto(private_key) session.add(m) session.commit() assert m.id is not None assert m.pem_data is not None def test_ca_certificate_from_crypto(self, ca_certificate: x509.Certificate, session): m = CACertificate.from_crypto(ca_certificate) session.add(m) session.commit() assert m.id is not None assert m.pem_data is not None assert m.fingerprint is not None assert m.x509_cn is not None ================================================ FILE: tests/pki/test_openssl.py ================================================ import pytest import os.path import logging from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import rsa from commandment.pki import openssl import oscrypto logger = logging.getLogger(__name__) class TestOpenssl: def test_pkcs12_from_crypto(self, private_key: rsa.RSAPrivateKeyWithSerialization, certificate: x509.Certificate): pkcs12_data = openssl.create_pkcs12(private_key, certificate) ================================================ FILE: tests/pki/test_ormutils.py ================================================ import pytest import os.path import logging from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import rsa from commandment.pki.models import RSAPrivateKey, CACertificate logger = logging.getLogger(__name__) class TestORMUtils: def test_find_recipient(self, certificate): pass ================================================ FILE: tests/test_api_flat.py ================================================ import pytest from flask import Flask from commandment import create_app from commandment.models import db import tempfile import json from . import JSON_API_HEADERS class TestApiCertificates: pass # def test_get_certificates(self, app): # res = app.get('/api/v1/certificates/?size=50&number=1', headers=JSON_API_HEADERS) # rd = json.loads(res.data) # print(rd) # # assert res.status_code == 200 # def test_post_push_certificate(self, app): # res = app.get('/api/v1/push_certificate', headers={ # 'Content-Type': 'application/vnd.api+json', # 'Accept': 'application/vnd.api+json' # }) # # assert res.status_code == 201 # def test_get_push_certificate(self, app): # res = app.get('/api/v1/push_certificate', headers={ # 'Content-Type': 'application/vnd.api+json', # 'Accept': 'application/vnd.api+json' # }) # print(res.data) # assert res.status_code == 200 # def test_post_push_certificate_pkcs12(self, app, pkcs12_certificate): # """Assert that a PKCS#12 can be posted to the push certificate endpoint.""" # res = app.post('/api/v1/push_certificate', headers={ # 'Content-Type': 'application/x-pkcs12', # 'Accept': 'application/json' # }, data=pkcs12_certificate) # print(res.data) # def test_post_certificate_signing_request(self, app): # res = app.post('/api/v1/certificate_signing_requests', headers={ # 'Content-Type': 'application/vnd.api+json', # 'Accept': 'application/vnd.api+json' # }, data=json.dumps({ # 'data': { # 'type': 'certificate_signing_requests', # 'attributes': { # 'purpose': 'mdm.pushcert', # 'subject': 'O=commandment/OU=IT/CN=commandment.dev' # } # } # })) # print(res.data) # assert res.status_code == 201 ================================================ FILE: tests/test_mdmcert.py ================================================ import pytest import os.path import logging from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.backends import default_backend from commandment.apns.mdmcert import submit_mdmcert_request ENCRYPTION_CERT = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'mdmcert-encryption.cer') logger = logging.getLogger(__name__) @pytest.fixture def private_key() -> rsa.RSAPrivateKey: key = rsa.generate_private_key( public_exponent=65537, key_size=2048, backend=default_backend(), ) return key @pytest.fixture def csr(private_key: rsa.RSAPrivateKey) -> x509.CertificateSigningRequest: b = x509.CertificateSigningRequestBuilder() req = b.subject_name(x509.Name([ x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"CA"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Commandment"), x509.NameAttribute(NameOID.COMMON_NAME, u"Commandment"), ])).sign(private_key, hashes.SHA256(), default_backend()) return req @pytest.fixture def encryption_cert() -> x509.Certificate: with open(ENCRYPTION_CERT, 'rb') as fd: certdata = fd.read() cert = x509.load_pem_x509_certificate(certdata, default_backend()) return cert # class TestMDMCert: # def test_submit_mdmcert_request(self, csr: x509.CertificateSigningRequest, encryption_cert: x509.Certificate): # res = submit_mdmcert_request("admin@localhost", csr, encryption_cert) # assert res['result'] == 'success' ================================================ FILE: tests/threads/__init__.py ================================================ ================================================ FILE: tests/threads/test_startup_thread.py ================================================ import pytest from commandment.threads import startup_thread from commandment.pki.models import CACertificate class TestStartupThread: def test_startup_thread_ca(self, session): """Assert that the startup thread actually creates self-signed certificates.""" startup_thread.startup_callback() certificate = session.query(CACertificate).one() assert certificate.x509_cn == 'COMMANDMENT-CA' assert certificate.pem_data is not None assert certificate.fingerprint is not None ================================================ FILE: tests/vpp/__init__.py ================================================ ================================================ FILE: tests/vpp/conftest.py ================================================ import pytest import requests from commandment.vpp.vpp import VPP SIMULATOR_URL = 'http://localhost:8080' SERVICE_CONFIG = { "associateLicenseSrvUrl": "http://localhost:8080/associateVPPLicenseSrv", "clientConfigSrvUrl": "http://localhost:8080/VPPClientConfigSrv", "contentMetadataLookupUrl": "https://uclient-api.itunes.apple.com/WebObjects/MZStorePlatform.woa/wa/lookup", "disassociateLicenseSrvUrl": "http://localhost:8080/disassociateVPPLicenseSrv", "editUserSrvUrl": "http://localhost:8080/editVPPUserSrv", "getLicensesSrvUrl": "http://localhost:8080/getVPPLicensesSrv", "getUserSrvUrl": "http://localhost:8080/getVPPUserSrv", "getUsersSrvUrl": "http://localhost:8080/getVPPUsersSrv", "getVPPAssetsSrvUrl": "http://localhost:8080/getVPPAssetsSrv", "invitationEmailUrl": "http://buy.itunes.apple.com/us/vpp-associate?inviteCode=%25inviteCode%25\u0026mt=8", "manageVPPLicensesByAdamIdSrvUrl": "http://localhost:8080/manageVPPLicensesByAdamIdSrv", "maxBatchAssociateLicenseCount": 10, "maxBatchDisassociateLicenseCount": 10, "registerUserSrvUrl": "http://localhost:8080/registerVPPUserSrv", "retireUserSrvUrl": "http://localhost:8080/retireVPPUserSrv", "status": 0, "vppWebsiteUrl": "https://vpp.itunes.apple.com/" } @pytest.fixture def simulator_token() -> str: res = requests.get('{}/internal/get_stoken'.format(SIMULATOR_URL)) return res.json().get('sToken', None) @pytest.fixture() def vpp(simulator_token: str) -> VPP: return VPP( stoken=simulator_token, vpp_service_config_url='http://localhost:8080/VPPServiceConfigSrv', service_config=SERVICE_CONFIG ) ================================================ FILE: tests/vpp/vpp_test.py ================================================ import pytest import logging from commandment.vpp.enum import LicenseAssociationType from commandment.vpp.vpp import VPP logger = logging.getLogger(__name__) VPP_MOCK_USER_CID = 'F33D9E0F-CDE3-427E-A444-B137BEF9EFA2' VPP_MOCK_USER_ID = 2878111686099947 VPP_MOCK_USER_EMAIL = 'vpp-test@localhost' VPP_MOCK_USER_EMAIL_2 = 'vpp-test-2@localhost' VPP_BATCH_LICENSE_ADAMID = 525463029 # This license is used as the test for large batch operations @pytest.mark.vppsim class TestVPP: # def test_vpp_init(self, vpp): # assert vpp is not None def test_vpp_register_user(self, vpp: VPP): reply = vpp.register_user(VPP_MOCK_USER_CID, VPP_MOCK_USER_EMAIL) assert reply['status'] == 0 assert 'user' in reply def test_getuser_by_client_id(self, vpp: VPP): reply = vpp.get_user(client_user_id=VPP_MOCK_USER_CID) assert reply['status'] == 0 assert 'user' in reply # def test_getuser_by_client_id_with_itshash(self, vpp): # reply = vpp.get_user(client_user_id=VPP_MOCK_USER_ID, its_id_hash='') def test_getuser_by_user_id(self, vpp: VPP): reply = vpp.get_user(user_id=VPP_MOCK_USER_ID) assert reply['status'] == 0 assert 'user' in reply def test_retireuser_by_client_id(self, vpp: VPP): reply = vpp.retire_user(client_user_id=VPP_MOCK_USER_CID) assert reply['status'] == 0 # def test_already_retired_by_client_id(self, vpp: VPP): # reply = vpp.retire_user(client_user_id=VPP_MOCK_USER_CID) # assert reply['status'] == 0 # def test_retireuser_by_user_id(self, vpp: VPP): # reply = vpp.retire_user(client_user_id=VPP_MOCK_USER_CID) # assert reply['status'] == 0 def test_edit_user_by_client_id(self, vpp: VPP): reply = vpp.edit_user(client_user_id=VPP_MOCK_USER_CID, email=VPP_MOCK_USER_EMAIL_2) assert reply['status'] == 0 assert reply['user']['email'] == VPP_MOCK_USER_EMAIL_2 def test_get_assets(self, vpp: VPP): reply = vpp.assets() assert 'assets' in reply # def test_get_licenses(self, vpp: VPP): # licenses = vpp.licenses() # print(licenses) def test_users(self, vpp: VPP): cursor = vpp.users() while cursor.next(): users = cursor.users print(users) print('cursor exhausted') def test_licenses(self, vpp: VPP): cursor = vpp.licenses(VPP_BATCH_LICENSE_ADAMID) licenses = [] total = cursor.total assert len(cursor.licenses) == 600 licenses = licenses + cursor.licenses while cursor.next(): licenses = licenses + cursor.licenses assert len(licenses) == total def test_manage_one_license(self, vpp: VPP): op = vpp.manage(VPP_BATCH_LICENSE_ADAMID) op.add(LicenseAssociationType.ClientUserID, VPP_MOCK_USER_CID) vpp.save(op) ================================================ FILE: travis-ci-settings.cfg ================================================ # This settings file will be used only in Travis CI from os import path dirname = path.dirname(__file__) # The public facing hostname of the MDM # This will also be used as the self signed certificate dnsname PUBLIC_HOSTNAME = 'commandment.dev' # Development mode listen port PORT = 5443 # Configure your Database URI. # All SQLAlchemy options are available here: # http://flask-sqlalchemy.pocoo.org/2.1/config/ SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:' # You may supply the certificate as a pair of PEM encoded files, or as a .p12 container. # If you supply .p12 it will be encoded as a PEM keypair PUSH_CERTIFICATE = '../push.pem' PUSH_KEY = '../push.key' PUSH_CERTIFICATE_PASSWORD = 'sekret' # for pkcs12 only # If commandment is running in development mode, specify the path to the certificate and private key. # These can also be generated at start up. # Normally SSL should be handled by Apache/Nginx/etc. # Specify the Enterprise CA here if Apple Devices won't natively trust your CA. CA_CERTIFICATE = path.join(dirname, 'ssl', 'ca.crt') SSL_CERTIFICATE = path.join(dirname, 'ssl', 'server.crt') SSL_RSA_KEY = path.join(dirname, 'ssl', 'server.key') # If not using external storage, the path to the root directory for upload storage. # This should not be used in production. STORAGE_ROOT = path.join(dirname, 'storage') ================================================ FILE: ui/.eslintrc.js ================================================ module.exports = { "env": { "browser": true, "es6": true }, "extends": "eslint:recommended", "globals": { "Atomics": "readonly", "SharedArrayBuffer": "readonly" }, "parser": "@typescript-eslint/parser", parserOptions: { "ecmaFeatures": { "jsx": true }, ecmaVersion: 2018, sourceType: 'module', }, "plugins": [ "react", "@typescript-eslint" ], "rules": {} }; ================================================ FILE: ui/.gitignore ================================================ # Created by .ignore support plugin (hsz.mobi) ### Node template # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env ================================================ FILE: ui/.storybook/config.js ================================================ import { configure } from '@storybook/react'; const req = require.context('../src/stories', true, /.stories.tsx$/); function loadStories() { require('../src/stories/index.ts'); } configure(loadStories, module); ================================================ FILE: ui/.storybook/preview-head.html ================================================ ================================================ FILE: ui/.storybook/webpack.config.js ================================================ const path = require('path'); const {CheckerPlugin} = require('awesome-typescript-loader'); module.exports = (baseConfig, env, defaultConfig) => { defaultConfig.module.rules.push({ test: /\.tsx?$/, include: path.resolve(__dirname, '../src'), loader: require.resolve('awesome-typescript-loader') }); defaultConfig.plugins.push(new CheckerPlugin()); defaultConfig.resolve.extensions.push('.ts', '.tsx'); // defaultConfig.module.rules.push({ // test: /\.jsx?$/, // include: [ // path.resolve(__dirname, "node_modules/semantic-ui-react"), // path.resolve(__dirname, "node_modules/byte-size") // ], // loader: "babel-loader" // }); return defaultConfig; }; ================================================ FILE: ui/_deprecated/AssistantPage.tsx ================================================ import * as React from 'react'; import { connect, Dispatch } from 'react-redux'; import { bindActionCreators } from 'redux'; import {RouteComponentProps} from 'react-router'; import {Assistant} from '../src/components/_deprecated/Assistant'; import {nextStep, prevStep} from '../src/actions/assistant'; import {newCertificateSigningRequest} from '../src/actions/signing_requests'; import {NextStepAction, PrevStepAction} from "../src/actions/assistant"; import {APNSConfiguration} from './assistant/APNSConfiguration'; import {SSLConfiguration} from "./assistant/SSLConfiguration"; import {SCEPConfiguration} from "./assistant/SCEPConfiguration"; import {FinalStep} from "./assistant/FinalStep"; import {RootState} from "../src/reducers/index"; import {AssistantState} from "../src/reducers/assistant"; interface AssistantPageStateProps { assistant: AssistantState; } interface AssistantPageDispatchProps { nextStep: () => NextStepAction; prevStep: () => PrevStepAction; newCertificateSigningRequest: (purpose: string) => void; } interface OwnProps { handleGenerateSSLCSR: () => void; } interface AssistantPageProps extends AssistantPageDispatchProps, AssistantPageStateProps, RouteComponentProps { } @connect( (state: RootState, ownProps?: any): AssistantPageStateProps => { return {assistant: state.assistant}; }, (dispatch: Dispatch): AssistantPageDispatchProps => { return bindActionCreators({ nextStep, prevStep, newCertificateSigningRequest }, dispatch); } ) export class AssistantPage extends React.Component { handleGenerateSSLCSR = (): void => { console.log('generating an SSL CSR'); this.props.newCertificateSigningRequest('ssl'); }; render() { const { children, currentStep, totalSteps, nextStep, prevStep } = this.props; const steps = [ , , , ]; return (
) } } ================================================ FILE: ui/_deprecated/DeviceGroupPage.tsx ================================================ import * as React from 'react'; import {connect, Dispatch} from 'react-redux'; import {RootState} from "../src/reducers/index"; import {bindActionCreators} from "redux"; import Container from "semantic-ui-react/src/elements/Container"; import Header from "semantic-ui-react/src/elements/Header"; import {DeviceGroupForm, FormData as DeviceGroupFormData} from "../src/forms/DeviceGroupForm"; import { post, PostActionRequest, read, ReadActionRequest } from "../src/actions/device_groups"; import {RouteComponentProps} from "react-router"; import Griddle, {RowDefinition, ColumnDefinition} from 'griddle-react'; import {DeviceGroup} from "../src/store/device_groups/types"; import {JSONAPIDetailResponse} from "../src/json-api"; import {SemanticUIPlugin} from "../src/griddle-plugins/semantic-ui/index"; import {SimpleLayout as Layout} from "../src/components/griddle/SimpleLayout"; import {Device} from "../src/store/device/types"; interface RouteProps { id?: string; } interface OwnProps extends RouteComponentProps { handleSubmit: (values: DeviceGroupFormData) => void; } interface ReduxStateProps { device_group: JSONAPIDetailResponse; } function mapStateToProps(state: RootState, ownProps?: OwnProps): ReduxStateProps { return { device_group: state.device_groups.editing }; } interface ReduxDispatchProps { post: PostActionRequest; read: ReadActionRequest; } function mapDispatchToProps(dispatch: Dispatch, ownProps?: OwnProps) { return bindActionCreators({ post, read }, dispatch); } class BaseDeviceGroupPage extends React.Component { componentWillMount?() { if (this.props.match.params.id) { this.props.read(this.props.match.params.id, ['devices']); } } handleSubmit = (values: DeviceGroupFormData) => { if (this.props.match.params.id) { // this.props.patch() } else { this.props.post(values); } }; render() { const { device_group } = this.props; let initialValues: any; if (device_group) { initialValues = device_group.data.attributes; } return (
Device Group
Members
) } } export const DeviceGroupPage = connect( mapStateToProps, mapDispatchToProps)(BaseDeviceGroupPage); ================================================ FILE: ui/_deprecated/DeviceGroupsPage.tsx ================================================ import Griddle, {ColumnDefinition, RowDefinition} from "griddle-react"; import * as React from "react"; import {connect, Dispatch} from "react-redux"; import {Link} from "react-router-dom"; import {bindActionCreators} from "redux"; import {RootState} from "../src/reducers/index"; import Grid from "semantic-ui-react/src/collections/Grid"; import Button from "semantic-ui-react/src/elements/Button"; import Container from "semantic-ui-react/src/elements/Container"; import Header from "semantic-ui-react/src/elements/Header"; import {RouteComponentProps} from "react-router"; import {index, IndexActionRequest} from "../src/actions/device_groups"; import {RouteLinkColumn} from "../src/components/griddle/RouteLinkColumn"; import {SimpleLayout} from "../src/components/griddle/SimpleLayout"; import {SelectionPlugin} from "../src/griddle-plugins/selection/index"; import {SemanticUIPlugin} from "../src/griddle-plugins/semantic-ui/index"; import {griddle, GriddleDecoratorState} from "../src/hoc/griddle"; import {DeviceGroupsState} from "../src/reducers/device_groups"; interface ReduxStateProps { device_groups: DeviceGroupsState; } function mapStateToProps(state: RootState, ownProps?: any): ReduxStateProps { return { device_groups: state.device_groups, }; } interface ReduxDispatchProps { index: IndexActionRequest; } function mapDispatchToProps(dispatch: Dispatch): ReduxDispatchProps { return bindActionCreators({ index, }, dispatch); } interface DeviceGroupsPageProps extends ReduxStateProps, ReduxDispatchProps, RouteComponentProps { griddleState: GriddleDecoratorState; events: any; } interface DeviceGroupsPageState { } class UnconnectedDeviceGroupsPage extends React.Component { componentWillMount?() { this.props.index(); } render() { const { device_groups, griddleState, } = this.props; return (
Groups
console.log("fmeh")}>
); } } export const DeviceGroupsPage = connect( mapStateToProps, mapDispatchToProps, )(griddle(UnconnectedDeviceGroupsPage)); ================================================ FILE: ui/_deprecated/InternalCAPage.tsx ================================================ import * as React from 'react'; import { connect, Dispatch } from 'react-redux'; import {RouteComponentProps} from 'react-router'; import { IndexActionRequest, index, DeleteCertificateActionRequest, remove } from "../src/actions/certificates"; import * as caActions from '../src/actions/certificates/ca'; import {bindActionCreators} from "redux"; import {CertificateDetail} from '../src/components/_deprecated/CertificateDetail'; import {CAState} from "../src/reducers/certificates/ca"; import {CAConfigurationForm} from '../src/forms/_retired/CAConfigurationForm'; interface CAPageState { ca: CAState; } interface CAPageDispatchProps { remove: DeleteCertificateActionRequest; fetchCACertificates: caActions.FetchCACertificatesActionRequest; } interface CAPageProps extends CAPageState, CAPageDispatchProps, RouteComponentProps { } @connect( (state: any, ownProps?: any): CAPageState => { return { ca: state.certificates.ca } }, (dispatch: Dispatch): CAPageDispatchProps => { return bindActionCreators({ fetchCACertificates: caActions.fetchCACertificates, remove }, dispatch); } ) export class InternalCAPage extends React.Component { componentWillMount?() { this.props.fetchCACertificates(); } handleDeleteCertificate = (certificateId: number): void => { this.props.remove(certificateId); }; handleDownloadCertificate = (certificateId: number): void => { window.location.href = `/api/v1/certificates/${certificateId}/download`; }; handleSubmit = (values: FormData): void => { }; render(): JSX.Element { const { ca } = this.props; let caCertificate; if (ca && ca.items) { caCertificate = ca.items.data[0]; } return (

Internal CA

Issued certificates

) } } ================================================ FILE: ui/_deprecated/MDMPage.tsx ================================================ import * as React from 'react'; import { connect, Dispatch } from 'react-redux'; import {RouteComponentProps} from 'react-router'; import { MDMConfigurationForm, FormData } from '../src/forms/_retired/MDMConfigurationForm'; import {FetchCertificateTypeActionRequest, fetchCertificatesForType} from "../src/actions/certificates"; import {bindActionCreators} from "redux"; import {JSONAPIDetailResponse} from "../src/json-api"; import {Certificate} from "../src/store/certificates/types"; interface MDMPageState { byType?: {[propName: string]: JSONAPIDetailResponse}; } interface MDMPageDispatchProps { fetchCertificatesForType: FetchCertificateTypeActionRequest; } interface MDMPageProps extends MDMPageState, MDMPageDispatchProps, RouteComponentProps { } @connect( (state: any, ownProps?: any): MDMPageState => { return { byType: state.certificates.byType || null } }, (dispatch: Dispatch): MDMPageDispatchProps => { return bindActionCreators({ fetchCertificatesForType }, dispatch); } ) export class MDMPage extends React.Component, MDMPageState> { componentWillMount?(): void { this.props.fetchCertificatesForType('mdm.pushcert'); this.props.fetchCertificatesForType('mdm.cacrt'); } handleSubmit = (values: FormData) => { }; render() { const { byType } = this.props; const pushCertificate = byType['mdm.pushcert']; const CACertificate = byType['mdm.cacrt']; return (

MDM Configuration

) } } ================================================ FILE: ui/_deprecated/SCEPConfigurationForm.tsx ================================================ import * as React from 'react'; import {Field, reduxForm, FormProps} from 'redux-form'; import {Header, Icon, Segment, Message, Input, Button, Grid, Form} from 'semantic-ui-react'; import {SemanticInput} from "../src/forms/fields/SemanticInput"; export interface FormData { scep_type: 'internal' | 'external'; scep_url: string; scep_challenge: string; scep_subject: string; } interface SCEPConfigurationFormProps extends FormProps { } @reduxForm({ form: 'scep_configuration' }) export class SCEPConfigurationForm extends React.Component { render() { const { handleSubmit } = this.props; return (

Your devices will contact this server directly to request their identity certificate. Use this if you are testing or developing commandment.

Devices will contact an external service to request their identity certificate.

) } } ================================================ FILE: ui/_deprecated/SSLPage.tsx ================================================ import * as React from 'react'; import { connect, Dispatch } from 'react-redux'; import {RouteComponentProps} from 'react-router'; import { IndexActionRequest, index, DeleteCertificateActionRequest, remove } from "../src/actions/certificates"; import * as pushActions from '../src/actions/certificates/push'; import * as sslActions from '../src/actions/certificates/ssl'; import {bindActionCreators} from "redux"; import {CertificateDetail} from '../src/components/_deprecated/CertificateDetail'; import * as Upload from 'rc-upload'; import {PushState} from "../src/reducers/certificates/push"; import {SSLState} from "../src/reducers/certificates/ssl"; interface SSLPageState { push: PushState; ssl: SSLState; } interface SSLPageDispatchProps { index: IndexActionRequest; remove: DeleteCertificateActionRequest; fetchPushCertificates: pushActions.FetchPushCertificatesActionRequest; fetchSSLCertificates: sslActions.FetchSSLCertificatesActionRequest; } interface SSLPageProps extends SSLPageState, SSLPageDispatchProps, RouteComponentProps { } @connect( (state: any, ownProps?: any): SSLPageState => { return { push: state.certificates.push, ssl: state.certificates.ssl } }, (dispatch: Dispatch): SSLPageDispatchProps => { return bindActionCreators({ index, fetchPushCertificates: pushActions.fetchPushCertificates, fetchSSLCertificates: sslActions.fetchSSLCertificates, remove }, dispatch); } ) export class SSLPage extends React.Component { componentWillMount?() { this.props.fetchPushCertificates(); this.props.fetchSSLCertificates(); } handleDeleteCertificate = (certificateId: number): void => { this.props.remove(certificateId); }; handleDownloadCertificate = (certificateId: number): void => { window.location.href = `/api/v1/certificates/${certificateId}/download`; }; render(): JSX.Element { const { push, ssl } = this.props; let pushCertificate; if (push && push.items) { pushCertificate = push.items.data[0]; } let sslCertificate; if (ssl && ssl.items) { sslCertificate = ssl.items.data[0]; } return (

SSL Configuration

) } } ================================================ FILE: ui/_deprecated/assistant/APNSConfiguration.tsx ================================================ /// import * as React from 'react'; import * as Upload from 'rc-upload'; interface APNSConfigurationProps { } export class APNSConfiguration extends React.Component { handleReady = (): void => { console.log('ready'); }; handleStart = (): void => { console.log('start'); }; handleError = (): void => { console.log('er'); }; handleSuccess = (): void => { console.log('success'); }; render(): JSX.Element { return (
Push Certificate

The MDM requires a Push Certificate to communicate with devices.

Upload a Push Certificate (PEM)

OR

Generate CSR

a signing request

) } } ================================================ FILE: ui/_deprecated/assistant/FinalStep.tsx ================================================ import * as React from 'react'; interface FinalStepProps { } export class FinalStep extends React.Component { render() { return (
Success

Congratulations, your commandment server is set up!

If your devices are not DEP provisioned, use the link below to download an enrollment profile.

) } } ================================================ FILE: ui/_deprecated/assistant/SCEPConfiguration.tsx ================================================ import * as React from 'react'; import {SCEPConfigurationForm} from '../SCEPConfigurationForm'; interface SCEPConfigurationProps { } export class SCEPConfiguration extends React.Component { handleLoaded = (loaded: { urls: Array, target: any }) => { console.dir(loaded.urls); }; handleError = (err: Error) => { console.log(err); }; render() { return (
SCEP Configuration

Devices need to request identity certificates to prove that they are enrolled in your MDM. This is done through a SCEP service. You can use the built-in service for testing, or provide information about your production SCEP server.

) } } ================================================ FILE: ui/_deprecated/assistant/SSLConfiguration.tsx ================================================ import * as React from 'react'; import * as Upload from 'rc-upload'; interface SSLConfigurationProps { onClickGenerateCSR: () => void; SSLSigningRequest?: any; } export class SSLConfiguration extends React.Component { handleLoaded = (urls: Array) => { console.dir(urls); }; handleError = (err: Error) => { console.log(err); }; handleGenerateCSR = (event: any): void => { event.preventDefault(); this.props.onClickGenerateCSR(); }; render() { const { SSLSigningRequest } = this.props; return (
Web Certificate

You need to generate an SSL Certificate to encrypt communications between the device and the MDM

Upload an SSL Certificate

OR

a signing request

) } } ================================================ FILE: ui/babel.config.js ================================================ module.exports = function(api) { api.cache(true); return { plugins: [ "@babel/plugin-proposal-export-default-from", "@babel/plugin-syntax-jsx", "@babel/plugin-transform-react-jsx", "@babel/plugin-transform-react-display-name", "@babel/plugin-proposal-class-properties", "@babel/plugin-proposal-export-namespace-from", "react-hot-loader/babel" ], presets: [ ], }; }; ================================================ FILE: ui/package.json ================================================ { "name": "commandment-ui", "version": "1.0.0", "description": "UI for commandment", "main": "index.js", "scripts": { "start": "./node_modules/.bin/webpack-dev-server", "storybook": "start-storybook -p 6006 -c .storybook", "build-storybook": "build-storybook", "lint": "eslint . --ext .ts,.tsx", "lint-fix": "eslint . --ext .ts,.tsx --fix" }, "author": "mosen", "license": "MIT", "private": true, "devDependencies": { "@storybook/addon-actions": "^5.0.11", "@storybook/addons": "^5.0.11", "@storybook/react": "^5.0.11", "@typescript-eslint/eslint-plugin": "^1.9.0", "@typescript-eslint/parser": "^1.9.0", "babel-runtime": "^6.26.0", "eslint": "^5.16.0", "eslint-plugin-react": "^7.13.0", "webpack-bundle-analyzer": "^3.3.2", "webpack-cli": "^3.1.2", "webpack-dev-server": "^3.1.4", "webpack-hot-middleware": "^2.18.2" }, "dependencies": { "@babel/core": "^7.1.6", "@babel/plugin-proposal-class-properties": "^7.1.0", "@babel/plugin-proposal-export-default-from": "^7.0.0", "@babel/plugin-proposal-export-namespace-from": "^7.0.0", "@babel/plugin-syntax-jsx": "^7.0.0", "@babel/plugin-transform-react-display-name": "^7.0.0", "@babel/plugin-transform-react-jsx": "^7.1.6", "@types/fetch-jsonp": "^1.0.0", "@types/history": "^4.6.0", "@types/lodash": "^4.14.119", "@types/lodash-es": "^4.17.0", "@types/prop-types": "^15.5.8", "@types/react": "^16.8.17", "@types/react-dom": "^16.8.4", "@types/react-dropzone": "^4.2.2", "@types/react-hot-loader": "^4.1.0", "@types/react-redux": "^7.0.9", "@types/react-router": "^5.0.0", "@types/react-router-dom": "^4.3.3", "@types/react-table": "^6.8.3", "@types/recompose": "^0.30.6", "@types/redux-devtools": "^3.0.46", "@types/semver": "^6.0.0", "@types/storybook__addon-actions": "^3.4.3", "@types/storybook__react": "^4.0.1", "@types/webpack": "^4.4.32", "@types/webpack-env": "^1.13.9", "@types/yup": "^0.26.1", "awesome-typescript-loader": "^5.0.0", "babel-loader": "^8.0.6", "babel-plugin-lodash": "^3.3.4", "babel-preset-env": "^1.7.0", "byte-size": "^5.0.1", "connected-react-router": "^6.4.0", "css-loader": "2.1.1", "date-fns": "^1.29.0", "extract-text-webpack-plugin": "^4.0.0-beta.0", "fetch-jsonp": "^1.1.3", "file-loader": "^3.0.1", "font-awesome": "^4.7.0", "formik": "^1.5.7", "formik-semantic-ui": "^0.9.2", "history": "^4.7.2", "immutable": "^4.0.0-rc.12", "is-arrayish": "^0.3.1", "moment": "^2.19.1", "node-sass": "^4.5.1", "prop-types": "^15.5.10", "react": "^16.8.6", "react-dom": "^16.8.6", "react-dropzone": "^10.1.5", "react-hot-loader": "^4.2.0", "react-redux": "^7.0.3", "react-router": "^5.0.0", "react-router-dom": "^5.0.0", "react-simple-pie-chart": "^0.5.0", "react-table": "^6.9.2", "recompose": "^0.30.0", "redux": "^4.0.1", "redux-api-middleware": "^3.0.1", "redux-debounce": "^1.0.1", "redux-thunk": "^2.3.0", "reselect": "^4.0.0", "resolve-url-loader": "^3.1.0", "sass-loader": "^7.1.0", "semantic-ui-css": "^2.4.1", "semantic-ui-react": "^0.87.1", "semver": "^6.0.0", "style-loader": "^0.23.0", "typescript": "^3.4.4", "typescript-plugin-lodash": "^0.1.0", "url-loader": "^1.1.2", "webpack": "^4.31.0", "yup": "^0.27.0" }, "resolutions": { "@types/react": "^16.8.17", "**/@types/react": "^16.8.17" } } ================================================ FILE: ui/sass/_dropzone.scss ================================================ .dropzone { margin: 1rem 0; height: 5rem; border: 1px solid rgba(34,36,38,.1); background-color: #F9FAFB; text-align: center; cursor: pointer; .ui.header { line-height: 5rem; } } ================================================ FILE: ui/sass/_helper.scss ================================================ @import 'settings'; .error { font-weight: bold; color: $highlight-color; } ================================================ FILE: ui/sass/_nav.scss ================================================ *, *:before, *:after { box-sizing: inherit; } //.navigation { // box-sizing: border-box; // display: flex; // align-items: center; // width: 100%; // background: #eee; // padding: 20px; //} nav { background: #eee; } // top-level menu nav ul { margin: 0; list-style: none; position: relative; display: flex; //padding: 1rem 0; // same as milligram body padding li { float: left; margin: 0; // cancel out the milligram default margin a { padding: 1rem 2rem; display: block; } span { // if not using a link display: inline-block; padding: 1rem 2rem; } } li:hover { // hover style } // make submenu active li:hover > ul { display: block; } li:active > ul { display: block; } } nav ul:after { content: ""; clear: both; display: block; } // submenu nav ul ul { z-index: 99; background-color: #9b4dca; position: absolute; top: 100%; display: none; padding: 0; li { float: none; position: relative; a { padding: 1rem 2rem; color: #fff; } } } ================================================ FILE: ui/sass/_settings.scss ================================================ $highlight-color: #d241c7; ================================================ FILE: ui/sass/_upload.scss ================================================ .ap-upload-input { background-color: #eee; border: 1px solid #aaa; border-radius: 0.4rem; } ================================================ FILE: ui/sass/app.scss ================================================ @import '~semantic-ui-css/semantic.min.css'; @import '~font-awesome/scss/font-awesome'; @import '~react-table/react-table.css'; @import 'settings'; @import 'helper'; @import 'upload'; @import 'nav'; @import 'dropzone'; .top-margin { margin-top: 2rem; } /* intended to be used where a button must appear next to an input with a label on top */ .form-field-button { margin-bottom: 1.5rem; } .paper { box-shadow: rgba(0, 0, 0, 0.117647) 0 1px 6px, rgba(0, 0, 0, 0.117647) 0 1px 4px; } .centered { text-align: center; } .padded { padding: 1.6rem; } .padded-sides { padding: 0 1.6rem; } .reversed { background-color: #9b4dca; color: white; } .title { font-size: 2.4rem; } .text-middle { vertical-align: middle; } .warning { color: red; } // TEST: Padding glyphicons in headers h3 i { margin-right: 0.8rem; } dl.horizontal { dt { width: 20%; float: left; font-weight: bold; } dd { margin: 0; } dd:after { content: ''; display: block; clear: both; } } // Padding out icons in lists because they never line up in semantic-ui .ui.list > .item > i.icon { min-width: 40px; } ================================================ FILE: ui/src/@types/byte-size/index.d.ts ================================================ declare module "byte-size" { export = byteSize function byteSize(bytes: number, options?: byteSize.Options): byteSize.ByteSize; namespace byteSize { export interface Options { precision: number; units: "metric" | "iec" | "metric_octet" | "iec_octet" } export interface ByteSize { value: string; unit: string; toString(): string; } } } ================================================ FILE: ui/src/@types/redux-api-middleware/index.d.ts ================================================ declare module "redux-api-middleware" { import {Action, AnyAction, Middleware} from "redux"; /** * Symbol key that carries API call info interpreted by this Redux middleware. * * @constant {string} * @access public * @default */ export const RSAA: string; export type RSAA = "@@redux-api-middleware/RSAA"; //// ERRORS export enum ErrorNames { ApiError = "ApiError", InternalError = "InternalError", InvalidRSAA = "InvalidRSAA", RequestError = "RequestError", } /** * Error class for an RSAA that does not conform to the RSAA definition * * @class InvalidRSAA * @access public * @param {array} validationErrors - an array of validation errors */ export class InvalidRSAA extends Error { public name: ErrorNames.InvalidRSAA; public message: string; public validationErrors: string[]; constructor(validationErrors: string[]); } /** * Error class for a custom `payload` or `meta` function throwing * * @class InternalError * @access public * @param {string} message - the error message */ export class InternalError extends Error { public name: ErrorNames.InternalError; public message: string; constructor(message: string); } /** * Error class for an error raised trying to make an API call * * @class RequestError * @access public * @param {string} message - the error message */ export class RequestError extends Error { public name: ErrorNames.RequestError; public message: string; constructor(message: string); } /** * Error class for an API response outside the 200 range * * @class ApiError * @access public * @param {number} status - the status code of the API response * @param {string} statusText - the status text of the API response * @param {object} response - the parsed JSON response of the API server if the * 'Content-Type' header signals a JSON response */ export class ApiError extends Error { public name: ErrorNames.ApiError; public message: string; public status: number; public statusText: string; public response?: R; constructor(status: number, statusText: string, response: R); } //// VALIDATION /** * Is the given action a plain JavaScript object with a [RSAA] property? */ export function isRSAA(action: any): action is RSAAction; /** * The README explains the following criteria for a TypeDescriptor: * * A type descriptor **MUST**: * - be a plain JavaScript object * - have a `type` property, which **MUST** be a string or a `Symbol`. */ export interface TypeDescriptor { type: string | TSymbol; payload?: TPayload; meta?: TMeta; } /** * Is the given object a valid type descriptor? */ export function isValidTypeDescriptor(obj: object): obj is TypeDescriptor; /** * Checks an action against the RSAA definition, returning a (possibly empty) * array of validation errors. */ export function validateRSAA(action: any): string[]; /** * Is the given action a valid RSAA? */ export function isValidRSAA(action: any): boolean; //// MIDDLEWARE export interface MiddlewareOptions { // Determines whether the response is an error ok: (res: any) => boolean; fetch: GlobalFetch; } /** * Create middleware with custom options. */ export function createMiddleware(options?: MiddlewareOptions): Middleware; /** * A Redux middleware that processes RSAA actions. */ export const apiMiddleware: Middleware; //// UTIL /** * Extract JSON body from a server response */ export function getJSON(res: Response): PromiseLike | undefined; export type RSAActionTypeTuple = [string | symbol, string | symbol, string | symbol]; /** * Blow up string or symbol types into full-fledged type descriptors, * and add defaults */ export function normalizeTypeDescriptors(types: RSAActionTypeTuple): RSAActionTypeTuple; export type HTTPVerb = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; export interface RSAActionBody { endpoint: string; // or function method: HTTPVerb; body?: any; headers?: { [propName: string]: string }; // or function credentials?: "omit" | "same-origin" | "include"; bailout?: boolean; // or function types: [R, S, F]; } export enum Credentials { omit = "omit", sameOrigin = "same-origjn", include = "include", } export type RSAAActionType = string | TypeDescriptor; export type RSAAActionTypes = [RSAAActionType, RSAAActionType, RSAAActionType]; export interface RSAAction { [propName: string]: { // Symbol as object key seems impossible endpoint: string; // or function method: HTTPVerb; types: [TRequest, TSuccess, TFail]; body?: any; headers?: any; // or function options?: any; credentials?: Credentials; bailout?: boolean; // or function fetch?: GlobalFetch; ok?: any; } } //// Augmentations module "redux" { export interface AnyAction { "@@redux-api-middleware/RSAA"?: RSAActionBody; } } } ================================================ FILE: ui/src/components/ActionMenu.tsx ================================================ import * as React from "react"; import {Dropdown} from "semantic-ui-react"; export enum UIActionTypes { BLANK_PUSH = "BLANK_PUSH", CLEAR_PASSCODE = "CLEAR_PASSCODE", FULL_INVENTORY = "FULL_INVENTORY", } export interface IActionMenu { enabledActions: UIActionTypes[]; } export const ActionMenu: React.FunctionComponent = (props: IActionMenu) => ( ); ================================================ FILE: ui/src/components/App.tsx ================================================ import * as React from "react"; import {hot} from "react-hot-loader"; /** * AppLayout is the top level root display component. * * It is recommended to keep this as a class and not a stateless component, due to earlier issues with react-router not * updating children. * * It is also recommended to keep this as an unconnected component for the same reason. * * @see https://github.com/ReactTraining/react-router/issues/4975 */ class AppCool extends React.Component<{}, {}> { public render() { const {children} = this.props; return (
{children}
); } } export const App = hot(module)(AppCool); ================================================ FILE: ui/src/components/BareLayout.tsx ================================================ import * as React from "react"; import {Grid} from "semantic-ui-react"; import {Route, RouteProps} from "react-router"; import {ComponentClass, FunctionComponent} from "react"; interface INavigationLayout { component: ComponentClass; } export const BareLayout: FunctionComponent = ({ Component: component, ...rest }) => ( ( )} /> ); ================================================ FILE: ui/src/components/CertificateTypeIcon.tsx ================================================ import * as React from "react"; import {Icon} from "semantic-ui-react"; interface CertificateTypeIconProps { value: number; title: string; } export const CertificateTypeIcon: React.StatelessComponent = (props: CertificateTypeIconProps): JSX.Element => { return ; }; ================================================ FILE: ui/src/components/CheckListItem.tsx ================================================ import * as React from "react"; import {List} from "semantic-ui-react"; interface ICheckListItemProps { title: string; description?: string; value: any; // will be interpreted as boolean children?: JSX.Element[] | JSX.Element; } export const CheckListItem: React.FunctionComponent = ({ title, value, description, children }: ICheckListItemProps) => ( {value ? : } {title} {children && {children} } ); ================================================ FILE: ui/src/components/DeviceActions.tsx ================================================ import * as React from 'react'; interface DeviceActionsProps { } export const DeviceActions: React.StatelessComponent = (props: DeviceActionsProps) => (
); ================================================ FILE: ui/src/components/Navigation.scss ================================================ ================================================ FILE: ui/src/components/Navigation.tsx ================================================ import * as React from "react"; import {Menu} from "semantic-ui-react"; import {MenuItemLink} from "../components/semantic-ui/MenuItemLink"; import "./Navigation.scss"; export interface INavigationProps { } export const Navigation: React.StatelessComponent = (props: INavigationProps) => ( CMDMNT Devices Profiles Applications Settings ); ================================================ FILE: ui/src/components/NavigationLayout.tsx ================================================ import * as React from "react"; import {Grid} from "semantic-ui-react"; import {NavigationVertical} from "./NavigationVertical"; import {RouteComponentProps} from "react-router"; import {ComponentProps, FunctionComponent} from "react"; export const NavigationLayout: FunctionComponent = (props: RouteComponentProps & ComponentProps) => ( {props.children} ); ================================================ FILE: ui/src/components/NavigationVertical.tsx ================================================ import * as React from "react"; import {MenuItemLink} from "../components/semantic-ui/MenuItemLink"; import "./Navigation.scss"; import {RouteComponentProps} from "react-router"; import {Divider, Sidebar, Menu} from "semantic-ui-react"; interface IRouteProps { } export interface INavigationVerticalProps extends RouteComponentProps { } export const NavigationVertical: React.FC = (props: INavigationVerticalProps) => ( CMDMNT Devices Profiles Applications Settings Logout ); ================================================ FILE: ui/src/components/ProtectedRoute.tsx ================================================ import * as React from "react"; import {connect} from "react-redux"; import {FunctionComponent, Component} from "react"; import {Redirect, Route} from "react-router"; import {RootState} from "../reducers"; export interface IProtectedRoute { component: Component; access_token: string; } const UnconnectedProtectedRoute: FunctionComponent = ({component: Component, access_token, ...rest}: Partial) => ( access_token ? ( ) : ( )} /> ); export const ProtectedRoute = connect((state: RootState) => { return { access_token: state.auth.access_token, expires_in: state.auth.expires_in, } }, null)(UnconnectedProtectedRoute); ================================================ FILE: ui/src/components/RSAAApiErrorMessage.tsx ================================================ import * as React from "react"; import {ApiError} from "redux-api-middleware"; import {Message} from "semantic-ui-react"; import {JSONAPIErrorObject, JSONAPIErrorResponse} from "../store/json-api"; export interface IRSAAApiErrorMessageProps { error: ApiError; } export const RSAAApiErrorMessage: React.FunctionComponent = (props: IRSAAApiErrorMessageProps) => ( `${err.detail}`), ]} /> ); ================================================ FILE: ui/src/components/SearchInput.tsx ================================================ import * as React from "react"; import {Input, InputOnChangeData, InputProps} from "semantic-ui-react"; import Timeout = NodeJS.Timeout; export interface ISearchInputProps { duration: number; loading: boolean; onSearch: (value: string) => void; } export interface ISearchInputState { value: string; timeout: Timeout; } export class SearchInput extends React.Component { // public static initialState: ISearchInputState = { // timeout: null, // value: "", // }; constructor(props: ISearchInputProps) { super(props); this.state = { timeout: null, value: "" }; } public render() { const { loading } = this.props; return ( ) } private handleTimeout = (e: any) => { this.props.onSearch(this.state.value); this.setState({ timeout: null }); }; private handleChange = (event: any, data: InputOnChangeData) => { if (this.state.timeout) { clearTimeout(this.state.timeout); } const timeout = setTimeout(this.handleTimeout, this.props.duration | 400); this.setState({ timeout, value: data.value }); }; } ================================================ FILE: ui/src/components/TagDropdown.tsx ================================================ import * as React from "react"; import {SyntheticEvent} from "react"; import {JSONAPIDataObject} from "../store/json-api"; import {Tag} from "../store/tags/types"; import { Dropdown, DropdownProps } from "semantic-ui-react"; // Not exported by Dropdown interface IDropdownOnSearchChangeData extends DropdownProps { searchQuery: string; } interface ITagDropdownProps { loading: boolean; tags: Array>; value?: any[]; onAddItem: (event: SyntheticEvent, data: object) => void; onSearch: (value: string) => void; onChange: (event: SyntheticEvent, values: string[]) => void; searchTimeout: number; } interface ITagDropdownState { value?: string; } export class TagDropdown extends React.Component { private timeout: number; constructor(props: ITagDropdownProps) { super(props); this.state = { value: "", }; } public render() { const { tags, loading, onAddItem, value, onChange } = this.props; const options = tags.map((item: JSONAPIDataObject) => { return { key: item.id, label: { color: item.attributes.color, empty: true, circular: true }, text: item.attributes.name, value: item.id, }; }); return ( ); } private performSearch = () => { console.log("perform search"); this.props.onSearch(this.state.value); }; private handleSearchChange = (event: React.SyntheticEvent, data: IDropdownOnSearchChangeData): void => { console.log("change"); if (this.timeout) { clearTimeout(this.timeout); } this.setState({ value: data.searchQuery }); if (data.length > 0) { this.timeout = window.setTimeout(this.performSearch, 400); } }; } ================================================ FILE: ui/src/components/devices/DEPDeviceDetail.tsx ================================================ import {FunctionComponent} from "react"; import {DeviceState} from "../../store/device/reducer"; import * as React from "react"; import {format} from "date-fns"; import { Divider, Grid, Button, Header, List, Message } from "semantic-ui-react"; export interface IDEPDeviceDetailProps { device: DeviceState; } export const DEPDeviceDetail: FunctionComponent = ({device, ...props}: IDEPDeviceDetailProps) => { return (
); }; ================================================ FILE: ui/src/components/devices/IOSDeviceDetail.tsx ================================================ import {distanceInWordsToNow} from "date-fns"; import * as React from "react"; import {DeviceState} from "../../store/device/reducer"; import {Route} from "react-router"; import {DeviceRename} from "../../containers/DeviceRename"; import {DeviceApplications} from "../../containers/devices/DeviceApplications"; import {DeviceCertificates} from "../../containers/devices/DeviceCertificates"; import {DeviceCommands} from "../../containers/devices/DeviceCommands"; import {DeviceDetail} from "../../containers/devices/DeviceDetail"; import {DeviceOSUpdates} from "../../containers/devices/DeviceOSUpdates"; import {DeviceProfiles} from "../../containers/devices/DeviceProfiles"; import { ClearPasscodeActionRequest, InventoryActionRequest, LockActionRequest, PushActionRequest, RestartActionRequest, ShutdownActionRequest, TestActionRequest, } from "../../store/device/actions"; import {ButtonLink} from "../semantic-ui/ButtonLink"; import {MenuItemLink} from "../semantic-ui/MenuItemLink"; import {TagDropdown} from "../TagDropdown"; import { Divider, Icon, Segment, Grid, Menu, Button, Header, List, DropdownProps, DropdownItemProps, } from "semantic-ui-react"; import {SyntheticEvent} from "react"; import {ITagsState} from "../../store/tags/reducer"; import "./MacOSDeviceDetail.scss"; interface IIOSDeviceDetailProps { device: DeviceState; tags: ITagsState; deviceTags: number[]; onAddTag: (event: SyntheticEvent, data: object) => void; onChangeTag: (event: SyntheticEvent, data: DropdownProps) => void; onSearchTag: (value: string) => void; clearPasscode: ClearPasscodeActionRequest; inventory: InventoryActionRequest; lock: LockActionRequest; push: PushActionRequest; restart: RestartActionRequest; shutdown: ShutdownActionRequest; } export const IOSDeviceDetail: React.FunctionComponent = ({ device, tags, deviceTags, clearPasscode, inventory, lock, push, restart, shutdown, onAddTag, onChangeTag, onSearchTag, }: IIOSDeviceDetailProps) => { if (!device.device) { return (
No device
); } const attributes = device.device.attributes; const niceLastSeen = attributes.last_seen ? distanceInWordsToNow(attributes.last_seen, {addSuffix: true}) : "Never"; return (
); }; ================================================ FILE: ui/src/components/devices/MacOSDeviceDetail.scss ================================================ .MacOSDeviceDetail { i.icon { width: 1.5em; } } .ui.item { padding: 1em 0; } ================================================ FILE: ui/src/components/devices/MacOSDeviceDetail.tsx ================================================ import {distanceInWordsToNow} from "date-fns"; import * as React from "react"; import {FunctionComponent, SyntheticEvent} from "react"; import {DeviceState} from "../../store/device/reducer"; import {ModelIcon} from "./ModelIcon"; import {Route} from "react-router"; import {DeviceRename} from "../../containers/DeviceRename"; import {DeviceApplications} from "../../containers/devices/DeviceApplications"; import {DeviceCertificates} from "../../containers/devices/DeviceCertificates"; import {DeviceCommands} from "../../containers/devices/DeviceCommands"; import {DeviceDetail} from "../../containers/devices/DeviceDetail"; import {DeviceOSUpdates} from "../../containers/devices/DeviceOSUpdates"; import {DeviceProfiles} from "../../containers/devices/DeviceProfiles"; import { ClearPasscodeActionRequest, InventoryActionRequest, LockActionRequest, PushActionRequest, RestartActionRequest, ShutdownActionRequest, TestActionRequest, } from "../../store/device/actions"; import {ButtonLink} from "../semantic-ui/ButtonLink"; import {MenuItemLink} from "../semantic-ui/MenuItemLink"; import {TagDropdown} from "../TagDropdown"; import { Divider, Icon, Grid, Menu, Button, Header, List, DropdownProps } from "semantic-ui-react"; import {ITagsState} from "../../store/tags/reducer"; import "./MacOSDeviceDetail.scss"; interface IMacOSDeviceDetailProps { device: DeviceState; tags: ITagsState; deviceTags: number[]; onAddTag: (event: SyntheticEvent, data: object) => void; onChangeTag: (event: SyntheticEvent, data: DropdownProps) => void; onSearchTag: (value: string) => void; clearPasscode: ClearPasscodeActionRequest; inventory: InventoryActionRequest; lock: LockActionRequest; push: PushActionRequest; restart: RestartActionRequest; shutdown: ShutdownActionRequest; } export const MacOSDeviceDetail: FunctionComponent = ({ device, tags, deviceTags, clearPasscode, inventory, lock, push, restart, shutdown, onAddTag, onChangeTag, onSearchTag, }: IMacOSDeviceDetailProps) => { if (!device.device) { return (
No device
); } const attributes = device.device.attributes; const niceLastSeen = attributes.last_seen ? distanceInWordsToNow(attributes.last_seen, { addSuffix: true }) : "Never"; return (
); }; ================================================ FILE: ui/src/components/devices/ModelIcon.tsx ================================================ import * as React from "react"; import {SemanticICONS} from "semantic-ui-react"; import {Icon} from "semantic-ui-react"; interface IModelIconProps { value: string; title: string; } export const ModelIcon = (props: IModelIconProps): JSX.Element => { const icons: { [propName: string]: SemanticICONS; } = { "Mac Pro": "computer", "MacBook Air": "laptop", "MacBook Pro": "laptop", "iMac": "desktop", "iPad": "tablet", "iPhone": "mobile", }; let className: SemanticICONS = "apple"; if (icons.hasOwnProperty(props.value)) { className = icons[props.value]; } return ; }; ================================================ FILE: ui/src/components/errors/ApiError.tsx ================================================ import * as React from "react"; import {ApiError} from "redux-api-middleware"; import {Message} from "semantic-ui-react"; export interface IApiErrorProps { error: ApiError; } export const ApiError: React.FC = ({ error }: IApiErrorProps) => ( Unhandled API Error. This might be a bug

{ error.response.code }

); ================================================ FILE: ui/src/components/formik/FormikCheckbox.tsx ================================================ import {Field, FieldConfig, FieldProps} from "formik"; import * as React from "react"; import {Form, FormProps, Checkbox, CheckboxProps} from "semantic-ui-react"; export type IFormikCheckbox = FieldConfig & CheckboxProps; export const FormikCheckbox: React.SFC = ({ id, name, label, toggle, }) => ( { const error = form.touched[name] && form.errors[name]; return ( {error ? ( {form.errors[name]} ) : null} ); }} /> ); ================================================ FILE: ui/src/components/forms/DEPAccountForm.tsx ================================================ import * as React from "react"; import {Form} from "semantic-ui-react"; export class DEPAccountForm extends React.Component { render() { return (
) } } ================================================ FILE: ui/src/components/forms/DEPProfileForm.tsx ================================================ import {Field, Form as FormikForm, Formik, FormikBag, FormikErrors, FormikProps, withFormik} from "formik"; import * as React from "react"; import { AccordionTitleProps, CheckboxProps, Form, Button, Divider, Icon, Label, Accordion, FormProps } from "semantic-ui-react"; import * as Yup from "yup"; import {DEPProfile, SkipSetupSteps} from "../../store/dep/types"; import {FormikCheckbox} from "../formik/FormikCheckbox"; // The major difference between the form values and the server-side model is that the skip values are boolean inverted // so that we can use the language "show" instead of hidden / unhide. export interface IDEPProfileFormValues { // show: not present on DEPProfile show: { [SkipSetupSteps: string]: boolean }; readonly id?: string; readonly uuid?: string; dep_account_id?: number; profile_name: string; url?: string; allow_pairing: boolean; is_supervised: boolean; is_multi_user: boolean; is_mandatory: boolean; await_device_configured: boolean; is_mdm_removable: boolean; support_phone_number: string; auto_advance_setup: boolean; support_email_address?: string; org_magic?: string; // inverted by the form // skip_setup_items: SkipSetupSteps[]; department?: string; } export interface IDEPProfileFormProps { data?: IDEPProfileFormValues; id?: string | number; loading: boolean; activeIndex: number; onSubmit: (values: IDEPProfileFormValues) => void; onClickAccordionTitle: (event: React.MouseEvent, data: AccordionTitleProps) => void; } export interface IDEPProfileFormState { activeIndex: number; } export enum DEPProfilePairWithOptions { AnyComputer = "AnyComputer", Certificates = "Certificates", } const initialValues: IDEPProfileFormValues = { allow_pairing: true, auto_advance_setup: false, await_device_configured: false, department: "", is_mandatory: false, is_mdm_removable: true, is_multi_user: false, is_supervised: true, org_magic: "", profile_name: "", show: { [SkipSetupSteps.AppleID]: true, [SkipSetupSteps.Biometric]: true, [SkipSetupSteps.Diagnostics]: true, [SkipSetupSteps.DisplayTone]: true, [SkipSetupSteps.Location]: true, [SkipSetupSteps.Passcode]: true, [SkipSetupSteps.Payment]: true, [SkipSetupSteps.Privacy]: true, [SkipSetupSteps.Restore]: true, [SkipSetupSteps.SIMSetup]: true, [SkipSetupSteps.Siri]: true, [SkipSetupSteps.TOS]: true, [SkipSetupSteps.Zoom]: true, [SkipSetupSteps.Android]: true, [SkipSetupSteps.HomeButtonSensitivity]: true, [SkipSetupSteps.iMessageAndFaceTime]: true, [SkipSetupSteps.OnBoarding]: true, [SkipSetupSteps.ScreenTime]: true, [SkipSetupSteps.SoftwareUpdate]: true, [SkipSetupSteps.WatchMigration]: true, [SkipSetupSteps.Appearance]: true, [SkipSetupSteps.FileVault]: true, [SkipSetupSteps.iCloudDiagnostics]: true, [SkipSetupSteps.iCloudStorage]: true, [SkipSetupSteps.Registration]: true, [SkipSetupSteps.ScreenSaver]: true, [SkipSetupSteps.TapToSetup]: true, [SkipSetupSteps.TVHomeScreenSync]: true, [SkipSetupSteps.TVProviderSignIn]: true, [SkipSetupSteps.TVRoom]: true, }, support_email_address: "", support_phone_number: "", }; export interface IInnerFormProps { activeIndex: number | string; id?: number | string; onClickAccordionTitle: (event: React.MouseEvent, data: AccordionTitleProps) => void; } const InnerForm = (props: IInnerFormProps & FormikProps) => { const { touched, errors, isSubmitting, activeIndex, handleChange, handleBlur, values, id, handleSubmit, onClickAccordionTitle } = props; return (
General {errors.profile_name && touched.profile_name && } {errors.support_phone_number && touched.support_phone_number && errors.support_phone_number} {errors.support_email_address && touched.support_email_address && errors.support_email_address} {errors.department && touched.department && errors.department} Setup Assistant Steps (Common) Setup Assistant Steps (iOS) Setup Assistant Steps (macOS) Setup Assistant Steps (tvOS)