create public endpoint for batch modify mdm config profiles (#32578)

resolves #24706

this creates a new public endpoint for batch modify of mdm config
profiles

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.
- [x] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually
This commit is contained in:
Gabriel Hernandez
2025-09-08 14:52:30 +01:00
committed by GitHub
parent 280b46998a
commit e22d49bcd1
5 changed files with 347 additions and 0 deletions
@@ -0,0 +1 @@
- add public endpoint to batch modify mdm config profiles
+10
View File
@@ -476,6 +476,16 @@ type MDMConfigProfilePayload struct {
LabelsExcludeAny []ConfigurationProfileLabel `json:"labels_exclude_any,omitempty" db:"-"`
}
// BatchModifyMDMConfigProfilePayload represents the payload for a config profile when
// performing a batch modify operation.
type BatchModifyMDMConfigProfilePayload struct {
Profile []byte `json:"profile,omitempty"`
DisplayName string `json:"display_name,omitempty"`
LabelsIncludeAll []string `json:"labels_include_all,omitempty"`
LabelsIncludeAny []string `json:"labels_include_any,omitempty"`
LabelsExcludeAny []string `json:"labels_exclude_any,omitempty"`
}
// MDMProfileBatchPayload represents the payload to batch-set the profiles for
// a team or no-team.
type MDMProfileBatchPayload struct {
+1
View File
@@ -751,6 +751,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// POST /configuration_profiles endpoint.
mdmAnyMW.POST("/api/_version_/fleet/mdm/profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{})
mdmAnyMW.POST("/api/_version_/fleet/configuration_profiles", newMDMConfigProfileEndpoint, newMDMConfigProfileRequest{})
mdmAnyMW.POST("/api/_version_/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesEndpoint, batchModifyMDMConfigProfilesRequest{})
// Deprecated: POST /hosts/{host_id:[0-9]+}/configuration_profiles/resend/{profile_uuid} is now deprecated, replaced by the
// POST /hosts/{host_id:[0-9]+}/configuration_profiles/{profile_uuid}/resend endpoint.
@@ -4652,6 +4652,305 @@ func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
}
}
// This tests the new public API endpoint for batch modifying MDM profiles
func (s *integrationMDMTestSuite) TestBatchModifyMDMProfiles() {
t := s.T()
ctx := context.Background()
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
bigString := strings.Repeat("a", 1024*1024+1)
// Profile is too big
resp := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{{Profile: []byte(bigString)}}},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB")
// apply an empty set to no-team
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil}, http.StatusNoContent)
// Nothing changed, so no activity items
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate PayloadDisplayName
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N2", Profile: mobileconfigForTest("N1", "I2")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved macOS identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: p, Profile: mobileconfigForTest(p, p)},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
}
// payloads with reserved types
for p := range mobileconfig.FleetPayloadTypes() {
if p == mobileconfig.FleetCustomSettingsPayloadType {
// FileVault options in the custom settings payload are checked in file_vault_options_test.go
continue
}
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
switch p {
case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType:
assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg)
default:
assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
}
}
// payloads with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
// profiles with forbidden declaration types
for dt := range fleet.ForbiddenDeclTypes {
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTestWithType("D1", dt)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Only configuration declarations that dont require an asset reference are supported", dt)
}
// and one more for the software update declaration
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Declaration profile cant include OS updates settings. To control these settings, go to OS updates.")
// invalid JSON
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: []byte(`{"foo":}`)},
}}, http.StatusBadRequest, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "The file should include valid JSON")
// profiles with reserved Windows location URIs
// bitlocker
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: syncml.FleetBitLockerTargetLocURI, Profile: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg)
// os updates
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: syncml.FleetOSUpdateTargetLocURI, Profile: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
// invalid windows tag
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N3", Profile: []byte(`<Exec></Exec>`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
// invalid xml
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N3", Profile: []byte(`foo`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
// successfully apply windows and macOS a profiles for the team, but it's a dry run
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
// successfully apply for a team and verify activities
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
// batch-apply profiles with labels
lbl1, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L1", Query: "select 1;"})
require.NoError(t, err)
lbl2, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L2", Query: "select 1;"})
require.NoError(t, err)
lbl3, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L3", Query: "select 1;"})
require.NoError(t, err)
// attempt with an invalid label name
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name, "no-such-label"}},
}}, http.StatusBadRequest)
msg := extractServerErrorText(res.Body)
require.Contains(t, msg, "some or all the labels provided don't exist")
// mix of labels fields
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name}, LabelsExcludeAny: []string{lbl2.Name}},
}}, http.StatusUnprocessableEntity)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
// successful batch-set
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAny: []string{lbl1.Name, lbl2.Name}},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}},
{DisplayName: "N4", Profile: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}},
}}, http.StatusNoContent)
// confirm expected results
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, 3)
require.Equal(t, "N1", listResp.Profiles[0].Name)
require.Equal(t, "N2", listResp.Profiles[1].Name)
require.Equal(t, "N4", listResp.Profiles[2].Name)
require.Equal(t, listResp.Profiles[0].LabelsIncludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[0].LabelsExcludeAny)
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
})
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[2].LabelsIncludeAll)
// successful batch-set that updates some labels
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}},
}}, http.StatusNoContent)
listResp = listMDMConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, 2)
require.Equal(t, "N1", listResp.Profiles[0].Name)
require.Equal(t, "N2", listResp.Profiles[1].Name)
require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
{LabelID: lbl3.ID, LabelName: lbl3.Name},
})
require.Nil(t, listResp.Profiles[0].LabelsIncludeAll)
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
// names cannot be duplicated across platforms
declBytes := json.RawMessage(`{
"Type": "com.apple.configuration.decl.foo",
"Identifier": "com.fleet.config.foo",
"Payload": {
"ServiceType": "com.apple.bash",
"DataAssetReference": "com.fleet.asset.bash"
}}`)
mcBytes := mobileconfigForTest("N1", "I1")
winBytes := syncMLForTest("./Foo/Bar")
for _, p := range []struct {
payload []fleet.BatchModifyMDMConfigProfilePayload
expectErr string
}{
{
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: declBytes}, {DisplayName: "N1", Profile: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: declBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
} {
// team profiles
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
// no team profiles
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
}
}
func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
t := s.T()
ctx := context.Background()
+36
View File
@@ -1601,6 +1601,42 @@ func (svc *Service) validateProfileLabels(ctx context.Context, labelNames []stri
return profLabels, nil
}
type batchModifyMDMConfigProfilesRequest struct {
TeamID *uint `json:"-" query:"team_id,optional"`
TeamName *string `json:"-" query:"team_name,optional"`
DryRun bool `json:"-" query:"dry_run,optional"` // if true, apply validation but do not save changes
ConfigurationProfiles []fleet.BatchModifyMDMConfigProfilePayload `json:"configuration_profiles"`
}
type batchModifyMDMConfigProfilesResponse struct {
Err error `json:"error,omitempty"`
}
func (r batchModifyMDMConfigProfilesResponse) Error() error { return r.Err }
func (r batchModifyMDMConfigProfilesResponse) Status() int { return http.StatusNoContent }
// this is the handler for the public endpoint for batch modifying MDM profiles.
func batchModifyMDMConfigProfilesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*batchModifyMDMConfigProfilesRequest)
// We want to still use the existing BatchSetMDMProfiles method, so we convert
// the payload to the expected type.
profiles := make([]fleet.MDMProfileBatchPayload, len(req.ConfigurationProfiles))
for i, p := range req.ConfigurationProfiles {
profiles[i] = fleet.MDMProfileBatchPayload{
Name: p.DisplayName,
Contents: p.Profile,
LabelsIncludeAll: p.LabelsIncludeAll,
LabelsIncludeAny: p.LabelsIncludeAny,
LabelsExcludeAny: p.LabelsExcludeAny,
}
}
if err := svc.BatchSetMDMProfiles(ctx, req.TeamID, req.TeamName, profiles, req.DryRun, false, nil, false); err != nil {
return batchSetMDMProfilesResponse{Err: err}, nil
}
return batchModifyMDMConfigProfilesResponse{}, nil
}
////////////////////////////////////////////////////////////////////////////////
// Batch Replace MDM Profiles
////////////////////////////////////////////////////////////////////////////////