I have been debugging an issue with Cloudflare One today - CF's official zero-trust client for Android - not receiving managed configurations through MDM.

While working through it, I noted their declared managed configuration comes in to AMAPI as:

"managedProperties": [
    {
      "key": "app_config_bundle_list",
      "type": "BUNDLE_ARRAY",
      "title": "App config list",
      "nestedProperties": [
        {
          "key": "app_config_bundle",
          "type": "BUNDLE",
          "title": "App config",
          "nestedProperties": [
            {
              "key": "organization",
              "type": "STRING",
              "title": "Organization",
              "description": "The user will be asked to sign into this organization. The name of the organization is case-insensitive."
            },
            {
              "key": "gateway_unique_id",
              "type": "STRING",
              "title": "Gateway Unique ID",
              "description": "Cloudflare Gateway DoH Subdomain. This option is not compatible with Cloudflare One for Families."
            },
            {
              "key": "service_mode",
              "type": "STRING",
              "title": "Service mode",
              "description": "Choose the mode in which the tunnel should run."
            },
            {
              "key": "onboarding",
              "type": "BOOL",
              "title": "Show onboarding",
              "description": "Show onboarding",
              "defaultValue": true
            },
            { so on, so forth.. }
          ]
        }
      ]
    }
  ],

Notably, they've built the configuration in their application as a bundle array, which is not something I'd normally expect to see as the one and only root configuration object..

..it also doesn't align with their documentation, which shows the app expects this flat configuration:

{
  "organization": "your-team-name",
  "gateway_unique_id": "your_gateway_doh_subdomain",
  "onboarding": true,
  "switch_locked": true,
  "auto_connect": 0,
  "service_mode": "warp",
  "support_url": "https://support.example.com"
}

Based on the managed properties above, that's unfortunately clearly not what they publish in the app, and so won't be how an MDM would import the configuration for users to set.

To be absolutely sure I wasn't missing anything, I took one further step in validating the app and documentation: I pulled their app_restrictions.xml file from an APK directly, showing:

<?xml version="1.0" encoding="utf-8"?>
<restrictions
  xmlns:android="http://schemas.android.com/apk/res/android">
    <restriction android:title="@string/restriction_app_config_list" android:key="app_config_bundle_list" android:restrictionType="bundle_array">
        <restriction android:title="@string/restriction_app_config" android:key="app_config_bundle" android:restrictionType="bundle">
            <restriction android:description="@string/restriction_organization_description" android:title="@string/restriction_organization" android:key="organization" android:restrictionType="string" />
            <restriction android:description="@string/restriction_gateway_unique_id_description" android:title="@string/restriction_gateway_unique_id" android:key="gateway_unique_id" android:restrictionType="string" />
            <restriction android:description="@string/restriction_service_mode_description" android:title="@string/restriction_service_mode" android:key="service_mode" android:restrictionType="string" />
            <restriction android:description="@string/restriction_show_onboarding_description" android:title="@string/restriction_show_onboarding" android:key="onboarding" android:defaultValue="true" android:restrictionType="bool" />
            <restriction android:description="@string/restriction_switch_locked_description" android:title="@string/restriction_switch_locked" android:key="switch_locked" android:restrictionType="bool" />
            <restriction android:description="@string/restriction_auto_connect_description" android:title="@string/restriction_auto_connect" android:key="auto_connect" android:restrictionType="integer" />
            <restriction android:description="@string/restriction_support_url_description" android:title="@string/restriction_support_url" android:key="support_url" android:restrictionType="string" />
            <restriction android:description="@string/restriction_override_api_endpoint" android:title="@string/restriction_override_api_endpoint" android:key="override_api_endpoint" android:restrictionType="string" />
            <restriction android:description="@string/restriction_override_doh_endpoint" android:title="@string/restriction_override_doh_endpoint" android:key="override_doh_endpoint" android:restrictionType="string" />
            <restriction android:description="@string/restriction_override_warp_endpoint" android:title="@string/restriction_override_warp_endpoint" android:key="override_warp_endpoint" android:restrictionType="string" />
            <restriction android:description="@string/restriction_unique_client_id" android:title="@string/restriction_unique_client_id" android:key="unique_client_id" android:restrictionType="string" />
            <restriction android:description="@string/restriction_auth_client_id" android:title="@string/restriction_auth_client_id" android:key="auth_client_id" android:restrictionType="string" />
            <restriction android:description="@string/restriction_auth_client_secret" android:title="@string/restriction_auth_client_secret" android:key="auth_client_secret" android:restrictionType="string" />
            <restriction android:description="@string/restriction_display_name" android:title="@string/restriction_display_name" android:key="display_name" android:restrictionType="string" />
            <restriction android:description="@string/restriction_warp_tunnel_protocol" android:title="@string/restriction_warp_tunnel_protocol" android:key="warp_tunnel_protocol" android:restrictionType="string" />
            <restriction android:title="@string/tunneled_apps_list" android:key="tunneled_apps" android:restrictionType="bundle_array">
                <restriction android:title="@string/tunneled_apps_container" android:key="tunneled_apps_bundle" android:restrictionType="bundle">
                    <restriction android:description="@string/tunneled_app_description" android:title="@string/tunneled_app_title" android:key="app_identifier" android:restrictionType="string" />
                    <restriction android:description="@string/tunneled_app_is_browser_description" android:title="@string/tunneled_app_is_browser_title" android:key="is_browser" android:defaultValue="false" android:restrictionType="bool" />
                </restriction>
            </restriction>
            <restriction android:description="@string/doh_outside_tunnel_description" android:title="@string/doh_outside_tunnel_title" android:key="doh_outside_tunnel" android:defaultValue="false" android:restrictionType="bool" />
            <restriction android:title="@string/enable_post_quantum_title" android:key="enable_post_quantum" android:defaultValue="false" android:restrictionType="bool" />
            <restriction android:description="@string/restriction_environment_description" android:title="@string/restriction_environment_title" android:key="environment" android:defaultValue="false" android:restrictionType="string" />
        </restriction>
    </restriction>
</restrictions>

Clear as day.

Given how they've defined their application restrictions XML here, they're building the config from a restrictionType="bundle_array", an MDM won't send out a flat configuration the documentation (and application) demands without manual modification.

When sending the config from MDM, the device is receiving (as confirmed via Package Search's managed config tool):

app_config_bundle_list:
    app_config_bundle_list[0]:
        support_url: domain.com
        onboarding: true
        organization: Wrong config
        switch_locked: false

Which corresponds to a JSON that looks like:

{
  "app_config_bundle_list": [
    {
      "onboarding": true,
      "organization": "Wrong config",
      "switch_locked": false,
      "support_url": "domain.com"
    }
  ]
}

Instead of the flat structure, which when manually modified in the AMAPI policy the device receives:

support_url: domain.com
onboarding: false
organization: Correct config
switch_locked: true

With the corresponding JSON:

{
  "onboarding": false,
  "support_url": "domain.com",
  "organization": "Correct config",
  "switch_locked": true
}

This seems like something CloudFlare needs to fix in an updated version. I should imagine platforms like Intune can get around this by allowing direct modification of the JSON in their editor before it's sent out, but for those platforms that simply consume the managed properties and turn them into a form.. that's troublesome.

Cloudflare can address this in one of two ways:

  1. Accept a configuration bundle when it is delivered by MDM (least turmoil for users who have already configured it via imported managed properties)
  2. Adjust the application restrictions to flatten down the configuration such that it matches the documentation. This will require users who have configured managed config to date to submit an updated one, and likely lose sight of the old configs in the process.

That should then cover this issue off.