fleet/server/service/integration_mdm_profiles_test.go
Magnus Jensen 16d62da6a4
use redis to block double profile work for apple devices setting up (#42421)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34433 Part 2

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] 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. Added by first PR

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
  * Profiles now install during device enrollment setup

* **Bug Fixes**
* Enhanced Apple MDM profile synchronization to handle concurrent
processing scenarios
* Improved profile reconciliation to prevent conflicts when multiple
workers process the same device simultaneously

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
2026-03-30 16:37:18 -05:00

9345 lines
424 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package service
import (
"bytes"
"context"
"crypto/md5" // nolint:gosec // used only for tests
"crypto/x509"
"database/sql"
_ "embed"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"testing"
"time"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
servermdm "github.com/fleetdm/fleet/v4/server/mdm"
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/micromdm/plist"
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/api/androidmanagement/v1"
)
func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) {
t := s.T()
rootCA := x509.NewCertPool()
assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{
fleet.MDMAssetCACert,
}, nil)
require.NoError(t, err)
require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value))
// verify that all the profiles were signed usign the SCEP certificate,
// and grab their contents
signedContents := [][]byte{}
for _, prof := range got {
p7, err := pkcs7.Parse(prof)
require.NoError(t, err)
require.NoError(t, p7.VerifyWithChain(rootCA))
signedContents = append(signedContents, p7.Content)
}
// verify that contents match
require.ElementsMatch(t, want, signedContents)
}
func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
}
wantGlobalProfiles := globalProfiles
wantGlobalProfiles = append(
wantGlobalProfiles,
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
)
// add global profiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// invalid secrets
invalidSecretsProfile := []byte(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array/>
<key>PayloadDisplayName</key>
<string>My profile</string>
<key>PayloadIdentifier</key>
<string>$FLEET_SECRET_INVALID</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>601E0B42-0989-4FAD-A61B-18656BA3670E</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`)
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
invalidSecretsProfile = []byte(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array/>
<key>PayloadDisplayName</key>
<string>$FLEET_SECRET_INVALID</string>
<key>PayloadIdentifier</key>
<string>N3</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>601E0B42-0989-4FAD-A61B-18656BA3670E</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`)
res = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "PayloadDisplayName cannot contain FLEET_SECRET variables")
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
// add an enroll secret so the fleetd profiles differ
var teamResp teamEnrollSecretsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm.ID),
modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "team1_enroll_sec"}},
}, http.StatusOK, &teamResp)
teamProfiles := [][]byte{
mobileconfigForTest("N3", "I3"),
}
wantTeamProfiles := teamProfiles
wantTeamProfiles = append(
wantTeamProfiles,
setupExpectedFleetdProfile(t, s.server.URL, "team1_enroll_sec", &tm.ID),
)
// add profiles to the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// create a non-macOS host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("non-macos-host"),
NodeKey: ptr.String("non-macos-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
Platform: "windows",
})
require.NoError(t, err)
// create a host that's not enrolled into MDM
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// calls ensure fleet profiles
s.awaitTriggerProfileSchedule(t)
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
s.awaitRunAppleMDMWorkerSchedule() // run the worker to process the enrollment and queue profiles
installs, removes := checkNextPayloads(t, mdmDevice, false)
// verify that we received all profiles
s.signedProfilesMatch(
append(wantGlobalProfiles, setupExpectedCAProfile(t, s.ds)),
installs,
)
require.Empty(t, removes)
expectedNoTeamSummary := fleet.MDMProfilesSummary{
Pending: 0,
Failed: 0,
Verifying: 1,
Verified: 0,
}
expectedTeamSummary := fleet.MDMProfilesSummary{}
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary)
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // empty because no hosts in team
// remove the key, to simulate key expiration.
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+host.UUID)
require.NoError(t, err)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// verify that we should install the team profile
s.signedProfilesMatch(wantTeamProfiles, installs)
// verify that we should delete both profiles
require.ElementsMatch(t, []string{"I1", "I2"}, removes)
expectedNoTeamSummary = fleet.MDMProfilesSummary{}
expectedTeamSummary = fleet.MDMProfilesSummary{
Pending: 0,
Failed: 0,
Verifying: 1,
Verified: 0,
}
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host now verifying team profiles
// Use secret variables in a profile
secretIdentifier := "secret-identifier-1"
secretType := "secret.type.1"
secretProfile := string(mobileconfigForTest("NS1", "IS1"))
req := createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier,
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType,
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile,
},
},
}
secretResp := createSecretVariablesResponse{}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
// set new team profiles (delete + addition)
teamProfiles = [][]byte{
mobileconfigForTest("N4", "I4"),
mobileconfigForTestWithContent("N5", "I5", "$FLEET_SECRET_IDENTIFIER", "${FLEET_SECRET_TYPE}",
"InnerName5"),
// The whole profile is one big secret.
[]byte("$FLEET_SECRET_PROFILE"),
}
// We deep copy one of the team profiles because we will modify the slice in place, and we want to keep the originals for later.
wantTeamProfiles = [][]byte{
teamProfiles[0],
make([]byte, len(teamProfiles[1])),
{},
}
copy(wantTeamProfiles[1], teamProfiles[1])
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// Manually replace the expected secret variables in the profile
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier))
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "${FLEET_SECRET_TYPE}", secretType))
wantTeamProfiles[2] = []byte(secretProfile)
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfiles, installs)
// verify that we should delete the old team profiles
require.ElementsMatch(t, []string{"I3"}, removes)
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
// Upload the same profiles again. No changes expected.
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
// Change the secret variable and upload the profiles again. We should see the profile with updated secret installed.
secretType = "new.secret.type.1"
req = createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier, // did not change
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType, // changed
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile, // did not change
},
},
}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// Manually replace the expected secret variables in the profile
wantTeamProfilesChanged := [][]byte{
teamProfiles[1],
}
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER",
secretIdentifier))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType))
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfilesChanged, installs)
wantTeamProfiles[1] = wantTeamProfilesChanged[0]
// No profiles should be deleted
assert.Empty(t, removes)
// Clear the profiles using the new (non-deprecated) endpoint.
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id",
fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id",
fmt.Sprint(tm.ID), "dry_run", "false")
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
assert.Len(t, removes, 3)
// And reapply the same profiles using the new (non-deprecated) endpoint.
batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N4", Contents: teamProfiles[0]},
{Name: "N5", Contents: teamProfiles[1]},
{Name: "NS1", Contents: teamProfiles[2]},
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I4", false)
s.assertConfigProfilesByIdentifier(&tm.ID, "I5", false)
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
assert.Empty(t, removes)
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfiles, installs)
// Upload the same profiles again. No changes expected.
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
// Change the secret variable and upload the profiles again. We should see the profile with updated secret installed.
secretType = "new2.secret.type.1"
req = createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier, // did not change
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType, // changed
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile, // did not change
},
},
}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// Manually replace the expected secret variables in the profile
wantTeamProfilesChanged = [][]byte{
teamProfiles[1],
}
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER",
secretIdentifier))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType))
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfilesChanged, installs)
wantTeamProfiles[1] = wantTeamProfilesChanged[0]
// No profiles should be deleted
assert.Empty(t, removes)
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.NotEmpty(t, hostResp.Host.MDM.Profiles)
resProfiles := *hostResp.Host.MDM.Profiles
// two extra profiles: fleetd config and root CA
require.Len(t, resProfiles, len(wantTeamProfiles)+2)
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
// add a new profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "identifier-"+mcUUID, mcUUID)
wantTeamProfiles = append(wantTeamProfiles, prof)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, test.MakeTestBytes())
return err
})
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend profile while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the profile to pending, can't resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil)
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the profile to failed, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// set the profile to failed, can resend from device endpoint
token := "good_token"
updateDeviceTokenForHost(t, s.ds, host.ID, token)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
_ = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", token, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend profile while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// set the profile to verified, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID)
return err
})
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
s.lastActivityMatches(
fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID),
0)
// add a declaration to the team
declIdent := "decl-ident-" + uuid.NewString()
fields := map[string][]string{
"team_id": {fmt.Sprintf("%d", tm.ID)},
}
body, headers := generateNewProfileMultipartRequest(
t, "some-declaration.json", declarationForTest(declIdent), s.token, fields,
)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
var resp newMDMConfigProfileResponse
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, "d", string(resp.ProfileUUID[0]))
declUUID := resp.ProfileUUID
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger))
cmd, err := d.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
cmd, err = d.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd, fmt.Sprintf("expected no more commands, but got: %+v", cmd))
_, err = d.DeclarativeManagement("tokens")
require.NoError(t, err)
}
checkDDMSync(mdmDevice)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can not resend declarations as admin or from device endpoint
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, declUUID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.CantResendAppleDeclarationProfilesMessage)
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", token, declUUID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.CantResendAppleDeclarationProfilesMessage)
// set the declaration to verified
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID)
return err
})
// transfer the host to the global team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(nil, []uint{host.ID}))
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, len(wantGlobalProfiles))
s.signedProfilesMatch(wantGlobalProfiles, installs)
require.Len(t, removes, len(wantTeamProfiles))
expectedNoTeamSummary = fleet.MDMProfilesSummary{Pending: 1}
expectedTeamSummary = fleet.MDMProfilesSummary{}
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // host now removing team profiles
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
// can't resend profile from another team
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Unable to match profile to host")
// invalid profile UUID prefix should return 404
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Invalid profile UUID prefix")
// set OS updates settings for no-team and team, should not change the
// summaries as this profile is ignored.
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": {
"deadline": "2023-12-31",
"minimum_version": "14.6.1"
}
}
}`), http.StatusOK)
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), fleet.TeamPayload{
MDM: &fleet.TeamPayloadMDM{
MacOSUpdates: &fleet.AppleOSUpdateSettings{
Deadline: optjson.SetString("1992-01-01"),
MinimumVersion: optjson.SetString("14.6.1"),
},
},
}, http.StatusOK)
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary)
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
// it should also not show up in the host's profiles list
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.NotEmpty(t, hostResp.Host.MDM.Profiles)
resProfiles = *hostResp.Host.MDM.Profiles
// two extra profiles: fleetd config and root CA
require.Len(t, resProfiles, len(wantGlobalProfiles)+2)
}
func (s *integrationMDMTestSuite) TestAppleProfileRetries() {
t := s.T()
ctx := context.Background()
enrollSecret := "test-profile-retries-secret"
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}})
require.NoError(t, err)
testProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
}
initialExpectedProfiles := testProfiles
initialExpectedProfiles = append(
initialExpectedProfiles,
setupExpectedFleetdProfile(t, s.server.URL, enrollSecret, nil),
setupExpectedCAProfile(t, s.ds),
)
h, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
// we remove the reds key and don't run the apple worker to keep the nature of the test
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+h.UUID)
require.NoError(t, err)
expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
"I1": fleet.MDMDeliveryVerifying,
"I2": fleet.MDMDeliveryVerifying,
mobileconfig.FleetdConfigPayloadIdentifier: fleet.MDMDeliveryVerifying,
mobileconfig.FleetCARootConfigPayloadIdentifier: fleet.MDMDeliveryVerifying,
}
checkProfilesStatus := func(t *testing.T) {
storedProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Len(t, storedProfs, len(expectedProfileStatuses))
for _, p := range storedProfs {
want, ok := expectedProfileStatuses[p.Identifier]
require.True(t, ok, "unexpected profile: %s", p.Identifier)
require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Identifier)
}
}
expectedRetryCounts := map[string]uint{
"I1": 0,
"I2": 0,
mobileconfig.FleetdConfigPayloadIdentifier: 0,
mobileconfig.FleetCARootConfigPayloadIdentifier: 0,
}
checkRetryCounts := func(t *testing.T) {
counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h)
require.NoError(t, err)
require.Len(t, counts, len(expectedRetryCounts))
for _, c := range counts {
want, ok := expectedRetryCounts[c.ProfileIdentifier]
require.True(t, ok, "unexpected profile: %s", c.ProfileIdentifier)
require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileIdentifier)
}
}
hostProfsByIdent := map[string]*fleet.HostMacOSProfile{
"I1": {
Identifier: "I1",
DisplayName: "N1",
InstallDate: time.Now().Add(15 * time.Minute),
},
"I2": {
Identifier: "I2",
DisplayName: "N2",
InstallDate: time.Now().Add(15 * time.Minute),
},
mobileconfig.FleetdConfigPayloadIdentifier: {
Identifier: mobileconfig.FleetdConfigPayloadIdentifier,
DisplayName: "Fleetd configuration",
InstallDate: time.Now().Add(15 * time.Minute),
},
}
reportHostProfs := func(t *testing.T, identifiers ...string) {
report := make(map[string]*fleet.HostMacOSProfile, len(hostProfsByIdent))
for _, ident := range identifiers {
report[ident] = hostProfsByIdent[ident]
}
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, report))
}
setProfileUploadedAt := func(t *testing.T, uploadedAt time.Time, identifiers ...interface{}) {
bindVars := strings.TrimSuffix(strings.Repeat("?, ", len(identifiers)), ", ")
stmt := fmt.Sprintf("UPDATE mdm_apple_configuration_profiles SET uploaded_at = ? WHERE identifier IN(%s)", bindVars)
args := append([]interface{}{uploadedAt}, identifiers...)
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, stmt, args...)
return err
})
}
t.Run("retry after verifying", func(t *testing.T) {
// upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier)
// trigger initial profile sync and confirm that we received all profiles
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch(initialExpectedProfiles, installs)
require.Empty(t, removes)
checkProfilesStatus(t) // all profiles verifying
checkRetryCounts(t) // no retries yet
// report osquery results with I2 missing and confirm I2 marked as pending and other profiles are marked as verified
reportHostProfs(t, "I1", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I2"] = fleet.MDMDeliveryPending
expectedProfileStatuses["I1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses[mobileconfig.FleetdConfigPayloadIdentifier] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
expectedRetryCounts["I2"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile command for I2 was resent
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch([][]byte{initialExpectedProfiles[1]}, installs)
require.Empty(t, removes)
// report osquery results with I2 present and confirm that all profiles are verified
reportHostProfs(t, "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that no profiles were sent
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
})
t.Run("retry after verification", func(t *testing.T) {
// I1 already has retries=1 from the previous subtest, continue retrying until max retries exceeded
startRetries := expectedRetryCounts["I1"]
for retryNum := startRetries + 1; retryNum <= servermdm.MaxAppleProfileRetries; retryNum++ {
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedRetryCounts["I1"] = retryNum
// not yet at max retries, profile should be pending for retry
expectedProfileStatuses["I1"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile command for I1 was resent
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch([][]byte{initialExpectedProfiles[0]}, installs)
require.Empty(t, removes)
}
// report osquery results with I1 missing again, now max retries exceeded
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I1"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged, still at max
// trigger a profile sync and confirm that the install profile command for I1 was not resent
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
})
t.Run("retry after device error", func(t *testing.T) {
// add another profile and set the updated_at timestamp back by 48 hours
newProfile := mobileconfigForTest("N3", "I3")
testProfiles = append(testProfiles, newProfile)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3")
// trigger a profile sync and confirm that the install profile command for I3 was sent and
// simulate a device error
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, true)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I3"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["I3"] = 1
checkRetryCounts(t)
// continue retrying via device errors until max retries exceeded
for retryNum := uint(2); retryNum <= servermdm.MaxAppleProfileRetries; retryNum++ {
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, true) // simulate device error
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I3"] = fleet.MDMDeliveryPending
expectedRetryCounts["I3"] = retryNum
checkProfilesStatus(t)
checkRetryCounts(t)
}
// trigger a profile sync and simulate a device ack (retries already at max)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I3"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// report osquery results with I3 missing and confirm that I3 is marked as failed (max retries exceeded)
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I3"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for I3 was not resent
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
})
t.Run("repeated device error", func(t *testing.T) {
// add another profile and set the updated_at timestamp back by 48 hours
newProfile := mobileconfigForTest("N4", "I4")
testProfiles = append(testProfiles, newProfile)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4")
// repeatedly simulate device errors until max retries exceeded
for retryNum := uint(1); retryNum <= servermdm.MaxAppleProfileRetries; retryNum++ {
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, true)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I4"] = fleet.MDMDeliveryPending
expectedRetryCounts["I4"] = retryNum
checkProfilesStatus(t)
checkRetryCounts(t)
}
// one more device error should mark as failed
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, true)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I4"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for I4 was not resent
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
})
t.Run("retry count does not reset", func(t *testing.T) {
// add another profile and set the updated_at timestamp back by 48 hours
newProfile := mobileconfigForTest("N5", "I5")
testProfiles = append(testProfiles, newProfile)
hostProfsByIdent["I5"] = &fleet.HostMacOSProfile{Identifier: "I5", DisplayName: "N5", InstallDate: time.Now()}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4", "I5")
// trigger a profile sync and confirm that the install profile command for I3 was sent and
// simulate a device error
s.awaitTriggerProfileSchedule(t)
installs, removes := checkNextPayloads(t, mdmDevice, true)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I5"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["I5"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile command for I5 was sent and
// simulate a device ack
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// report osquery results with I5 found and confirm that the I5 marked as verified
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I5")
expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for I5 was not resent
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
// report osquery results again with I5 missing, retry until max retries exceeded.
// Each iteration: report missing (increments retries, sets status=NULL) -> cron re-enqueues -> ack (verifying).
// When retries reaches MaxAppleProfileRetries, the next missing report marks it as failed.
for expectedRetryCounts["I5"] < servermdm.MaxAppleProfileRetries {
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedRetryCounts["I5"]++
expectedProfileStatuses["I5"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
checkRetryCounts(t)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
s.signedProfilesMatch([][]byte{newProfile}, installs)
require.Empty(t, removes)
}
// one final missing report: retries == MaxAppleProfileRetries, should be failed
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I5"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for I5 was not resent
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
})
}
func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
t := s.T()
ctx := context.Background()
testProfiles := []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1"}})},
{Name: "N2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L2", Data: "D2"}, {Verb: "Add", LocURI: "L3", Data: "D3"}})},
}
h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
"N1": fleet.MDMDeliveryVerified,
"N2": fleet.MDMDeliveryVerified,
}
checkProfilesStatus := func(t *testing.T) {
storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Len(t, storedProfs, len(expectedProfileStatuses))
for _, p := range storedProfs {
want, ok := expectedProfileStatuses[p.Name]
require.True(t, ok, "unexpected profile: %s", p.Name)
require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name)
}
}
expectedRetryCounts := map[string]uint{
"N1": 0,
"N2": 0,
}
checkRetryCounts := func(t *testing.T) {
counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h)
require.NoError(t, err)
require.Len(t, counts, len(expectedRetryCounts))
for _, c := range counts {
want, ok := expectedRetryCounts[c.ProfileName]
require.True(t, ok, "unexpected profile: %s", c.ProfileName)
require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileName)
}
}
verifyCommands := func(wantProfileInstalls int, status string) {
s.awaitTriggerProfileSchedule(t)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
// profile installs + 2 protocol commands acks
require.Len(t, cmds, wantProfileInstalls+2)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
atomicCmds := 0
for _, c := range cmds {
if c.Verb == "Atomic" {
atomicCmds++
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(status),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.Equal(t, wantProfileInstalls, atomicCmds)
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
t.Run("does not retry after successful delivery", func(t *testing.T) {
t.Cleanup(func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `DELETE FROM host_mdm_windows_profiles WHERE host_uuid = ?`
_, err := q.ExecContext(ctx, stmt, h.UUID)
return err
})
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t) // all profiles verified
checkRetryCounts(t) // no retries
})
retriesBeforeFailure := servermdm.MaxWindowsProfileRetries
t.Run(fmt.Sprintf("retries %d time before marking as failed", retriesBeforeFailure), func(t *testing.T) {
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
mysql.DumpTable(t, q, "host_mdm_windows_profiles")
return nil
})
for i := range retriesBeforeFailure {
s.awaitTriggerProfileSchedule(t)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
// Reply to initial status commands
for _, c := range cmds {
if c.Verb == "Status" {
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(syncml.CmdStatusOK),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
}
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
atomicCmds := 0
for _, c := range cmds {
if c.Verb == "Atomic" {
atomicCmds++
}
if c.Verb == "Status" {
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(syncml.CmdStatusOK),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
continue
}
status := syncml.CmdStatusOK
if len(c.Cmd.ReplaceCommands) > 0 && c.Cmd.ReplaceCommands[0].GetTargetURI() == "L1" {
status = syncml.CmdStatusAtomicFailed
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(status),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.Equal(t, 2, atomicCmds)
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryPending
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
expectedRetryCounts["N1"] = uint(i + 1) //nolint:gosec // dismiss G115
checkRetryCounts(t)
}
// Final run to mark it as failed
s.awaitTriggerProfileSchedule(t)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
atomicCmds := 0
for _, c := range cmds {
if c.Verb == "Atomic" {
atomicCmds++
}
if c.Verb == "Status" {
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(syncml.CmdStatusOK),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
continue
}
status := syncml.CmdStatusOK
if len(c.Cmd.ReplaceCommands) > 0 && c.Cmd.ReplaceCommands[0].GetTargetURI() == "L1" {
status = syncml.CmdStatusAtomicFailed
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(status),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.Equal(t, 1, atomicCmds)
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryFailed
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
expectedRetryCounts["N1"] = servermdm.MaxWindowsProfileRetries
checkRetryCounts(t)
})
}
// TestWindowsProfileResend verifies that a Windows profile is resent when its contents have been modified.
func (s *integrationMDMTestSuite) TestWindowsProfileResend() {
t := s.T()
ctx := context.Background()
testProfiles := []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1"}})},
{Name: "N2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L2", Data: "D2"}, {Verb: "Replace", LocURI: "L3", Data: "D3"}})},
}
h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
"N1": fleet.MDMDeliveryVerifying,
"N2": fleet.MDMDeliveryVerifying,
}
checkProfilesStatus := func(t *testing.T) {
storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Len(t, storedProfs, len(expectedProfileStatuses))
for _, p := range storedProfs {
want, ok := expectedProfileStatuses[p.Name]
require.True(t, ok, "unexpected profile: %s", p.Name)
require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name)
}
}
// verifyCommands checks the number of commands sent to the device.
// nRemovals is optional. When a profile is replaced, <Delete> commands
// are generated for the old version in addition to install commands.
verifyCommands := func(wantProfileInstalls int, status string, nRemovals ...int) {
nRemove := 0
if len(nRemovals) > 0 {
nRemove = nRemovals[0]
}
s.awaitTriggerProfileSchedule(t)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
// profile installs + delete commands + 2 protocol commands acks
require.Len(t, cmds, wantProfileInstalls+nRemove+2)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
atomicCmds := 0
for _, c := range cmds {
if c.Verb == "Atomic" {
atomicCmds++
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(status),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.Equal(t, wantProfileInstalls+nRemove, atomicCmds)
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
// cleanupWindowsHostState removes host-profile rows and queued commands
// left behind by two-phase removal, so subsequent subtests start clean.
cleanupWindowsHostState := func() {
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{}},
http.StatusNoContent)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if _, err := q.ExecContext(context.Background(), `DELETE FROM host_mdm_windows_profiles WHERE host_uuid = ?`, h.UUID); err != nil {
return err
}
_, err := q.ExecContext(context.Background(), `
DELETE wmcq FROM windows_mdm_command_queue wmcq
JOIN mdm_windows_enrollments mwe ON mwe.id = wmcq.enrollment_id
WHERE mwe.host_uuid = ?`, h.UUID)
return err
})
}
t.Run("do not resend if nothing changed", func(t *testing.T) {
t.Cleanup(cleanupWindowsHostState)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// profiles to install + 2 boilerplate <Status>
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t) // all profiles verified
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
// Upload the same profiles again
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
})
t.Run("resend if contents changed", func(t *testing.T) {
t.Cleanup(cleanupWindowsHostState)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// profiles to install + 2 boilerplate <Status>
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t) // all profiles verified
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
// Change one profile and upload
copiedTestProfiles := make([]fleet.MDMProfileBatchPayload, len(testProfiles))
copy(copiedTestProfiles, testProfiles)
copiedTestProfiles[0].Contents = syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1-Modified"}})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: copiedTestProfiles}, http.StatusNoContent)
// Confirm that one install profile was re-sent with updated content.
// When a profile's content changes (same name, different checksum), the
// profile is updated in place (not deleted+re-created), so only the
// new install command is sent -- no <Delete> needed.
verifyCommands(1, syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t) // all profiles verified
})
}
func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() {
ctx := context.Background()
t := s.T()
// before we switch to a gitops token, ensure ABM is setup
s.enableABM(t.Name())
// Use a gitops user for all Puppet actions
u := &fleet.User{
Name: "GitOps",
Email: "gitops-TestPuppetMatchPreassignProfiles@example.com",
GlobalRole: ptr.String(fleet.RoleGitOps),
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.setTokenForTest(t, "gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword)
runWithAdminToken := func(cb func()) {
s.token = s.getTestAdminToken()
cb()
s.token = s.getCachedUserToken("gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword)
}
// create a host enrolled in fleet
mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// create a host that's not enrolled into MDM
nonMDMHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// create a setup assistant for no team, for this we need to:
// 1. mock the ABM API, as it gets called to set the profile
// 2. run the DEP schedule, as this registers the default profile
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
}))
s.runDEPSchedule()
noTeamProf := `{"x": 1}`
var globalAsstResp createMDMAppleSetupAssistantResponse
s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
TeamID: nil,
Name: "no-team",
EnrollmentProfile: json.RawMessage(noTeamProf),
}, http.StatusOK, &globalAsstResp)
// set the global Enable Release Device manually setting to true,
// will be inherited by teams created via preassign/match.
s.Do("PATCH", "/api/latest/fleet/setup_experience",
json.RawMessage(jsonMustMarshal(t, map[string]any{"enable_release_device_manually": true})),
http.StatusNoContent)
s.awaitRunAppleMDMWorkerSchedule()
// preassign an empty profile, fails
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "empty", HostUUID: nonMDMHost.UUID, Profile: nil}}, http.StatusUnprocessableEntity)
// preassign a valid profile to the MDM host
prof1 := mobileconfigForTest("n1", "i1")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof1}}, http.StatusNoContent)
// preassign another valid profile to the MDM host
prof2 := mobileconfigForTest("n2", "i2")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof2, Group: "g1"}}, http.StatusNoContent)
// preassign a valid profile to the non-MDM host, still works as the host is not validated in this call
prof3 := mobileconfigForTest("n3", "i3")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "non-mdm", HostUUID: nonMDMHost.UUID, Profile: prof3, Group: "g2"}}, http.StatusNoContent)
// match with an invalid external host id, succeeds as it is the same as if
// there was no matching to do (no preassignment was done)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "no-such-id"}, http.StatusNoContent)
// match with the non-mdm host fails
res := s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "non-mdm"}, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "host is not enrolled in Fleet MDM")
// match with the mdm host succeeds and creates a team based on the group labels
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm1"}, http.StatusNoContent)
// the host is now part of that team
h, err := s.ds.Host(ctx, mdmHost.ID)
require.NoError(t, err)
require.NotNil(t, h.TeamID)
tm1, err := s.ds.TeamLite(ctx, *h.TeamID)
require.NoError(t, err)
require.Equal(t, "g1", tm1.Name)
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
require.True(t, tm1.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
runWithAdminToken(func() {
// it create activities for the new team, the profiles assigned to it,
// the host moved to it, and setup assistant
s.lastActivityOfTypeMatches(
fleet.ActivityTypeCreatedTeam{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm1.ID, tm1.Name, tm1.ID, tm1.Name),
0)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm1.ID, tm1.Name, tm1.ID, tm1.Name),
0)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeTransferredHostsToTeam{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q, "host_ids": [%d], "host_display_names": [%q]}`,
tm1.ID, tm1.Name, tm1.ID, tm1.Name, h.ID, h.DisplayName()),
0)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "name": %q, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`,
tm1.ID, globalAsstResp.Name, tm1.Name, tm1.ID, tm1.Name),
0)
})
// and the team has the expected profiles (prof1 and prof2)
profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 2)
// order is guaranteed by profile name
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
// setup assistant settings are copyied from "no team"
teamAsst, err := s.ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID)
require.NoError(t, err)
require.Equal(t, globalAsstResp.Name, teamAsst.Name)
require.JSONEq(t, string(globalAsstResp.Profile), string(teamAsst.Profile))
// trigger the schedule so profiles are set in their state
s.awaitTriggerProfileSchedule(t)
s.awaitRunAppleMDMWorkerSchedule()
// the mdm host has the same profiles (i1, i2, plus fleetd config and disk encryption)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
mdmHost: {
{Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "i2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// create a team and set profiles to it (note that it doesn't have disk encryption enabled)
tm2, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "g1 - g4",
Secrets: []*fleet.EnrollSecret{{Secret: "tm2secret"}},
})
require.NoError(t, err)
prof4 := mobileconfigForTest("n4", "i4")
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
prof1, prof4,
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
// tm2 has disk encryption and release device manually disabled
require.False(t, tm2.Config.MDM.EnableDiskEncryption)
require.False(t, tm2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
// create another team with a superset of profiles
tm3, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team3_" + t.Name(),
Secrets: []*fleet.EnrollSecret{{Secret: "tm3secret"}},
})
require.NoError(t, err)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
prof1, prof2, prof4,
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm3.ID))
// and yet another team with the same profiles as tm3
tm4, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: "team4_" + t.Name(),
Secrets: []*fleet.EnrollSecret{{Secret: "tm4secret"}},
})
require.NoError(t, err)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
prof1, prof2, prof4,
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm4.ID))
// preassign the MDM host to prof1 and prof4, should match existing team tm2
//
// additionally, use external host identifiers with different
// suffixes to simulate real world distributed scenarios where more
// than one puppet server might be running at the time.
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler06.test.example.com", HostUUID: mdmHost.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler01.test.example.com", HostUUID: mdmHost.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
// match with the mdm host succeeds and assigns it to tm2
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler03.test.example.com"}, http.StatusNoContent)
// the host is now part of that team
h, err = s.ds.Host(ctx, mdmHost.ID)
require.NoError(t, err)
require.NotNil(t, h.TeamID)
require.Equal(t, tm2.ID, *h.TeamID)
// tmLite2 still has disk encryption and release device manually disabled
tmLite2, err := s.ds.TeamLite(ctx, *h.TeamID)
require.NoError(t, err)
require.False(t, tmLite2.Config.MDM.EnableDiskEncryption)
require.False(t, tmLite2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
// the host's profiles are:
// - the same as the team's and are pending (prof1 + prof4)
// - prof2 + old filevault are pending removal
// - fleetd config being reinstalled (for new enroll secret)
s.awaitTriggerProfileSchedule(t)
// useful for debugging
// mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// mysql.DumpTable(t, q, "host_mdm_apple_profiles")
// return nil
// })
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
mdmHost: {
{Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
// Profiles from previous team being deleted
{Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{
Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryPending,
},
},
})
// create a new mdm host enrolled in fleet
mdmHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// make it part of team 2
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tmLite2.ID, HostIDs: []uint{mdmHost2.ID}}, http.StatusOK)
// simulate having its profiles installed
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
res, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID)
n, _ := res.RowsAffected()
require.Equal(t, 4, int(n))
return err
})
// preassign the MDM host using "g1" and "g4", should match existing
// team tmLite2, and nothing be done since the host is already in tmLite2
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm2"}, http.StatusNoContent)
// the host is still part of tmLite2
h, err = s.ds.Host(ctx, mdmHost2.ID)
require.NoError(t, err)
require.NotNil(t, h.TeamID)
require.Equal(t, tmLite2.ID, *h.TeamID)
// and its profiles have been left untouched
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
mdmHost2: {
{Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
}
// while s.TestPuppetMatchPreassignProfiles focuses on many edge cases/extra
// checks around profile assignment, this test is mainly focused on
// simulating a few puppet runs in scenarios we want to support, and ensuring that:
//
// - different hosts end up in the right teams
// - teams get edited as expected
// - commands to add/remove profiles are issued adequately
func (s *integrationMDMTestSuite) TestPuppetRun() {
t := s.T()
ctx := context.Background()
// define a few profiles
prof1, prof2, prof3, prof4 := mobileconfigForTest("n1", "i1"),
mobileconfigForTest("n2", "i2"),
mobileconfigForTest("n3", "i3"),
mobileconfigForTest("n4", "i4")
// create three hosts
host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host3, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// ensure fleet profiles
s.awaitTriggerProfileSchedule(t)
s.awaitRunAppleMDMWorkerSchedule()
// Set up a mock Apple DEP API
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
_, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo")))
case "/profile":
w.WriteHeader(http.StatusOK)
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}))
}
}))
// Use a gitops user for all Puppet actions
u := &fleet.User{
Name: "GitOps",
Email: "gitops-TestPuppetRun@example.com",
GlobalRole: ptr.String(fleet.RoleGitOps),
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.setTokenForTest(t, "gitops-TestPuppetRun@example.com", test.GoodPassword)
// preassignAndMatch simulates the puppet module doing all the
// preassign/match calls for a given set of profiles.
preassignAndMatch := func(profs []fleet.MDMApplePreassignProfilePayload) {
require.NotEmpty(t, profs)
for _, prof := range profs {
s.Do(
"POST",
"/api/latest/fleet/mdm/apple/profiles/preassign",
preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: prof},
http.StatusNoContent,
)
}
s.Do(
"POST",
"/api/latest/fleet/mdm/apple/profiles/match",
matchMDMApplePreassignmentRequest{ExternalHostIdentifier: profs[0].ExternalHostIdentifier},
http.StatusNoContent,
)
}
// node default {
// fleetdm::profile { 'n1':
// template => template('n1.mobileconfig.erb'),
// group => 'base',
// }
//
// fleetdm::profile { 'n2':
// template => template('n2.mobileconfig.erb'),
// group => 'workstations',
// }
//
// fleetdm::profile { 'n3':
// template => template('n3.mobileconfig.erb'),
// group => 'workstations',
// }
//
// if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
// fleetdm::profile { 'n4':
// template => template('fleetdm/n4.mobileconfig.erb'),
// group => 'kiosks',
// }
// }
puppetRun := func(host *fleet.Host) {
payload := []fleet.MDMApplePreassignProfilePayload{
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof1,
Group: "base",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof2,
Group: "workstations",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof3,
Group: "workstations",
},
}
if host.UUID == host2.UUID {
payload = append(payload, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof4,
Group: "kiosks",
})
}
preassignAndMatch(payload)
}
// host1 checks in
puppetRun(host1)
// the host now belongs to a team
h1, err := s.ds.Host(ctx, host1.ID)
require.NoError(t, err)
require.NotNil(t, h1.TeamID)
// the team has the right name
tm1, err := s.ds.TeamWithExtras(ctx, *h1.TeamID)
require.NoError(t, err)
require.Equal(t, "base - workstations", tm1.Name)
// and the right profiles
profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// host2 checks in
puppetRun(host2)
// a new team is created
h2, err := s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.NotNil(t, h2.TeamID)
// the team has the right name
tm2, err := s.ds.TeamWithExtras(ctx, *h2.TeamID)
require.NoError(t, err)
require.Equal(t, "base - kiosks - workstations", tm2.Name)
// and the right profiles
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
require.Len(t, profs, 4)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
require.True(t, tm2.Config.MDM.EnableDiskEncryption)
// host3 checks in
puppetRun(host3)
// it belongs to the same team as host1
h3, err := s.ds.Host(ctx, host3.ID)
require.NoError(t, err)
require.Equal(t, h1.TeamID, h3.TeamID)
// prof2 is edited
oldProf2 := prof2
prof2 = mobileconfigForTest("n2", "i2-v2")
// host3 checks in again
puppetRun(host3)
// still belongs to the same team
h3, err = s.ds.Host(ctx, host3.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h3.TeamID)
// but the team has prof2 updated
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// host2 checks in, still belongs to the same team
puppetRun(host2)
h2, err = s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.Equal(t, tm2.ID, *h2.TeamID)
// but the team has prof2 updated as well
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
require.Len(t, profs, 4)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// the puppet manifest is changed, and prof3 is removed
// node default {
// fleetdm::profile { 'n1':
// template => template('n1.mobileconfig.erb'),
// group => 'base',
// }
//
// fleetdm::profile { 'n2':
// template => template('n2.mobileconfig.erb'),
// group => 'workstations',
// }
//
// if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
// fleetdm::profile { 'n4':
// template => template('fleetdm/n4.mobileconfig.erb'),
// group => 'kiosks',
// }
// }
puppetRun = func(host *fleet.Host) {
payload := []fleet.MDMApplePreassignProfilePayload{
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof1,
Group: "base",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof2,
Group: "workstations",
},
}
if host.UUID == host2.UUID {
payload = append(payload, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof4,
Group: "kiosks",
})
}
preassignAndMatch(payload)
}
// host1 checks in again
puppetRun(host1)
// still belongs to the same team
h1, err = s.ds.Host(ctx, host1.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h1.TeamID)
// but the team doesn't have prof3 anymore
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 2)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// same for host2
puppetRun(host2)
h2, err = s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.Equal(t, tm2.ID, *h2.TeamID)
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof4, []byte(profs[2].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// The puppet manifest is drastically updated, this time to use exclusions on host3:
//
// node default {
// fleetdm::profile { 'n1':
// template => template('n1.mobileconfig.erb'),
// group => 'base',
// }
//
// fleetdm::profile { 'n2':
// template => template('n2.mobileconfig.erb'),
// group => 'workstations',
// }
//
// if $facts['system_profiler']['hardware_uuid'] == 'host_3_uuid' {
// fleetdm::profile { 'n3':
// template => template('fleetdm/n3.mobileconfig.erb'),
// group => 'no-nudge',
// }
// } else {
// fleetdm::profile { 'n3':
// ensure => absent,
// template => template('fleetdm/n3.mobileconfig.erb'),
// group => 'workstations',
// }
// }
// }
puppetRun = func(host *fleet.Host) {
manifest := []fleet.MDMApplePreassignProfilePayload{
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof1,
Group: "base",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof2,
Group: "workstations",
},
}
if host.UUID == host3.UUID {
manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof3,
Group: "no-nudge",
Exclude: true,
})
} else {
manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof3,
Group: "workstations",
})
}
preassignAndMatch(manifest)
}
// host1 checks in
puppetRun(host1)
// the host belongs to the same team
h1, err = s.ds.Host(ctx, host1.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h1.TeamID)
// the team has the right profiles
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// host2 checks in
puppetRun(host2)
// it is assigned to tm1
h2, err = s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h2.TeamID)
// host3 checks in
puppetRun(host3)
// it is assigned to a new team
h3, err = s.ds.Host(ctx, host3.ID)
require.NoError(t, err)
require.NotNil(t, h3.TeamID)
require.NotEqual(t, tm1.ID, *h3.TeamID)
require.NotEqual(t, tm2.ID, *h3.TeamID)
// a new team is created
tm3, err := s.ds.TeamWithExtras(ctx, *h3.TeamID)
require.NoError(t, err)
require.Equal(t, "base - no-nudge - workstations", tm3.Name)
// and the right profiles
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm3.ID)
require.NoError(t, err)
require.Len(t, profs, 2)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.True(t, tm3.Config.MDM.EnableDiskEncryption)
}
func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
// ensure fleet profile
s.awaitTriggerProfileSchedule(t)
mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.awaitRunAppleMDMWorkerSchedule()
t.Run("no profiles", func(t *testing.T) {
var listResp listMDMAppleConfigProfilesResponse
s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil
require.Len(t, listResp.ConfigProfiles, 0)
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &listResp)
require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil
require.Len(t, listResp.ConfigProfiles, 0)
var hostProfilesResp getHostProfilesResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
require.NotNil(t, hostProfilesResp.Profiles) // expect empty slice instead of nil
require.Len(t, hostProfilesResp.Profiles, 0)
require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
})
t.Run("with profiles", func(t *testing.T) {
p1, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p1", "p1.identifier", "p1.uuid"), nil)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *p1, nil)
require.NoError(t, err)
p2, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p2", "p2.identifier", "p2.uuid"), &testTeam.ID)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *p2, nil)
require.NoError(t, err)
var resp listMDMAppleConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: 0}, http.StatusOK, &resp)
require.NotNil(t, resp.ConfigProfiles)
require.Len(t, resp.ConfigProfiles, 1)
require.Equal(t, p1.Name, resp.ConfigProfiles[0].Name)
require.Equal(t, p1.Identifier, resp.ConfigProfiles[0].Identifier)
resp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.ConfigProfiles)
require.Len(t, resp.ConfigProfiles, 1)
require.Equal(t, p2.Name, resp.ConfigProfiles[0].Name)
require.Equal(t, p2.Identifier, resp.ConfigProfiles[0].Identifier)
p3, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p3", "p3.identifier", "p3.uuid"), &testTeam.ID)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *p3, nil)
require.NoError(t, err)
resp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.ConfigProfiles)
require.Len(t, resp.ConfigProfiles, 2)
for _, p := range resp.ConfigProfiles {
if p.Name == p2.Name { //nolint:gocritic // ignore ifElseChain
require.Equal(t, p2.Identifier, p.Identifier)
} else if p.Name == p3.Name {
require.Equal(t, p3.Identifier, p.Identifier)
} else {
require.Fail(t, "unexpected profile name")
}
}
var hostProfilesResp getHostProfilesResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
require.NotNil(t, hostProfilesResp.Profiles)
require.Len(t, hostProfilesResp.Profiles, 1)
require.Equal(t, p1.Name, hostProfilesResp.Profiles[0].Name)
require.Equal(t, p1.Identifier, hostProfilesResp.Profiles[0].Identifier)
require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&testTeam.ID, []uint{mdmHost.ID}))
require.NoError(t, err)
hostProfilesResp = getHostProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
require.NotNil(t, hostProfilesResp.Profiles)
require.Len(t, hostProfilesResp.Profiles, 2)
require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
})
}
func (s *integrationMDMTestSuite) TestAppConfigMDMCustomSettings() {
t := s.T()
// set the macos custom settings fields with the deprecated Labels field
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": {
"custom_settings": [
{"path": "foo", "labels": ["baz"]},
{"path": "bar"}
]
}
}
}`), http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
// check that they are returned by a GET /config
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
// set the windows custom settings fields with included/excluded labels
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels_exclude_any": ["x", "y"]},
{"path": "bar", "labels_include_all": ["a", "b"]},
{"path": "baz", "labels": ["c"]}
]
}
}
}`), http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// check that they are returned by a GET /config
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// patch without specifying the windows/macos custom settings fields and an unrelated
// field, should not remove them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
}`), http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// patch with explicitly empty macos/windows custom settings fields, would remove
// them but this is a dry-run
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": { "custom_settings": null },
"windows_settings": { "custom_settings": null }
}
}`), http.StatusOK, &acResp, "dry_run", "true")
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// patch with explicitly empty macos custom settings fields, removes them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": { "custom_settings": null },
"windows_settings": { "custom_settings": null }
}
}`), http.StatusOK, &acResp)
assert.Empty(t, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, acResp.MDM.WindowsSettings.CustomSettings)
// mix of labels fields returns an error
res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": {
"custom_settings": [
{"path": "foo", "labels": ["a"], "labels_exclude_any": ["b"]}
]
}
}
}`), 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.`)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels_include_all": ["a"], "labels_exclude_any": ["b"]}
]
}
}
}`), 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.`)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels_include_any": ["a"], "labels_exclude_any": ["b"]}
]
}
}
}`), 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.`)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels": ["a"], "labels_include_any": ["b"]}
]
}
}
}`), 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.`)
}
func (s *integrationMDMTestSuite) TestApplyTeamsMDMAppleProfiles() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
// apply with custom macos settings
teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{
"custom_settings": []map[string]interface{}{
{"path": "foo", "labels": []string{"a", "b"}},
{"path": "bar", "labels_exclude_any": []string{"c"}},
},
},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// retrieving the team returns the custom macos settings
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"a", "b"}},
{Path: "bar", LabelsExcludeAny: []string{"c"}},
}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with invalid macos settings subfield should fail
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"foo_bar": 123},
},
}}}
res := s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `unsupported key provided: "foo_bar"`)
// apply with some good and some bad macos settings subfield should fail
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []interface{}{"A", true}},
},
}}}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `invalid value type at 'macos_settings.custom_settings': expected array of MDMProfileSpecs but got bool`)
// apply without custom macos settings specified and unrelated field, should
// not replace existing settings
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
EnableDiskEncryption: optjson.SetBool(false),
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"a", "b"}},
{Path: "bar", LabelsExcludeAny: []string{"c"}},
}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with explicitly empty custom macos settings would clear the existing
// settings, but dry-run
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []map[string]interface{}{}},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"a", "b"}},
{Path: "bar", LabelsExcludeAny: []string{"c"}},
}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with explicitly empty custom macos settings clears the existing settings
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []map[string]interface{}{}},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with invalid mix of labels fails
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{
"custom_settings": []map[string]interface{}{
{"path": "bar", "labels": []string{"x"}},
{"path": "foo", "labels": []string{"a", "b"}, "labels_include_all": []string{"c"}},
},
},
},
}}}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMAppleProfiles() {
t := s.T()
ctx := context.Background()
bigString := strings.Repeat("a", 1024*1024+1)
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil}, http.StatusNoContent)
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// Profile is too big
resp := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{[]byte(bigString)}},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "maximum configuration profile file size is 1 MB")
// duplicate profile names
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N1", "I2"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest(p, p),
}}, 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/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
}}, 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/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", p, "random", ""),
}}, 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))
}
// successfully apply a profile for the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
}
func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() {
t := s.T()
ctx := context.Background()
createManualMDMEnrollWithOrbit := func(secret string, doUserEnroll bool) (*fleet.Host, *fleet.NanoEnrollment, *mdmtest.TestAppleMDMClient) {
// orbit enrollment happens before mdm enrollment, otherwise the host would
// always receive the "no team" profiles on mdm enrollment since it would
// not be part of any team yet (team assignment is done when it enrolls
// with orbit).
mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{
SCEPChallenge: s.scepChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
}, "MacBookPro16,1")
// enroll the device with orbit
var resp enrollOrbitResponse
s.DoJSON("POST", "/api/fleet/orbit/enroll", fleet.EnrollOrbitRequest{
EnrollSecret: secret,
HardwareUUID: mdmDevice.UUID, // will not match any existing host
HardwareSerial: mdmDevice.SerialNumber,
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.OrbitNodeKey)
orbitNodeKey := resp.OrbitNodeKey
h, err := s.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey)
require.NoError(t, err)
h.OrbitNodeKey = &orbitNodeKey
h.Platform = "darwin"
err = mdmDevice.Enroll()
require.NoError(t, err)
var userEnrollment *fleet.NanoEnrollment
if doUserEnroll {
// Do a user enrollment with a bit of extra sanity checking
userEnrollment, err = s.ds.GetNanoMDMUserEnrollment(ctx, h.UUID)
require.NoError(t, err)
require.Nil(t, userEnrollment)
err = mdmDevice.UserEnroll()
require.NoError(t, err)
userEnrollment, err = s.ds.GetNanoMDMUserEnrollment(ctx, h.UUID)
require.NoError(t, err)
require.NotNil(t, userEnrollment)
}
return h, userEnrollment, mdmDevice
}
triggerReconcileProfilesMarkVerifying := func() {
t.Logf("[TestHostMDMAppleProfilesStatus] Calling awaitTriggerProfileSchedule at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] awaitTriggerProfileSchedule completed, updating profiles to verifying at %s", time.Now().Format(time.RFC3339))
// this will only mark them as "pending", as the response to confirm
// profile deployment is asynchronous, so we simulate it here by
// updating any "pending" (not NULL) profiles to "verifying"
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending)
return err
})
t.Logf("[TestHostMDMAppleProfilesStatus] Profiles updated to verifying status at %s", time.Now().Format(time.RFC3339))
}
assignHostToTeam := func(h *fleet.Host, teamID *uint) {
var moveHostResp addHostsToTeamResponse
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &moveHostResp)
h.TeamID = teamID
}
// add a couple global profiles
payloadScopeSystem := fleet.PayloadScopeSystem
payloadScopeUser := fleet.PayloadScopeUser
globalProfiles := [][]byte{
mobileconfigForTest("G1", "G1"),
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// create the no-team enroll secret
var applyResp applyEnrollSecretSpecResponse
globalEnrollSec := "global_enroll_sec"
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
},
}, http.StatusOK, &applyResp)
// create a team with a couple profiles
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_1"})
require.NoError(t, err)
tm1Profiles := [][]byte{
mobileconfigForTest("T1.1", "T1.1"),
scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem),
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
// create the team 1 enroll secret
var teamResp teamEnrollSecretsResponse
tm1EnrollSec := "team1_enroll_sec"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1.ID),
modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: tm1EnrollSec}},
}, http.StatusOK, &teamResp)
// create another team with different profiles
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_2"})
require.NoError(t, err)
tm2Profiles := [][]byte{
mobileconfigForTest("T2.1", "T2.1"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm2.ID))
// enroll a couple hosts in no team
h1, h1UserEnrollment, _ := createManualMDMEnrollWithOrbit(globalEnrollSec, true)
require.Nil(t, h1.TeamID)
h2, _, _ := createManualMDMEnrollWithOrbit(globalEnrollSec, false)
require.Nil(t, h2.TeamID)
// run the cron
t.Logf("[TestHostMDMAppleProfilesStatus] Starting FIRST cron run (after h1, h2 enrolled) at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] FIRST cron run completed at %s", time.Now().Format(time.RFC3339))
s.awaitRunAppleMDMWorkerSchedule()
// G3 is user-scoped and the h2 host doesn't have a user-channel yet (and
// enrolled just now, so the minimum delay to give up and fail the profile
// delivery is not reached)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
h2: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// Verify there is a command on the user channel for h1
enrollmentIds, err := s.ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx)
require.NoError(t, err)
assert.Contains(t, enrollmentIds, h1UserEnrollment.ID)
assert.Contains(t, enrollmentIds, h1.UUID)
assert.Contains(t, enrollmentIds, h2.UUID)
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+h1.UUID)
require.NoError(t, err)
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+h2.UUID)
require.NoError(t, err)
// enroll a couple hosts in team 1
h3, h3UserEnrollment, _ := createManualMDMEnrollWithOrbit(tm1EnrollSec, true)
require.NotNil(t, h3.TeamID)
require.Equal(t, tm1.ID, *h3.TeamID)
h4, _, h4Device := createManualMDMEnrollWithOrbit(tm1EnrollSec, false)
require.NotNil(t, h4.TeamID)
require.Equal(t, tm1.ID, *h4.TeamID)
// run the cron
t.Logf("[TestHostMDMAppleProfilesStatus] Starting SECOND cron run (after h3, h4 enrolled in team1) at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] SECOND cron run completed at %s", time.Now().Format(time.RFC3339))
s.awaitRunAppleMDMWorkerSchedule()
// T1.3 is user-scoped and the h4 host doesn't have a user-channel yet (and
// enrolled just now, so the minimum delay to give up and send the
// user-scoped profiles to the device channel is not reached)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+h3.UUID)
require.NoError(t, err)
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+h4.UUID)
require.NoError(t, err)
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// Verify there is a command on the user channel for h3
enrollmentIds, err = s.ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx)
require.NoError(t, err)
assert.Contains(t, enrollmentIds, h3UserEnrollment.ID)
assert.Contains(t, enrollmentIds, h3.UUID)
assert.Contains(t, enrollmentIds, h4.UUID)
// switch a no team host (h1) to a team (tm2)
t.Logf("[TestHostMDMAppleProfilesStatus] Transferring host h1 (id=%d) from no team to team tm2 (id=%d) at %s", h1.ID, tm2.ID, time.Now().Format(time.RFC3339))
var moveHostResp addHostsToTeamResponse
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h1.ID}}, http.StatusOK, &moveHostResp)
t.Logf("[TestHostMDMAppleProfilesStatus] Host h1 transfer completed at %s", time.Now().Format(time.RFC3339))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// switch a team host (h3) to another team (tm2)
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h3.ID}}, http.StatusOK, &moveHostResp)
// create the user-enrollment for host h4
err = h4Device.UserEnroll()
require.NoError(t, err)
// run the cron
t.Logf("[TestHostMDMAppleProfilesStatus] Starting THIRD cron run (after h3->tm2, h4 user enrolled) at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] THIRD cron run completed at %s", time.Now().Format(time.RFC3339))
s.awaitRunAppleMDMWorkerSchedule()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// switch a team host (h4) to no team
t.Logf("[TestHostMDMAppleProfilesStatus] Transferring host h4 (id=%d) from team tm1 to NO TEAM (nil) at %s", h4.ID, time.Now().Format(time.RFC3339))
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{h4.ID}}, http.StatusOK, &moveHostResp)
t.Logf("[TestHostMDMAppleProfilesStatus] Host h4 transfer to NO TEAM completed at %s", time.Now().Format(time.RFC3339))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, // still pending install due to cron not having run
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// add a profile to no team (h2 and h4 are now part of no team)
body, headers := generateNewProfileMultipartRequest(t,
"some_name", mobileconfigForTest("G4", "G4"), s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: { // still no user channel
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
h4: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// add a profile to team 2 (h1 and h3 are now part of team 2)
body, headers = generateNewProfileMultipartRequest(t,
"some_name", mobileconfigForTest("T2.2", "T2.2"), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm2.ID)}})
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// delete a no team profile
noTeamProfs, err := s.ds.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
var g1ProfID uint
for _, p := range noTeamProfs {
if p.Identifier == "G1" {
g1ProfID = p.ProfileID
break
}
}
require.NotZero(t, g1ProfID)
var delProfResp deleteMDMAppleConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", g1ProfID),
deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// delete a team profile
tm2Profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
var tm21ProfID uint
for _, p := range tm2Profs {
if p.Identifier == "T2.1" {
tm21ProfID = p.ProfileID
break
}
}
require.NotZero(t, tm21ProfID)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", tm21ProfID),
deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// bulk-set profiles for no team, with add/delete/edit
g2Edited := mobileconfigForTest("G2b", "G2b")
g5Content := mobileconfigForTest("G5", "G5")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
// G3 is deleted
// G4 is deleted
g5Content,
},
}, http.StatusNoContent)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
{Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// bulk-set profiles for a team, with add/delete/edit
t22Edited := mobileconfigForTest("T2.2b", "T2.2b")
t23Content := mobileconfigForTest("T2.3", "T2.3")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
t22Edited,
t23Content,
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// bulk-set profiles for no team and team 2, without changes, and team 1 added (but no host affected)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
g5Content,
},
}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
t22Edited,
t23Content,
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser),
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// delete team 2 (h1 and h3 are part of that team)
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tm2.ID), nil, http.StatusOK)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// all profiles now verifying
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// h1 verified one of the profiles
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
"G2b": {Identifier: "G2b", DisplayName: "G2b", InstallDate: time.Now()},
}))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// switch a team host (h1) to another team (tm1)
assignHostToTeam(h1, &tm1.ID)
// Create a new profile that will be labeled
body, headers = generateNewProfileMultipartRequest(
t,
"label_prof",
mobileconfigForTest("label_prof", "label_prof"),
s.token,
map[string][]string{"team_id": {fmt.Sprintf("%d", tm1.ID)}},
)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
var uid string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "label_prof")
})
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "test label 1", Query: "select 1;"})
require.NoError(t, err)
// Update label with host membership
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
h1.ID,
label.ID,
)
return err
},
)
// Update profile <-> label mapping
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
uid,
label.Name,
label.ID,
)
return err
},
)
triggerReconcileProfilesMarkVerifying()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
"label_prof": {Identifier: "label_prof", DisplayName: "label_prof", InstallDate: time.Now()},
}))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
}
func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
// Ensure MDM is turned on
appConfig, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConfig.MDM.AndroidEnabledAndConfigured = true
appConfig.MDM.EnabledAndConfigured = true
appConfig.MDM.WindowsEnabledAndConfigured = true
err = s.ds.SaveAppConfig(ctx, appConfig)
require.NoError(t, err)
// NOTE: label names starting with "-" are sent as "labels_excluding_any"
// (and the leading "-" is removed from the name). Names starting with
// "!" are sent as the deprecated "labels" field (and the "!" is removed).
// Names starting with a "~" prefix are sent as "labels_include_any"
// (and the leading "~" is removed.
addLabelsFields := func(labelNames []string) map[string][]string {
var deprLabels, inclAllLabels, inclAnyLabels, exclLabels []string
for _, lbl := range labelNames {
switch {
case strings.HasPrefix(lbl, "~"):
inclAnyLabels = append(inclAnyLabels, strings.TrimPrefix(lbl, "~"))
case strings.HasPrefix(lbl, "-"):
exclLabels = append(exclLabels, strings.TrimPrefix(lbl, "-"))
case strings.HasPrefix(lbl, "!"):
deprLabels = append(deprLabels, strings.TrimPrefix(lbl, "!"))
default:
inclAllLabels = append(inclAllLabels, lbl)
}
}
fields := make(map[string][]string)
if len(deprLabels) > 0 {
fields["labels"] = deprLabels
}
if len(inclAllLabels) > 0 {
fields["labels_include_all"] = inclAllLabels
}
if len(exclLabels) > 0 {
fields["labels_exclude_any"] = exclLabels
}
if len(inclAnyLabels) > 0 {
fields["labels_include_any"] = inclAnyLabels
}
return fields
}
assertAppleProfile := func(filename, name, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
body, headers := generateNewProfileMultipartRequest(
t, filename, mobileconfigForTest(name, ident), s.token, fields,
)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, "a", string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
assertAppleDeclaration := func(filename, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
bytes := []byte(fmt.Sprintf(`{
"Type": "com.apple.configuration.foo",
"Payload": {
"Echo": "f1337"
},
"Identifier": "%s"
}`, ident))
body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, fleet.MDMAppleDeclarationUUIDPrefix, string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
createAppleProfile := func(name, ident string, teamID uint, labelNames []string) string {
uid := assertAppleProfile(name+".mobileconfig", name, ident, teamID, labelNames, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null, "profile_name": %q, "profile_identifier": %q}`, name, ident)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q, "profile_name": %q, "profile_identifier": %q}`, teamID, testTeam.Name, teamID, testTeam.Name, name, ident)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedMacosProfile{}.ActivityName(), wantJSON, 0)
return uid
}
createAppleDeclaration := func(name, ident string, teamID uint, labelNames []string) string {
uid := assertAppleDeclaration(name+".json", ident, teamID, labelNames, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null, "profile_name": %q, "identifier": %q}`, name, ident)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q, "profile_name": %q, "identifier": %q}`, teamID, testTeam.Name, teamID, testTeam.Name, name, ident)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedDeclarationProfile{}.ActivityName(), wantJSON, 0)
return uid
}
assertWindowsProfile := func(filename, locURI string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
body, headers := generateNewProfileMultipartRequest(
t,
filename,
[]byte(fmt.Sprintf(`<Add><Item><Target><LocURI>%s</LocURI></Target></Item></Add><Replace><Item><Target><LocURI>%s</LocURI></Target></Item></Replace>`, locURI, locURI)),
s.token,
fields,
)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, "w", string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
createWindowsProfile := func(name string, teamID uint, labels []string) string {
uid := assertWindowsProfile(name+".xml", "./Test", teamID, labels, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null, "profile_name": %q}`, name)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q, "profile_name": %q}`, teamID, testTeam.Name, teamID, testTeam.Name, name)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.ActivityName(), wantJSON, 0)
return uid
}
assertAndroidProfile := func(filename string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
bytes := []byte(`{
"removeUserDisabled": false
}`)
body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, fleet.MDMAndroidProfileUUIDPrefix, string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
createAndroidProfile := func(name string, teamID uint, labelNames []string) string {
uid := assertAndroidProfile(name+".json", teamID, labelNames, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null, "profile_name": %q}`, name)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q, "profile_name": %q}`, teamID, testTeam.Name, teamID, testTeam.Name, name)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedAndroidProfile{}.ActivityName(), wantJSON, 0)
return uid
}
// create a couple Apple profiles for no-team and team
noTeamAppleProfUUID := createAppleProfile("apple-global-profile", "test-global-ident", 0, nil)
teamAppleProfUUID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID, nil)
// create a couple Windows profiles for no-team and team
noTeamWinProfUUID := createWindowsProfile("win-global-profile", 0, nil)
teamWinProfUUID := createWindowsProfile("win-team-profile", testTeam.ID, nil)
// Create a couple Android profiles for no-team and team
noTeamAndroidProfUUID := createAndroidProfile("android-global-profile", 0, nil)
teamAndroidProfUUID := createAndroidProfile("android-team-profile", testTeam.ID, nil)
// Windows profile name conflicts with Apple's for no team
assertWindowsProfile("apple-global-profile.xml", "./Test", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for team 1
assertWindowsProfile("apple-global-profile.xml", "./Test", testTeam.ID, nil, http.StatusOK, "")
// Apple profile name conflicts with Windows' for no team
assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for team 1
assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, nil, http.StatusOK, "")
// Windows profile name conflicts with Apple's for team 1
assertWindowsProfile("apple-team-profile.xml", "./Test", testTeam.ID, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for no-team
assertWindowsProfile("apple-team-profile.xml", "./Test", 0, nil, http.StatusOK, "")
// Apple profile name conflicts with Windows' for team 1
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for no-team
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "")
// Android profile name conflicts with Apple's for no team
assertAndroidProfile("apple-global-profile.json", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// add some macOS declarations
createAppleDeclaration("apple-declaration", "test-declaration-ident", 0, nil)
// identifier must be unique, it conflicts with existing declaration
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", 0, nil, http.StatusConflict, "test-declaration-ident already exists")
// name is pulled from filename, it conflicts with existing declaration
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// uniqueness is checked only within team, so it's fine to have the same name and identifier in different teams
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", testTeam.ID, nil, http.StatusOK, "")
// name is pulled from filename, it conflicts with existing macOS config profile
assertAppleDeclaration("apple-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// name is pulled from filename, it conflicts with existing macOS config profile
assertAppleDeclaration("win-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// name is pulled from filename, it conflicts with existing Android config profile
assertAppleDeclaration("android-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// windows profile name conflicts with existing declaration
assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// macOS profile name conflicts with existing declaration
assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// Android profile name conflicts with existing declaration
assertAndroidProfile("apple-declaration.json", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// not an xml nor mobileconfig file
assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAppleDeclaration("foo.txt", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAndroidProfile("foo.txt", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
// Windows-reserved LocURI
assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest,
syncml.DiskEncryptionProfileRestrictionErrMsg)
assertWindowsProfile("updates.xml", syncml.FleetOSUpdateTargetLocURI, testTeam.ID, nil, http.StatusBadRequest,
"Couldn't add. Custom configuration profiles can't include Windows updates settings.")
// Fleet-reserved profiles
for name := range servermdm.FleetReservedProfileNames() {
assertAppleProfile(name+".mobileconfig", name, name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %s is not allowed`, name))
assertAppleDeclaration(name+".json", name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %q is not allowed`, name))
assertWindowsProfile(name+".xml", "./Test", 0, nil, http.StatusBadRequest, fmt.Sprintf(`Couldn't add. Profile name %q is not allowed.`, name))
}
// profiles with non-existent labels
assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
assertAndroidProfile("android-with-labels.json", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
// create a couple of labels
labelFoo := &fleet.Label{Name: "foo", Query: "select * from foo;"}
labelFoo, err = s.ds.NewLabel(context.Background(), labelFoo)
require.NoError(t, err)
labelBar := &fleet.Label{Name: "bar", Query: "select * from bar;"}
labelBar, err = s.ds.NewLabel(context.Background(), labelBar)
require.NoError(t, err)
// profiles mixing existent and non-existent labels
assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
assertAndroidProfile("android-profile-with-labels.json", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
// profiles with invalid mix of labels
assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "-bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
// profiles with valid labels
uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"!foo"}, http.StatusOK, "")
uuidAppleWithInclAnyLabel := assertAppleProfile("apple-profile-with-incl-any-labels.mobileconfig", "apple-profile-with-incl-any-labels", "ident-with-incl-any-labels", 0, []string{"~foo", "~bar"}, http.StatusOK, "")
uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"})
uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"-foo", "-bar"}, http.StatusOK, "")
uuidAppleDDMTeamWithLabel := createAppleDeclaration("apple-team-decl-with-labels", "ident-team-decl-with-labels", testTeam.ID, []string{"-foo"})
uuidWindowsTeamWithLabel := assertWindowsProfile("win-team-profile-with-labels.xml", "./Test", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "")
uuidWindowsTeamWithInclAnyLabel := assertWindowsProfile("win-team-profile-with-incl-any-labels.xml", "./Test", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "")
uuidAndroidWithLabel := assertAndroidProfile("android-profile-with-labels.json", 0, []string{"-foo", "-bar"}, http.StatusOK, "")
uuidAndroidTeamWithLabel := assertAndroidProfile("android-team-profile-with-labels.json", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "")
uuidAndroidTeamWithInclAnyLabel := assertAndroidProfile("android-team-profile-with-incl-any-labels.json", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "")
// Windows invalid content
body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't add. The file should include valid XML:")
// Apple invalid mobileconfig content
body, headers = generateNewProfileMultipartRequest(t,
"apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles can't be signed. Fleet will sign the profile for you.")
// Apple/Android invalid json declaration
body, headers = generateNewProfileMultipartRequest(t,
"apple.json", []byte("{"), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't add. The file should include valid JSON:")
// Apple/Android cannot determine which
body, headers = generateNewProfileMultipartRequest(t,
"apple_or_android.json", []byte(`{"lower_key": true,"UpperKey": false}`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Keys in declaration (DDM) profile must contain only letters and start with a uppercase letter. Keys in Android profile must contain only letters and start with a lowercase letter.")
// Android invalid keys
for key, expectedErr := range fleet.AndroidForbiddenJSONKeys {
body, headers = generateNewProfileMultipartRequest(t,
"android.json", []byte(fmt.Sprintf(`{"%s": true}`, key)), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, expectedErr)
}
// invalid JSON structure
body, headers = generateNewProfileMultipartRequest(t,
"android.json", []byte(`{"passwordPolicies": {"testKey": 123}}`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Couldn't add. Invalid JSON payload. "passwordPolicies" format is wrong.`)
// nested key
body, headers = generateNewProfileMultipartRequest(t,
"android.json", []byte(`{"passwordPolicies": [{"passwordMinimumLength": true}]}`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Couldn't add. Invalid JSON payload. "passwordPolicies.passwordMinimumLength" format is wrong.`)
// disallow unknown keys
body, headers = generateNewProfileMultipartRequest(t,
"android.json", []byte(`{"unknownKey": true}`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `Couldn't add. Invalid JSON payload. Unknown key "unknownKey"`)
// get the existing profiles work
expectedProfiles := []fleet.MDMConfigProfilePayload{
{ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil, Scope: string(fleet.PayloadScopeSystem)},
{ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID, Scope: string(fleet.PayloadScopeSystem)},
{ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
{ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID},
{ProfileUUID: noTeamAndroidProfUUID, Platform: "android", Name: "android-global-profile", TeamID: nil},
{ProfileUUID: teamAndroidProfUUID, Platform: "android", Name: "android-team-profile", TeamID: &testTeam.ID},
{
ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAppleWithLabel, Platform: "darwin", Name: "apple-profile-with-labels", Identifier: "ident-with-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAppleWithInclAnyLabel, Platform: "darwin", Name: "apple-profile-with-incl-any-labels", Identifier: "ident-with-incl-any-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem),
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAppleDDMTeamWithLabel, Platform: "darwin", Name: "apple-team-decl-with-labels", Identifier: "ident-team-decl-with-labels", TeamID: &testTeam.ID,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidWindowsTeamWithLabel, Platform: "windows", Name: "win-team-profile-with-labels", TeamID: &testTeam.ID,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidWindowsTeamWithInclAnyLabel, Platform: "windows", Name: "win-team-profile-with-incl-any-labels", TeamID: &testTeam.ID,
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAndroidWithLabel, Platform: "android", Name: "android-profile-with-labels", TeamID: nil,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAndroidTeamWithLabel, Platform: "android", Name: "android-team-profile-with-labels", TeamID: &testTeam.ID,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAndroidTeamWithInclAnyLabel, Platform: "android", Name: "android-team-profile-with-incl-any-labels", TeamID: &testTeam.ID,
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
}
for _, prof := range expectedProfiles {
var getResp getMDMConfigProfileResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, &getResp)
require.NotZero(t, getResp.CreatedAt)
require.NotZero(t, getResp.UploadedAt)
if getResp.Platform == "darwin" {
require.Len(t, getResp.Checksum, 16)
} else {
require.Empty(t, getResp.Checksum)
}
getResp.CreatedAt, getResp.UploadedAt = time.Time{}, time.Time{}
getResp.Checksum = nil
// sort the labels by name
sort.Slice(getResp.LabelsIncludeAll, func(i, j int) bool {
return getResp.LabelsIncludeAll[i].LabelName < getResp.LabelsIncludeAll[j].LabelName
})
sort.Slice(getResp.LabelsExcludeAny, func(i, j int) bool {
return getResp.LabelsExcludeAny[i].LabelName < getResp.LabelsExcludeAny[j].LabelName
})
sort.Slice(getResp.LabelsIncludeAny, func(i, j int) bool {
return getResp.LabelsIncludeAny[i].LabelName < getResp.LabelsIncludeAny[j].LabelName
})
require.Equal(t, prof, *getResp.MDMConfigProfilePayload)
resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media")
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleProfileUUIDPrefix) { //nolint:gocritic // ignore ifElseChain
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAndroidProfileUUIDPrefix) {
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
} else {
require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream")
}
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
}
var getResp getMDMConfigProfileResponse
// get an unknown Apple profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media")
// get an unknown Apple declaration
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
// get an unknown Windows profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media")
// get an unknown Android profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
var deleteResp deleteMDMConfigProfileResponse
// delete existing Apple profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp)
// turn off apple MDM
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.EnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp)
// delete non-existing Apple profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp)
// delete existing Apple declaration
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(),
`{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
// delete non-existing Apple declaration
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
// delete existing Windows profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamWinProfUUID), nil, http.StatusOK, &deleteResp)
// Now disabling windows MDM
filler := struct{}{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_enabled_and_configured": false}}`), http.StatusOK, &filler)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamWinProfUUID), nil, http.StatusOK, &deleteResp)
// delete non-existing Windows profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &deleteResp)
// delete existing Android profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAndroidProfUUID), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedAndroidProfile{}.ActivityName(),
`{"profile_name": "android-global-profile", "team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
// turn off Android MDM
appCfg, err = s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAndroidProfUUID), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedAndroidProfile{}.ActivityName(),
fmt.Sprintf(`{"profile_name": "android-team-profile", "team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, testTeam.ID, testTeam.Name, testTeam.ID, testTeam.Name),
0,
)
// delete non-existing Android profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
// turn back on apple MDM
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, "UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.enabled_and_configured', true) ")
return err
})
// trying to create/delete profiles managed by Fleet fails
for p := range mobileconfig.FleetPayloadIdentifiers() {
assertAppleProfile("foo.mobileconfig", p, p, 0, nil, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p))
// create it directly in the DB to test deletion
uid := "a" + uuid.NewString()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
mc := mcBytesForTest(p, p, uuid.New().String())
_, err := q.ExecContext(ctx,
"INSERT INTO mdm_apple_configuration_profiles (profile_uuid, identifier, name, mobileconfig, checksum, team_id, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP())",
uid, p, p, mc, "1234", 0)
return err
})
var deleteResp deleteMDMConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uid), nil, http.StatusBadRequest, &deleteResp)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?",
uid)
return err
})
}
// TODO: Add tests for create/delete forbidden declaration types?
// make fleet add a FileVault profile
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
// try to delete the profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profile.ProfileUUID), nil, http.StatusBadRequest, &deleteResp)
// make fleet add a Windows OS Updates profile
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }
}`), http.StatusOK, &acResp)
profUUID := checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1)})
// try to delete the profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp)
// TODO: Add tests for OS updates declaration when implemented.
}
func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() {
t := s.T()
ctx := context.Background()
// create some teams
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
require.NoError(t, err)
// create 5 profiles for no team and team 1, names are A, B, C ... for global and
// tA, tB, tC ... for team 1. Alternate macOS and Windows profiles.
for i := 0; i < 5; i++ {
name := string('A' + byte(i))
if i%2 == 0 {
prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof, nil)
require.NoError(t, err)
tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil)
require.NoError(t, err)
tprof.TeamID = &tm1.ID
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil)
require.NoError(t, err)
} else {
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(`<Replace></Replace>`)}, nil)
require.NoError(t, err)
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(`<Replace></Replace>`)}, nil)
require.NoError(t, err)
}
}
lblFoo, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "foo", Query: "select 1"})
require.NoError(t, err)
lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"})
require.NoError(t, err)
lblBaz, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "baz", Query: "select 1"})
require.NoError(t, err)
// create a couple profiles (Win and mac) for team 2, and none for team 3
tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil)
require.NoError(t, err)
tprof.TeamID = &tm2.ID
// make tm2ProfF a "exclude-any" label-based profile
tprof.LabelsExcludeAny = []fleet.ConfigurationProfileLabel{
{LabelID: lblFoo.ID, LabelName: lblFoo.Name},
{LabelID: lblBar.ID, LabelName: lblBar.Name},
}
tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil)
require.NoError(t, err)
// checksum is not returned by New..., so compute it manually
checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test
tm2ProfF.Checksum = checkSum[:]
// make tm2ProfG a "include-all" label-based profile
tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
Name: "tG",
TeamID: &tm2.ID,
SyncML: []byte(`<Add></Add>`),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: lblFoo.ID, LabelName: lblFoo.Name},
{LabelID: lblBar.ID, LabelName: lblBar.Name},
},
}, nil)
require.NoError(t, err)
// make tm2ProfH a "include-any" label-based profile
tm2ProfH, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
Name: "tH",
TeamID: &tm2.ID,
SyncML: []byte(`<Add></Add>`),
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
},
}, nil)
require.NoError(t, err)
// break lblFoo by deleting it
require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}))
// test that all fields are correctly returned with team 2
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID))
require.Len(t, listResp.Profiles, 3)
require.NotZero(t, listResp.Profiles[0].CreatedAt)
require.NotZero(t, listResp.Profiles[0].UploadedAt)
require.NotZero(t, listResp.Profiles[1].CreatedAt)
require.NotZero(t, listResp.Profiles[1].UploadedAt)
listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{}
listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{}
listResp.Profiles[2].CreatedAt, listResp.Profiles[2].UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfF.ProfileUUID,
TeamID: tm2ProfF.TeamID,
Name: tm2ProfF.Name,
Platform: "darwin",
Identifier: tm2ProfF.Identifier,
Checksum: tm2ProfF.Checksum,
Scope: string(fleet.PayloadScopeSystem),
// labels are ordered by name
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, listResp.Profiles[0])
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfG.ProfileUUID,
TeamID: tm2ProfG.TeamID,
Name: tm2ProfG.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, listResp.Profiles[1])
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfH.ProfileUUID,
TeamID: tm2ProfH.TeamID,
Name: tm2ProfH.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
},
}, listResp.Profiles[2])
// get the specific include-all label-based profile returns the information
var getProfResp getMDMConfigProfileResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfG.ProfileUUID, nil, http.StatusOK, &getProfResp)
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfG.ProfileUUID,
TeamID: tm2ProfG.TeamID,
Name: tm2ProfG.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, getProfResp.MDMConfigProfilePayload)
// get the specific exclude-any label-based profile returns the information
getProfResp = getMDMConfigProfileResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfF.ProfileUUID, nil, http.StatusOK, &getProfResp)
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfF.ProfileUUID,
TeamID: tm2ProfF.TeamID,
Name: tm2ProfF.Name,
Platform: "darwin",
Identifier: tm2ProfF.Identifier,
Checksum: tm2ProfF.Checksum,
Scope: string(fleet.PayloadScopeSystem),
// labels are ordered by name
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, getProfResp.MDMConfigProfilePayload)
// get the specific include-any label-based profile returns the information
getProfResp = getMDMConfigProfileResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfH.ProfileUUID, nil, http.StatusOK, &getProfResp)
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfH.ProfileUUID,
TeamID: tm2ProfH.TeamID,
Name: tm2ProfH.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
},
}, getProfResp.MDMConfigProfilePayload)
// list for a non-existing team returns 404
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999")
cases := []struct {
queries []string // alternate query name and value
teamID *uint
wantNames []string
wantMeta *fleet.PaginationMetadata
}{
{
wantNames: []string{"A", "B", "C", "D", "E"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2"},
wantNames: []string{"A", "B"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2", "page", "1"},
wantNames: []string{"C", "D"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
},
{
queries: []string{"per_page", "2", "page", "2"},
wantNames: []string{"E"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3"},
teamID: &tm1.ID,
wantNames: []string{"tA", "tB", "tC"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
},
{
queries: []string{"per_page", "3", "page", "1"},
teamID: &tm1.ID,
wantNames: []string{"tD", "tE"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3", "page", "2"},
teamID: &tm1.ID,
wantNames: nil,
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3"},
teamID: &tm2.ID,
wantNames: []string{"tF", "tG", "tH"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2"},
teamID: &tm3.ID,
wantNames: nil,
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) {
var listResp listMDMConfigProfilesResponse
queryArgs := c.queries
if c.teamID != nil {
queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID))
}
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, queryArgs...)
require.Equal(t, len(c.wantNames), len(listResp.Profiles))
require.Equal(t, c.wantMeta, listResp.Meta)
var gotNames []string
if len(listResp.Profiles) > 0 {
gotNames = make([]string, len(listResp.Profiles))
for i, p := range listResp.Profiles {
gotNames[i] = p.Name
if p.Name == "tG" {
require.Len(t, p.LabelsIncludeAll, 2)
} else {
require.Nil(t, p.LabelsIncludeAll)
}
if p.Name == "tF" {
require.Len(t, p.LabelsExcludeAny, 2)
} else {
require.Nil(t, p.LabelsExcludeAny)
}
if c.teamID == nil {
// we set it to 0 for global
require.NotNil(t, p.TeamID)
require.Zero(t, *p.TeamID)
} else {
require.NotNil(t, p.TeamID)
require.Equal(t, *c.teamID, *p.TeamID)
}
require.NotEmpty(t, p.Platform)
}
}
require.Equal(t, c.wantNames, gotNames)
})
}
}
func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
globalProfiles := []string{
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
}
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
teamProfiles := []string{
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
}
// create a non-Windows host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("non-windows-host"),
NodeKey: ptr.String("non-windows-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.non.windows", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// create a Windows host that's not enrolled into MDM
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "windows",
})
require.NoError(t, err)
verifyHostProfileStatus := func(cmds []fleet.ProtoCmdOperation, wantStatus string) {
for _, cmd := range cmds {
var gotProfile struct {
Status string `db:"status"`
Retries int `db:"retries"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `
SELECT COALESCE(status, 'pending') as status, retries
FROM host_mdm_windows_profiles
WHERE command_uuid = ?`
return sqlx.GetContext(context.Background(), q, &gotProfile, stmt, cmd.Cmd.CmdID.Value)
})
wantDeliveryStatus := fleet.WindowsResponseToDeliveryStatus(wantStatus)
if gotProfile.Retries <= servermdm.MaxWindowsProfileRetries && wantDeliveryStatus == fleet.MDMDeliveryFailed {
require.EqualValues(t, "pending", gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value)
} else {
require.EqualValues(t, wantDeliveryStatus, gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value)
}
}
}
verifyProfiles := func(device *mdmtest.TestWindowsMDMClient, n int, fail bool, nRemovals ...int) {
mdmResponseStatus := syncml.CmdStatusOK
if fail {
mdmResponseStatus = syncml.CmdStatusAtomicFailed
}
nRemove := 0
if len(nRemovals) > 0 {
nRemove = nRemovals[0]
}
s.awaitTriggerProfileSchedule(t)
cmds, err := device.StartManagementSession()
require.NoError(t, err)
// 2 Status + n install profiles + nRemove delete commands
require.Len(t, cmds, n+nRemove+2)
var atomicInstallCmds []fleet.ProtoCmdOperation
msgID, err := device.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
cmdID := c.Cmd.CmdID
status := syncml.CmdStatusOK
if c.Verb == "Atomic" {
if len(c.Cmd.ReplaceCommands) > 0 {
// Install command (Atomic with Replace sub-commands)
atomicInstallCmds = append(atomicInstallCmds, c)
status = mdmResponseStatus
for _, rc := range c.Cmd.ReplaceCommands {
require.NotEmpty(t, rc.CmdID)
}
} else {
// Delete command (Atomic with Delete sub-commands)
status = syncml.CmdStatusOK
}
}
device.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmdID.Value,
Cmd: ptr.String(c.Verb),
Data: &status,
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
// TODO: verify profile contents as well
require.Len(t, atomicInstallCmds, n)
// before we send the response, commands should be "pending"
verifyHostProfileStatus(atomicInstallCmds, "")
cmds, err = device.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
// verify that we updated status in the db
verifyHostProfileStatus(atomicInstallCmds, mdmResponseStatus)
}
checkHostsProfilesMatch := func(host *fleet.Host, wantUUIDs []string) {
var gotUUIDs []string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `SELECT profile_uuid FROM host_mdm_windows_profiles WHERE host_uuid = ? AND operation_type = ?`
return sqlx.SelectContext(context.Background(), q, &gotUUIDs, stmt, host.UUID, fleet.MDMOperationTypeInstall)
})
require.ElementsMatch(t, wantUUIDs, gotUUIDs)
}
checkHostDetails := func(t *testing.T, host *fleet.Host, wantProfs []string, wantStatus fleet.MDMDeliveryStatus) {
var gotHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &gotHostResp)
require.NotNil(t, gotHostResp.Host.MDM.Profiles)
var gotProfs []string
require.Len(t, *gotHostResp.Host.MDM.Profiles, len(wantProfs))
for _, p := range *gotHostResp.Host.MDM.Profiles {
gotProfs = append(gotProfs, strings.Replace(p.Name, "name-", "", 1))
require.NotNil(t, p.Status)
require.EqualValues(t, wantStatus, *p.Status, "profile", p.Name)
require.Equal(t, "windows", p.Platform)
// Fleet reserved profiles (e.g., OS updates) should be screened from the host details response
require.NotContains(t, servermdm.ListFleetReservedWindowsProfileNames(), p.Name)
}
require.ElementsMatch(t, wantProfs, gotProfs)
}
checkHostsFilteredByOSSettingsStatus := func(t *testing.T, wantHosts []string, wantStatus fleet.MDMDeliveryStatus, teamID *uint, labels ...*fleet.Label) {
var teamFilter string
if teamID != nil {
teamFilter = fmt.Sprintf("&team_id=%d", *teamID)
}
var gotHostsResp listHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
require.NotNil(t, gotHostsResp.Hosts)
var gotHosts []string
for _, h := range gotHostsResp.Hosts {
gotHosts = append(gotHosts, h.Hostname)
}
require.ElementsMatch(t, wantHosts, gotHosts)
var countHostsResp countHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
require.Equal(t, len(wantHosts), countHostsResp.Count)
for _, l := range labels {
gotHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/labels/%d/hosts?os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
require.NotNil(t, gotHostsResp.Hosts)
gotHosts = []string{}
for _, h := range gotHostsResp.Hosts {
gotHosts = append(gotHosts, h.Hostname)
}
require.ElementsMatch(t, wantHosts, gotHosts, "label", l.Name)
countHostsResp = countHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?label_id=%d&os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
}
}
getProfileUUID := func(t *testing.T, profName string, teamID *uint) string {
var profUUID string
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
var globalOrTeamID uint
if teamID != nil {
globalOrTeamID = *teamID
}
return sqlx.GetContext(ctx, tx, &profUUID, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, profName)
})
require.NotNil(t, profUUID)
return profUUID
}
checkHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, wantStatus fleet.MDMDeliveryStatus) {
var gotStatus fleet.MDMDeliveryStatus
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_uuid = ?`
err := sqlx.GetContext(context.Background(), q, &gotStatus, stmt, hostUUID, profUUID)
return err
})
require.Equal(t, wantStatus, gotStatus)
}
// Create a host and then enroll to MDM.
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// Trigger a profile sync (PENDING)
s.awaitTriggerProfileSchedule(t)
// can't resend windows configuration profiles as admin or from device endpoint while pending
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, globalProfiles[0]), nil, http.StatusConflict)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status cant be resent")
deviceToken := "windows-device-token"
createDeviceTokenForHost(t, s.ds, host.ID, deviceToken)
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", deviceToken, globalProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status cant be resent")
// trigger a profile sync
verifyProfiles(mdmDevice, 3, false)
checkHostsProfilesMatch(host, globalProfiles)
checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerified)
// create new label that includes host
label := &fleet.Label{
Name: t.Name() + "foo",
Query: "select * from foo;",
}
label, err = s.ds.NewLabel(context.Background(), label)
require.NoError(t, err)
require.NoError(t, s.ds.RecordLabelQueryExecutions(ctx, host, map[uint]*bool{label.ID: ptr.Bool(true)}, time.Now(), false))
// simulate osquery reporting host mdm details (host_mdm.enrolled = 1 is condition for
// hosts filtering by os settings status and generating mdm profiles summaries)
require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "", false))
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerified, nil, label)
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Verified: 1,
}, nil)
// another sync shouldn't return profiles
verifyProfiles(mdmDevice, 0, false)
// make fleet add a Windows OS Updates profile
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &acResp)
osUpdatesProf := getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, nil)
// os updates is sent via a profiles commands
verifyProfiles(mdmDevice, 1, false)
checkHostsProfilesMatch(host, append(globalProfiles, osUpdatesProf))
// but is hidden from host details response
checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerified)
// os updates profile status doesn't matter for filtered hosts results or summaries
checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerified)
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerified, nil, label)
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Verified: 1,
}, nil)
// force os updates profile to failed, doesn't impact filtered hosts results or summaries
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, osUpdatesProf)
return err
})
checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed)
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerified, nil, label)
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Verified: 1,
}, nil)
// force another profile to failed, does impact filtered hosts results and summaries
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, globalProfiles[0])
return err
})
checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryFailed)
checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerified, nil, label) // expect no hosts
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, nil, label) // expect host
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Failed: 1,
Verified: 0,
}, nil)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
// trigger a profile sync, device gets the team profile (+ delete commands for old global + OS updates profiles).
// Delete commands are individual <Delete> per LocURI: 3 global profiles × 1 LocURI + 1 OS updates × 6 LocURIs = 9.
verifyProfiles(mdmDevice, 2, false, 9)
checkHostsProfilesMatch(host, teamProfiles)
checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerified)
// set new team profiles (delete + addition)
oldTeamProfile := teamProfiles[1]
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, oldTeamProfile)
return err
})
// Also clean up the host-profile row for the deleted config profile
// since the direct SQL deletion bypasses cancelWindowsHostInstallsForDeletedMDMProfiles.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `DELETE FROM host_mdm_windows_profiles WHERE profile_uuid = ?`, oldTeamProfile)
return err
})
teamProfiles = []string{
teamProfiles[0],
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
}
// trigger a profile sync, device gets the team profile
verifyProfiles(mdmDevice, 1, false)
// check that we deleted the old profile in the DB
checkHostsProfilesMatch(host, teamProfiles)
checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerified)
// another sync shouldn't return profiles
verifyProfiles(mdmDevice, 0, false)
// set new team profiles (delete + addition)
oldTeamProfile = teamProfiles[1]
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, oldTeamProfile)
return err
})
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `DELETE FROM host_mdm_windows_profiles WHERE profile_uuid = ?`, oldTeamProfile)
return err
})
teamProfiles = []string{
teamProfiles[0],
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
}
// trigger a profile sync, this time fail the delivery
verifyProfiles(mdmDevice, 1, true)
// check that we deleted the old profile in the DB
checkHostsProfilesMatch(host, teamProfiles)
// a second sync gets the profile again, because of delivery retries.
// Succeed that one
verifyProfiles(mdmDevice, 1, false)
// another sync shouldn't return profiles
verifyProfiles(mdmDevice, 0, false)
// make fleet add a Windows OS Updates profile
tmResp := teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &tmResp)
osUpdatesProf = getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, &tm.ID)
// os updates is sent via a profiles commands
verifyProfiles(mdmDevice, 1, false)
checkHostsProfilesMatch(host, append(teamProfiles, osUpdatesProf))
// but is hidden from host details response
checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerified)
// os updates profile status doesn't matter for filtered hosts results or summaries
checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerified)
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerified, &tm.ID, label)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Verified: 1,
}, nil)
// force os updates profile to failed, doesn't impact filtered hosts results or summaries
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, osUpdatesProf)
return err
})
checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed)
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerified, &tm.ID, label)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Verified: 1,
}, nil)
// force another profile to failed, does impact filtered hosts results and summaries
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
return err
})
checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryFailed)
checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerified, &tm.ID, label) // expect no hosts
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, &tm.ID, label) // expect host
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Failed: 1,
Verified: 0,
}, nil)
// Resend the failed profile. Should succeed
s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Pending: 1,
}, nil)
// Try resending, should fail since it's pending
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status cant be resent")
// Trigger a profile sync, device gets the resent profile
verifyProfiles(mdmDevice, 1, false)
// update to verifying - should not allow resending
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'verifying' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
return err
})
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status cant be resent")
// trigger a profile sync, device doesn't get the profile since resend was not allowed
verifyProfiles(mdmDevice, 0, false)
// Update to verified, resending allowed again
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'verified' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
return err
})
s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
// Trigger a profile sync, device gets the resent profile
verifyProfiles(mdmDevice, 1, false)
// add a macOS profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "identifier-"+mcUUID, mcUUID)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, test.MakeTestBytes())
return err
})
// trigger a profile sync, device doesn't get the macOS profile
verifyProfiles(mdmDevice, 0, false)
// can't resend a macOS profile to a Windows host
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Profile is not compatible with host platform")
t.Run("edit_profile_removes_locuri_sends_delete", func(t *testing.T) {
// Editing a profile to remove a LocURI should send a <Delete> for the removed LocURI.
// Create a profile with two LocURIs via batch-set, then edit it to have only one.
// The device should receive a <Delete> for the removed LocURI plus an install for the updated profile.
// First, clean up: use batch-set with only our new test profile to remove all
// existing team profiles cleanly (this goes through the proper deletion path).
twoLocURIProfile := `<Atomic><Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Camera/AllowCamera</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>0</Data></Item></Replace><Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Experience/AllowCortana</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>0</Data></Item></Replace></Atomic>`
// Also disable OS updates to simplify
tmResp := teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": null, "grace_period_days": null} }}`), http.StatusOK, &tmResp)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "edit-locuri-test", Contents: []byte(twoLocURIProfile)},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
// Trigger profile sync: device gets the new profile + delete commands for old profiles.
// Drain all commands from the device without asserting exact counts (cleanup varies).
s.awaitTriggerProfileSchedule(t)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
cmdID := c.Cmd.CmdID
status := syncml.CmdStatusOK
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmdID.Value,
Cmd: ptr.String(c.Verb),
Data: &status,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
// Drain any remaining commands until the device is clean.
verifyProfiles(mdmDevice, 0, false)
// Now edit the profile to remove AllowCortana, keeping only AllowCamera.
oneLocURIProfile := `<Atomic><Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Camera/AllowCamera</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>0</Data></Item></Replace></Atomic>`
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "edit-locuri-test", Contents: []byte(oneLocURIProfile)},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
// Trigger profile sync: device should get:
// - 1 <Delete> for the removed AllowCortana LocURI (from batchSet edit diff)
// - 1 Atomic install for the updated profile (from reconciler checksum mismatch)
// Total: 2 Status + 1 Delete + 1 Atomic = 4 commands
s.awaitTriggerProfileSchedule(t)
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
require.Len(t, cmds, 4, "expected 2 status + 1 delete + 1 install")
var gotDelete, gotInstall bool
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
cmdID := c.Cmd.CmdID
status := syncml.CmdStatusOK
switch c.Verb {
case "Delete":
gotDelete = true
require.Contains(t, c.Cmd.GetTargetURI(), "AllowCortana",
"Delete should target the removed LocURI")
case "Atomic":
if len(c.Cmd.ReplaceCommands) > 0 {
gotInstall = true
// Verify the install only has AllowCamera, not AllowCortana
var installURIs []string
for _, rc := range c.Cmd.ReplaceCommands {
installURIs = append(installURIs, rc.GetTargetURI())
}
require.Len(t, installURIs, 1)
require.Contains(t, installURIs[0], "AllowCamera")
}
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmdID.Value,
Cmd: ptr.String(c.Verb),
Data: &status,
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.True(t, gotDelete, "expected a Delete command for the removed LocURI")
require.True(t, gotInstall, "expected an install command for the updated profile")
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
require.Len(t, cmds, 1) // just the ack
})
t.Run("edit_profile_does_not_delete_locuri_used_by_another_profile", func(t *testing.T) {
// If profile A has LocURIs X, Y and profile B has LocURI Y,
// editing A to remove Y should NOT send a <Delete> for Y
// because profile B still enforces it.
// Set up two profiles that share a LocURI (AllowCamera).
profileA := `<Atomic><Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Camera/AllowCamera</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>0</Data></Item></Replace><Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Experience/AllowCortana</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>0</Data></Item></Replace></Atomic>`
profileB := `<Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Camera/AllowCamera</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>1</Data></Item></Replace>`
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "shared-locuri-A", Contents: []byte(profileA)},
{Name: "shared-locuri-B", Contents: []byte(profileB)},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
// Drain install commands.
s.awaitTriggerProfileSchedule(t)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
cmdID := c.Cmd.CmdID
status := syncml.CmdStatusOK
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmdID.Value,
Cmd: ptr.String(c.Verb),
Data: &status,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
verifyProfiles(mdmDevice, 0, false)
// Edit profile A to remove AllowCamera (shared with B), keep only AllowCortana.
profileAEdited := `<Replace><Item><Target><LocURI>./Device/Vendor/MSFT/Policy/Config/Experience/AllowCortana</LocURI></Target><Meta><Format xmlns="syncml:metinf">int</Format></Meta><Data>0</Data></Item></Replace>`
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "shared-locuri-A", Contents: []byte(profileAEdited)},
{Name: "shared-locuri-B", Contents: []byte(profileB)},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
// Trigger sync: should get an install for the updated A, but NO delete
// for AllowCamera since profile B still uses it.
s.awaitTriggerProfileSchedule(t)
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
if c.Verb == "Delete" {
require.NotContains(t, c.Cmd.GetTargetURI(), "AllowCamera",
"should NOT delete AllowCamera because profile B still uses it")
}
cmdID := c.Cmd.CmdID
status := syncml.CmdStatusOK
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmdID.Value,
Cmd: ptr.String(c.Verb),
Data: &status,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
})
}
func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
rawTeamSpec := func(mdmValue string) json.RawMessage {
return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue))
}
// set the windows custom settings fields
var applyResp applyTeamSpecsResponse
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels": ["baz"]},
{"path": "bar", "labels_exclude_any": ["x", "y"]}
]
}
}
`), http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// check that they are returned by a GET /config
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.ElementsMatch(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"baz"}},
{Path: "bar", LabelsExcludeAny: []string{"x", "y"}},
}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// patch without specifying the windows custom settings fields and an unrelated
// field, should not remove them
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// check that they are returned by a GET /config
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.ElementsMatch(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"baz"}},
{Path: "bar", LabelsExcludeAny: []string{"x", "y"}},
}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// patch with explicitly empty windows custom settings fields, would remove
// them but this is a dry-run
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{ "windows_settings": { "custom_settings": null } }
`), http.StatusOK, &applyResp, "dry_run", "true")
assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.ElementsMatch(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"baz"}},
{Path: "bar", LabelsExcludeAny: []string{"x", "y"}},
}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// patch with explicitly empty windows custom settings fields, removes them
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{ "windows_settings": { "custom_settings": null } }
`), http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// apply with invalid mix of labels fails
res := s.Do("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels": ["a"], "labels_include_all": ["b"]}
]
}
}
`), http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{{Contents: []byte(bigString)}}},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB")
// invalid profile (bad mobileconfig)
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{
Name: "Bad mobileconfig", Contents: []byte(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array/>`),
},
}}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: new MDMAppleConfigProfile: plist: error parsing XML property list: XML syntax error")
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent)
// Nothing changed, so no activity items
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate PayloadDisplayName
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: p, Contents: mobileconfigForTest(p, p)},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: []byte(`{"foo":}`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "N4 is not a valid macOS, Windows, or Android configuration profile")
// profiles with reserved Windows location URIs
// bitlocker
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: syncml.FleetBitLockerTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
{Name: "N3", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: syncml.FleetOSUpdateTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))},
{Name: "N3", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: []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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: []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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: 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, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name, "no-such-label"}},
}}, http.StatusBadRequest)
msg := extractServerErrorText(res.Body)
require.Contains(t, msg, `Couldn't update. Label "no-such-label" doesn't exist. Please remove the label from the configuration profile.`)
// mix of labels fields
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name, lbl2.Name}},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}},
{Name: "N4", Contents: 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].LabelsIncludeAll, []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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}},
{Name: "N2", Contents: 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.MDMProfileBatchPayload
expectErr string
}{
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
} {
// team profiles
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: 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/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
}
}
// 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, "fleet_id": null, "fleet_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_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.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "N4 is not a valid macOS, Windows, or Android configuration profile.")
// 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: "NotRelevant", Profile: mobileconfigForTest("N1", "I1")}, // Check that we don't care about displayname for mobileconfig profiles
{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, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, 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, `Couldn't update. Label "no-such-label" doesn't exist. Please remove the label from the configuration profile.`)
// 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)
}
// Get the current list of configuration profiles
var currentProfiles listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &currentProfiles)
require.Greater(t, len(currentProfiles.Profiles), 0)
// Now we disable all three MDM's
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.EnabledAndConfigured = false
appCfg.MDM.WindowsEnabledAndConfigured = false
appCfg.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
// Now do a batch with profiles in it, to see it fails trying to add.
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "NEW", Profile: mobileconfigForTest("NEW", "INEW")},
}}, http.StatusUnprocessableEntity)
// Now do a batch without any profiles to ensure we can delete with all MDM's disabled.
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{}}, http.StatusNoContent)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
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)
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent)
// Nothing changed, so no activity
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null, "fleet_id": null, "fleet_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate PayloadDisplayName
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": mobileconfigForTest("N1", "I2"),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved macOS identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
p: mobileconfigForTest(p, p),
"N3": syncMLForTest("./Foo/Bar"),
}}, 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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
"N3": syncMLForTest("./Foo/Bar"),
}}, 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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTestWithContent("N1", "I1", p, "random", ""),
"N3": syncMLForTest("./Foo/Bar"),
}}, 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 reserved Windows location URIs
// bitlocker
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
syncml.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI)),
"N3": 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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
syncml.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI)),
"N3": 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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N3": []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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N3": []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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": syncMLForTest("./Foo/Bar"),
}}, 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/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": syncMLForTest("./Foo/Bar"),
}}, 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, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "fleet_id": %d, "fleet_name": %q}`, tm.ID, tm.Name, tm.ID, tm.Name),
0,
)
}
func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() {
t := s.T()
ctx := context.Background()
kv := redis_key_value.New(s.redisPool)
checkMacProfs := func(teamID *uint, names ...string) {
var count int
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var tid uint
if teamID != nil {
tid = *teamID
}
return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_apple_configuration_profiles WHERE team_id = ?`, tid)
})
require.Equal(t, len(names), count)
for _, n := range names {
s.assertMacOSConfigProfilesByName(teamID, n, true)
}
}
checkWinProfs := func(teamID *uint, names ...string) {
var count int
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var tid uint
if teamID != nil {
tid = *teamID
}
return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tid)
})
for _, n := range names {
s.assertWindowsConfigProfilesByName(teamID, n, true)
}
}
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
require.True(t, acResp.MDM.EnabledAndConfigured)
require.True(t, acResp.MDM.WindowsEnabledAndConfigured)
// ensures that the fleetd profile is created
secrets, err := s.ds.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
if len(secrets) == 0 {
require.NoError(t, s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}))
}
require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, kv, s.logger, 0))
// turn on disk encryption and os updates
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"enable_disk_encryption": true,
"windows_updates": {
"deadline_days": 3,
"grace_period_days": 1
},
"macos_updates": {
"deadline": "2023-12-31",
"minimum_version": "14.6.1"
}
}
}`), http.StatusOK, &acResp)
checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
// batch set only windows profiles doesn't remove the reserved names
newWinProfile := syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "l1", Data: "d1"}})
var testProfiles []fleet.MDMProfileBatchPayload
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "n1",
Contents: newWinProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set windows and mac profiles doesn't remove the reserved names
newMacProfile := mcBytesForTest("n2", "i2", uuid.NewString())
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "n2",
Contents: newMacProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set only mac profiles doesn't remove the reserved names
testProfiles = []fleet.MDMProfileBatchPayload{{
Name: "n2",
Contents: newMacProfile,
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
// create a team
var tmResp teamResponse
s.DoJSON("POST", "/api/v1/fleet/teams", map[string]string{"Name": t.Name()}, http.StatusOK, &tmResp)
// edit team mdm config to turn on disk encryption and os updates
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tmResp.Team.ID), modifyTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(t.Name()),
MDM: &fleet.TeamPayloadMDM{
EnableDiskEncryption: optjson.SetBool(true),
WindowsUpdates: &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(4),
GracePeriodDays: optjson.SetInt(1),
},
MacOSUpdates: &fleet.AppleOSUpdateSettings{
Deadline: optjson.SetString("2023-12-31"),
MinimumVersion: optjson.SetString("14.6.1"),
UpdateNewHosts: optjson.SetBool(true),
},
},
},
}, http.StatusOK, &teamResponse{})
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/teams/%d", tmResp.Team.ID), nil, http.StatusOK, &tmResp)
require.True(t, tmResp.Team.Config.MDM.EnableDiskEncryption)
require.Equal(t, 4, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 1, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
require.Equal(t, "2023-12-31", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "14.6.1", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, true, tmResp.Team.Config.MDM.MacOSUpdates.UpdateNewHosts.Value)
require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, kv, s.logger, 0))
checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
// batch set only windows profiles doesn't remove the reserved names
var testTeamProfiles []fleet.MDMProfileBatchPayload
testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
Name: "n1",
Contents: newWinProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tmResp.Team.ID))
checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set windows and mac profiles doesn't remove the reserved names
testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
Name: "n2",
Contents: newMacProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tmResp.Team.ID))
checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set only mac profiles doesn't remove the reserved names
testTeamProfiles = []fleet.MDMProfileBatchPayload{{
Name: "n2",
Contents: newMacProfile,
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tmResp.Team.ID))
checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
}
func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
teamDelete, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TeamDelete"})
require.NoError(t, err)
testProfiles := make(map[string]fleet.MDMAppleConfigProfile)
generateTestProfile := func(name string, identifier string) {
i := identifier
if i == "" {
i = fmt.Sprintf("%s.SomeIdentifier", name)
}
cp := fleet.MDMAppleConfigProfile{
Name: name,
Identifier: i,
}
cp.Mobileconfig = mcBytesForTest(cp.Name, cp.Identifier, fmt.Sprintf("%s.UUID", name))
testProfiles[name] = cp
}
setTestProfileID := func(name string, id uint) {
tp := testProfiles[name]
tp.ProfileID = id
testProfiles[name] = tp
}
generateNewReq := func(name string, teamID *uint) (*bytes.Buffer, map[string]string) {
args := map[string][]string{}
if teamID != nil {
args["team_id"] = []string{fmt.Sprintf("%d", *teamID)}
}
return generateNewProfileMultipartRequest(t, "some_filename", testProfiles[name].Mobileconfig, s.token, args)
}
checkGetResponse := func(resp *http.Response, expected fleet.MDMAppleConfigProfile) {
// check expected headers
require.Contains(t, resp.Header["Content-Type"], "application/x-apple-aspen-config")
require.Contains(t, resp.Header["Content-Disposition"], fmt.Sprintf(`attachment;filename="%s_%s.%s"`, time.Now().Format("2006-01-02"), strings.ReplaceAll(expected.Name, " ", "_"), "mobileconfig"))
// check expected body
var bb bytes.Buffer
_, err = io.Copy(&bb, resp.Body)
require.NoError(t, err)
require.Equal(t, []byte(expected.Mobileconfig), bb.Bytes())
}
checkConfigProfile := func(expected fleet.MDMAppleConfigProfile, actual fleet.MDMAppleConfigProfile) {
require.Equal(t, expected.Name, actual.Name)
require.Equal(t, expected.Identifier, actual.Identifier)
}
host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.Do("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &teamDelete.ID,
HostIDs: []uint{host.ID},
}, http.StatusOK)
// create new profile (no team)
generateTestProfile("TestNoTeam", "")
body, headers := generateNewReq("TestNoTeam", nil)
newResp := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
var newCP fleet.MDMAppleConfigProfile
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestNoTeam", newCP.ProfileID)
// create new profile (with team id)
generateTestProfile("TestWithTeamID", "")
body, headers = generateNewReq("TestWithTeamID", &testTeam.ID)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestWithTeamID", newCP.ProfileID)
// Create a profile that we're going to remove immediately
generateTestProfile("TestImmediateDelete", "")
body, headers = generateNewReq("TestImmediateDelete", &teamDelete.ID)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
newCP = fleet.MDMAppleConfigProfile{}
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestImmediateDelete", newCP.ProfileID)
// check that host_mdm_apple_profiles entry was created
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
require.NotNil(t, hostResp.Host.MDM.Profiles)
require.Len(t, *hostResp.Host.MDM.Profiles, 1)
require.Equal(t, (*hostResp.Host.MDM.Profiles)[0].Name, "TestImmediateDelete")
// now delete the profile before it's sent, we should see the host_mdm_apple_profiles entry go
// away
deletedCP := testProfiles["TestImmediateDelete"]
deletePath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
var deleteResp deleteMDMAppleConfigProfileResponse
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
var listResp listMDMAppleConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: teamDelete.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// confirm no host profiles
hostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
require.Nil(t, hostResp.Host.MDM.Profiles)
// list profiles (no team)
expectedCP := testProfiles["TestNoTeam"]
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 1)
respCP := listResp.ConfigProfiles[0]
require.Equal(t, expectedCP.Name, respCP.Name)
checkConfigProfile(expectedCP, *respCP)
require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
require.Empty(t, respCP.TeamID) // zero means no team
// list profiles (team 1)
expectedCP = testProfiles["TestWithTeamID"]
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 1)
respCP = listResp.ConfigProfiles[0]
require.Equal(t, expectedCP.Name, respCP.Name)
checkConfigProfile(expectedCP, *respCP)
require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
require.Equal(t, testTeam.ID, *respCP.TeamID) // team 1
// get profile (no team)
expectedCP = testProfiles["TestNoTeam"]
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
getResp := s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
checkGetResponse(getResp, expectedCP)
// get profile (team 1)
expectedCP = testProfiles["TestWithTeamID"]
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
getResp = s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
checkGetResponse(getResp, expectedCP)
// delete profile (no team)
deletedCP = testProfiles["TestNoTeam"]
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// delete profile (team 1)
deletedCP = testProfiles["TestWithTeamID"]
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
deleteResp = deleteMDMAppleConfigProfileResponse{}
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// fail to create new profile (no team), invalid fleet secret
testProfiles["badSecrets"] = fleet.MDMAppleConfigProfile{
Name: "badSecrets",
Identifier: "badSecrets.One",
Mobileconfig: mobileconfig.Mobileconfig(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array/>
<key>PayloadDisplayName</key>
<string>badSecrets</string>
<key>PayloadIdentifier</key>
<string>badSecrets.One</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>$FLEET_SECRET_INVALID.35E2029E-A0C2-4754-B709-4CAAB1B8D3CB</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
`),
}
body, headers = generateNewReq("badSecrets", nil)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg := extractServerErrorText(newResp.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
// trying to add/delete profiles with identifiers managed by Fleet fails
for p := range mobileconfig.FleetPayloadIdentifiers() {
generateTestProfile("TestNoTeam", p)
body, headers := generateNewReq("TestNoTeam", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
generateTestProfile("TestWithTeamID", p)
body, headers = generateNewReq("TestWithTeamID", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
require.NoError(t, err)
testProfiles["WithContent"] = *cp
body, headers = generateNewReq("WithContent", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
}
// trying to add profiles with identifiers managed by Fleet fails
for p := range mobileconfig.FleetPayloadIdentifiers() {
generateTestProfile("TestNoTeam", p)
body, headers := generateNewReq("TestNoTeam", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
generateTestProfile("TestWithTeamID", p)
body, headers = generateNewReq("TestWithTeamID", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
require.NoError(t, err)
testProfiles["WithContent"] = *cp
body, headers = generateNewReq("WithContent", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
}
// trying to add profiles with names reserved by Fleet fails
for name := range servermdm.FleetReservedProfileNames() {
cp := &fleet.MDMAppleConfigProfile{
Name: name,
Identifier: "valid.identifier",
Mobileconfig: mcBytesForTest(name, "valid.identifier", "some-uuid"),
}
body, headers := generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
"team_id": {fmt.Sprintf("%d", testTeam.ID)},
})
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent(
"valid outer name",
"valid.outer.identifier",
"valid.inner.identifer",
"some-uuid",
name,
), nil)
require.NoError(t, err)
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp.TeamID = &testTeam.ID
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
"team_id": {fmt.Sprintf("%d", testTeam.ID)},
})
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
}
// make fleet add a FileVault profile
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
// try to delete the profile
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", profile.ProfileID)
deleteResp = deleteMDMAppleConfigProfileResponse{}
s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp)
}
func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
triggerReconcileProfiles := func() {
s.awaitTriggerProfileSchedule(t)
// this will only mark them as "pending", as the response to confirm
// profile deployment is asynchronous, so we simulate it here by
// updating any "pending" (not NULL) profiles to "verifying"
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
return nil
})
}
// run the crons immediately, will create the Fleet-controlled profiles that
// will then be expected to be applied (e.g. com.fleetdm.fleetd.config and
// com.fleetdm.caroot)
// first create the no-team enroll secret (required to create the fleet profiles)
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}},
}, http.StatusOK, &applyResp)
s.awaitTriggerProfileSchedule(t)
// create an Apple and a Windows host
appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// create a few labels
labels := make([]*fleet.Label, 5)
for i := 0; i < len(labels); i++ {
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"})
require.NoError(t, err)
labels[i] = label
}
// simulate reporting label results for those hosts
appleHost.LabelUpdatedAt = time.Now()
windowsHost.LabelUpdatedAt = time.Now()
err := s.ds.UpdateHost(ctx, appleHost)
require.NoError(t, err)
err = s.ds.UpdateHost(ctx, windowsHost)
require.NoError(t, err)
// set an Apple profile and declaration and a Windows profile
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsExcludeAny: []string{labels[0].Name, labels[1].Name}},
{Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsExcludeAny: []string{labels[2].Name, labels[3].Name}},
{Name: "D3", Contents: declarationForTest("D3"), LabelsExcludeAny: []string{labels[4].Name}},
}}, http.StatusNoContent)
// hosts are not members of any label yet, so running the cron applies the labels
s.awaitTriggerProfileSchedule(t)
s.awaitRunAppleMDMWorkerSchedule()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+appleHost.UUID)
require.NoError(t, err)
// simulate the reconcile profiles deployment
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// mark some profiles as verified (despite accepting a HostMacOSProfile struct, it supports Windows too)
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, appleHost, map[string]*fleet.HostMacOSProfile{
"A1": {Identifier: "A1", DisplayName: "A1", InstallDate: time.Now()},
})
require.NoError(t, err)
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, windowsHost, map[string]*fleet.HostMacOSProfile{
"W2": {Identifier: "W2", DisplayName: "W2", InstallDate: time.Now()},
})
require.NoError(t, err)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
})
// make hosts members of labels [1], [2], [3] and [4], meaning that none of the profiles apply anymore
err = s.ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{
{labels[1].ID, appleHost.ID},
{labels[2].ID, appleHost.ID},
{labels[3].ID, appleHost.ID},
{labels[4].ID, appleHost.ID},
{labels[1].ID, windowsHost.ID},
{labels[2].ID, windowsHost.ID},
{labels[3].ID, windowsHost.ID},
{labels[4].ID, windowsHost.ID},
})
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// windows profiles now get marked for removal with a pending delete command
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
},
})
// remove membership of labels [2] for Windows, and [4] for Apple, meaning
// that only D3 will be installed on Apple (as the Windows host is still
// member of an excluded label)
err = s.ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{
{labels[4].ID, appleHost.ID},
{labels[2].ID, windowsHost.ID},
})
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
},
})
// remove label [3] as an excluded label for the Windows profile, meaning
// that the host now meets the requirement to install.
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsExcludeAny: []string{labels[0].Name, labels[1].Name}},
{Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsExcludeAny: []string{labels[2].Name}},
{Name: "D3", Contents: declarationForTest("D3"), LabelsExcludeAny: []string{labels[4].Name}},
}}, http.StatusNoContent)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// The batch set above removed label [3] as an exclude label for W2, so the
// host now meets the requirement and W2 is re-installed (the install query
// detects profiles in desired state with operation_type='remove' and flips
// them back to install).
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// simulate the reconcile profiles deployment and mark as verified
triggerReconcileProfiles()
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, windowsHost, map[string]*fleet.HostMacOSProfile{
"W2": {Identifier: "W2", DisplayName: "W2", InstallDate: time.Now()},
})
require.NoError(t, err)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// W2 was re-installed (flipped from remove to install) and is now
// install+pending after the reconciler sent the command.
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
})
// break the A1 profile by deleting labels [1]
err = s.ds.DeleteLabel(ctx, labels[1].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.NoError(t, err)
// it doesn't get installed to the Apple host, as it is broken
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// W2 is still install+pending from the re-install triggered earlier.
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
})
// it also doesn't get installed to a new host not a member of any labels
appleHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.awaitRunAppleMDMWorkerSchedule()
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+appleHost2.UUID)
require.NoError(t, err)
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
appleHost2: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
})
// delete labels [2] and [4], breaking D3 and W2, they don't get removed
// since they are broken
err = s.ds.DeleteLabel(ctx, labels[2].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.NoError(t, err)
err = s.ds.DeleteLabel(ctx, labels[4].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.NoError(t, err)
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
appleHost2: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// W2 references a deleted label, so the reconciler skips it entirely
// (it appears in neither the install nor the remove list).
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
})
}
func (s *integrationMDMTestSuite) TestMDMProfilesIncludeAnyLabels() {
t := s.T()
ctx := context.Background()
s.setSkipWorkerJobs(t)
triggerReconcileProfiles := func() {
s.awaitTriggerProfileSchedule(t)
// this will only mark them as "pending", as the response to confirm
// profile deployment is asynchronous, so we simulate it here by
// updating any "pending" (not NULL) profiles to "verifying"
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
return nil
})
}
// run the crons immediately, will create the Fleet-controlled profiles that
// will then be expected to be applied (e.g. com.fleetdm.fleetd.config and
// com.fleetdm.caroot)
// first create the no-team enroll secret (required to create the fleet profiles)
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}},
}, http.StatusOK, &applyResp)
s.awaitTriggerProfileSchedule(t)
// create an Apple and a Windows host
appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
s.awaitRunAppleMDMWorkerSchedule()
err := s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+appleHost.UUID)
require.NoError(t, err)
// create a few labels, we'll use the first five for "exclude any" profiles and the remaining for "include any"
labels := make([]*fleet.Label, 10)
for i := 0; i < len(labels); i++ {
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"})
require.NoError(t, err)
labels[i] = label
}
// simulate reporting label results for those hosts
appleHost.LabelUpdatedAt = time.Now()
windowsHost.LabelUpdatedAt = time.Now()
err = s.ds.UpdateHost(ctx, appleHost)
require.NoError(t, err)
err = s.ds.UpdateHost(ctx, windowsHost)
require.NoError(t, err)
// set up some Apple profiles and declarations and Windows profiles
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsIncludeAny: []string{labels[0].Name, labels[1].Name}},
{Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsIncludeAny: []string{labels[2].Name, labels[3].Name}},
{Name: "D3", Contents: declarationForTest("D3"), LabelsIncludeAny: []string{labels[4].Name}},
}}, http.StatusNoContent)
// hosts are not members of any label yet, so running the cron applies no labels
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {},
})
// make hosts members of labels [1], [2], [3] and [4], meaning that each of the "include any"
// labels will now match at least one host
err = s.ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{
{labels[0].ID, appleHost.ID},
{labels[1].ID, appleHost.ID},
{labels[2].ID, appleHost.ID},
{labels[3].ID, appleHost.ID},
{labels[4].ID, appleHost.ID},
{labels[1].ID, windowsHost.ID},
{labels[2].ID, windowsHost.ID},
{labels[3].ID, windowsHost.ID},
{labels[4].ID, windowsHost.ID},
})
require.NoError(t, err)
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// remove membership of labels [2] for Windows, and [1] and [4] for Apple, meaning
// that D3 will be removed on Apple, A1 will remain on Apple because the host is still a member
// of [0], and W2 will remain on Windows because the host is still a member of [3]
err = s.ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{
{labels[1].ID, appleHost.ID},
{labels[4].ID, appleHost.ID},
{labels[2].ID, windowsHost.ID},
})
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
}
func (s *integrationMDMTestSuite) TestOTAProfile() {
t := s.T()
ctx := context.Background()
// Getting profile for non-existent secret it's ok
s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real")
// Create an enroll secret; has some special characters that should be escaped in the profile
globalEnrollSec := "global_enroll+_/sec"
escSec := url.QueryEscape(globalEnrollSec)
s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
},
}, http.StatusOK)
cfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
t.Run("gets profile with idp uuid included if boyd cookie is set", func(t *testing.T) {
// Get profile with that enroll secret
j, err := json.Marshal(getOTAProfileRequest{})
require.NoError(t, err)
idpUUID := uuid.New()
resp := s.DoRawWithHeaders("GET", "/api/latest/fleet/enrollment_profiles/ota", j, http.StatusOK, map[string]string{
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, idpUUID.String()),
}, "enroll_secret", globalEnrollSec)
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`)
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s&amp;idp_uuid=%s", cfg.ServerSettings.ServerURL, escSec, idpUUID.String()))
require.Contains(t, string(b), cfg.OrgInfo.OrgName)
})
t.Run("does not include idp_uuid in the url if cookie is not set", func(t *testing.T) {
resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", &getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec)
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`)
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec))
require.NotContains(t, string(b), "idp_uuid=")
require.Contains(t, string(b), cfg.OrgInfo.OrgName)
})
}
// TestAppleDDMSecretVariablesUpload tests uploading DDM profiles with secrets via the /configuration_profiles endpoint
func (s *integrationMDMTestSuite) TestAppleDDMSecretVariablesUpload() {
tmpl := `
{
"Type": "com.apple.configuration.decl%d",
"Identifier": "com.fleet.config%d",
"Payload": {
"ServiceType": "com.apple.bash%d",
"DataAssetReference": "com.fleet.asset.bash"
}
}`
newProfileBytes := func(i int) []byte {
return []byte(fmt.Sprintf(tmpl, i, i, i))
}
getProfileContents := func(profileUUID string) string {
profile, err := s.ds.GetMDMAppleDeclaration(context.Background(), profileUUID)
require.NoError(s.T(), err)
assert.NotNil(s.T(), profile.SecretsUpdatedAt)
return string(profile.RawJSON)
}
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "json", "darwin")
}
func (s *integrationMDMTestSuite) testSecretVariablesUpload(newProfileBytes func(i int) []byte,
getProfileContents func(profileUUID string) string, fileExtension string, platform string,
) {
t := s.T()
const numProfiles = 2
var profiles [][]byte
for i := 0; i < numProfiles; i++ {
profiles = append(profiles, newProfileBytes(i))
}
// Use secrets
myBash := "com.apple.bash0"
profiles[0] = []byte(strings.ReplaceAll(string(profiles[0]), myBash, "$"+fleet.ServerSecretPrefix+"BASH"))
secretProfile := profiles[1]
profiles[1] = []byte("${" + fleet.ServerSecretPrefix + "PROFILE}")
body, headers := generateNewProfileMultipartRequest(
t, "secret-config0."+fileExtension, profiles[0], s.token, nil,
)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusUnprocessableEntity, headers)
assertBodyContains(t, res, `Secret variable \"$FLEET_SECRET_BASH\" missing`)
// Add secret(s) to server
req := createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_BASH",
Value: myBash,
},
{
Name: "FLEET_SECRET_PROFILE",
Value: string(secretProfile),
},
},
}
secretResp := createSecretVariablesResponse{}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.ProfileUUID)
body, headers = generateNewProfileMultipartRequest(
t, "secret-config1."+fileExtension, profiles[1], s.token, nil,
)
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.ProfileUUID)
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, numProfiles)
profileUUIDs := make([]string, numProfiles)
for _, p := range listResp.Profiles {
switch p.Name {
case "secret-config0":
assert.Equal(t, platform, p.Platform)
profileUUIDs[0] = p.ProfileUUID
case "secret-config1":
assert.Equal(t, platform, p.Platform)
profileUUIDs[1] = p.ProfileUUID
default:
t.Errorf("unexpected profile %s", p.Name)
}
}
// Check that contents are masking secret values
for i := 0; i < numProfiles; i++ {
assert.Equal(t, string(profiles[i]), getProfileContents(profileUUIDs[i]))
}
// Delete profiles -- make sure there is no issue deleting profiles with secrets
for i := 0; i < numProfiles; i++ {
s.Do("DELETE", "/api/latest/fleet/configuration_profiles/"+profileUUIDs[i], nil, http.StatusOK)
}
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Empty(t, listResp.Profiles)
}
// TestAppleConfigSecretVariablesUpload tests uploading Apple config profiles with secrets via the /configuration_profiles endpoint
func (s *integrationMDMTestSuite) TestAppleConfigSecretVariablesUpload() {
tmpl := `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadDescription</key>
<string>For secret variables</string>
<key>PayloadDisplayName</key>
<string>secret-config%d</string>
<key>PayloadIdentifier</key>
<string>PI%d</string>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>%d</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadContent</key>
<array>
<dict>
<key>Bash</key>
<string>$FLEET_SECRET_BASH</string>
<key>PayloadDisplayName</key>
<string>secret payload</string>
<key>PayloadIdentifier</key>
<string>com.test.secret</string>
<key>PayloadType</key>
<string>com.test.secretd</string>
<key>PayloadUUID</key>
<string>476F5334-D501-4768-9A31-1A18A4E1E808</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</array>
</dict>
</plist>`
newProfileBytes := func(i int) []byte {
return []byte(fmt.Sprintf(tmpl, i, i, i))
}
getProfileContents := func(profileUUID string) string {
profile, err := s.ds.GetMDMAppleConfigProfile(context.Background(), profileUUID)
require.NoError(s.T(), err)
assert.NotNil(s.T(), profile.SecretsUpdatedAt)
return string(profile.Mobileconfig)
}
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "mobileconfig", "darwin")
}
// TestWindowsConfigSecretVariablesUpload tests uploading Windows profiles with secrets via the /configuration_profiles endpoint
func (s *integrationMDMTestSuite) TestWindowsConfigSecretVariablesUpload() {
tmpl := `
<Replace>
<Item>
<Meta>
<Format xmlns="syncml:metinf">int</Format>
</Meta>
<Target>
<LocURI>./Device/Vendor/MSFT/Policy/Config/System/DisableOneDriveFileSync</LocURI>
</Target>
<Data>$FLEET_SECRET_BASH</Data>
</Item>
</Replace>
`
newProfileBytes := func(i int) []byte {
return []byte(fmt.Sprintf(tmpl, i, i, i))
}
getProfileContents := func(profileUUID string) string {
profile, err := s.ds.GetMDMWindowsConfigProfile(context.Background(), profileUUID)
require.NoError(s.T(), err)
return string(profile.SyncML)
}
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "xml", "windows")
}
func (s *integrationMDMTestSuite) TestAppleProfileDeletion() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
}
wantGlobalProfiles := globalProfiles
wantGlobalProfiles = append(
wantGlobalProfiles,
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
)
// add global profiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// make sure ensureFleetProfiles has been called
s.awaitTriggerProfileSchedule(t)
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// Add IdP email to host
mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error {
_, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp@example.com", host.ID,
fleet.DeviceMappingMDMIdpAccounts)
return err
})
s.awaitRunAppleMDMWorkerSchedule()
installs, removes := checkNextPayloads(t, mdmDevice, false)
// verify that we received all profiles
s.signedProfilesMatch(
append(wantGlobalProfiles, setupExpectedCAProfile(t, s.ds)),
installs,
)
require.Empty(t, removes)
// Simulate 1 minute expiration for redis key
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+host.UUID)
require.NoError(t, err)
// Add a profile with a Fleet variable. We are also testing that removal of a profile with a Fleet variable works.
// A unique command is created for each host when this Fleet variable is used.
globalProfilesPlusOne := [][]byte{
globalProfiles[0],
mobileconfigForTest("N2", "$FLEET_VAR_"+string(fleet.FleetVarHostEndUserEmailIDP)),
}
// via the deprecated endpoint, this fails because variables are not supported
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfilesPlusOne},
http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "profile variables are not supported by this deprecated endpoint")
// via the new endpoint, this works
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: globalProfilesPlusOne[0]},
{Name: "N2", Contents: globalProfilesPlusOne[1]},
}}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// Make sure profile was uploaded
profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 4)
// Delete a profile before it is sent to device
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
sendErrorOnRemoveProfile := func(device *mdmtest.TestAppleMDMClient) {
// The host grabs the removal command from Fleet
cmd, err := device.Idle()
require.NoError(t, err)
assert.Equal(t, "RemoveProfile", cmd.Command.RequestType)
// Since profile is not on the device, it returns an error.
errChain := []mdm.ErrorChain{
{
ErrorCode: 89,
ErrorDomain: "FooErrorDomain",
LocalizedDescription: "The profile not found",
},
}
cmd, err = device.Err(cmd.CommandUUID, errChain)
require.NoError(t, err)
assert.Nil(t, cmd)
}
sendErrorOnRemoveProfile(mdmDevice)
// Make sure deleted profile no longer shows up
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 3)
// Add a profile again
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: globalProfilesPlusOne[0]},
{Name: "N2", Contents: globalProfilesPlusOne[1]},
}}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// The host grabs the profile from Fleet
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
assert.Equal(t, "InstallProfile", cmd.Command.RequestType)
// Verify that the Fleet variable was replaced with the IdP email
type Command struct {
Command struct {
Payload []byte
}
}
var p Command
err = plist.Unmarshal(cmd.Raw, &p)
require.NoError(t, err)
assert.NotContains(t, string(p.Command.Payload), "$FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP)
assert.Contains(t, string(p.Command.Payload), "idp@example.com")
// While the host is installing the profile, we delete it.
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// Host acknowledges installing the profile and grabs the remove command
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Equal(t, "RemoveProfile", cmd.Command.RequestType)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Nil(t, cmd)
// Add another device
host2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// Add IdP email to host
mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error {
_, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp2@example.com", host2.ID,
fleet.DeviceMappingMDMIdpAccounts)
return err
})
// Run the worker to process post-enrollment for host2 and install initial profiles
s.awaitRunAppleMDMWorkerSchedule()
installs, removes = checkNextPayloads(t, mdmDevice2, false)
assert.Len(t, installs, 3)
assert.Empty(t, removes)
// Simulate redis key expiration for host2
err = s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+host2.UUID)
require.NoError(t, err)
// Add a profile again
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: globalProfilesPlusOne[0]},
{Name: "N2", Contents: globalProfilesPlusOne[1]},
}}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// Delete a profile before it is sent to both devices
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// The host grabs the removal command from Fleet
sendErrorOnRemoveProfile(mdmDevice)
sendErrorOnRemoveProfile(mdmDevice2)
// Make sure deleted profile no longer shows up on either host
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 3)
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host2.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 3)
}
func (s *integrationMDMTestSuite) TestBatchResendMDMProfiles() {
t := s.T()
ctx := t.Context()
s.setSkipWorkerJobs(t)
// create a few hosts
host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host3, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// register a couple profiles for Apple and one for Windows
profN1 := mobileconfigForTest("N1", "I1")
profN2 := mobileconfigForTest("N2", "I2")
profN3 := syncMLForTest("./Foo/N3")
declN4 := declarationForTest("N4")
batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: profN1},
{Name: "N2", Contents: profN2},
{Name: "N3", Contents: profN3},
{Name: "N4", Contents: declN4},
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent)
// list the profiles to get the UUIDs
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
}
// get status for non-existing profile
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "ano-such-profile"), nil, http.StatusNotFound)
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "wno-such-profile"), nil, http.StatusNotFound)
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "dno-such-profile"), nil, http.StatusNotFound)
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "zno-such-profile"), nil, http.StatusNotFound)
// get status for existing profiles, all 0 counts
for _, uuid := range []string{profNameToPayload["N1"].ProfileUUID, profNameToPayload["N2"].ProfileUUID, profNameToPayload["N3"].ProfileUUID} {
var statusResp getMDMConfigProfileStatusResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", uuid), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{}, statusResp.MDMConfigProfileStatus)
}
// except for the declaration, which is immediately set as pending on the hosts
var statusResp getMDMConfigProfileStatusResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
// try to batch-resend a non-existing profile
batchReq := batchResendMDMProfileToHostsRequest{ProfileUUID: "zzzz"} // not a known prefix
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound)
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "azzzz"} // unknown Apple profile
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound)
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "wzzzz"} // unknown Windows profile
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound)
// batch-resend with an invalid filter
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryPending)
res := s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest)
msg := extractServerErrorText(res.Body)
require.Contains(t, msg, "Invalid profile_status filter value, only 'failed' is currently supported.")
// batch-resend with an Apple DDM
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N4"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
res = s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, "Can't resend declaration (DDM) profiles.")
// batch-resend an Apple and a Windows profile, does nothing as it is not delivered yet
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetWindowsHostProfileStatus(t, s.ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["N3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N1"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N2"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N3"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 1}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
// acknowledge the Apple profiles, failing I2 on both hosts, and fail the Windows one
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
forceSetWindowsHostProfileStatus(t, s.ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["N3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
// batch-resend N2 profile
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N2"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(),
fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N2", 2),
0,
)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryFailed},
},
})
// set I2/N2 as verifying
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
// batch-resend N3 profile
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.lastActivityOfTypeMatches(
fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(),
fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N3", 1),
0,
)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N1"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Verifying: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N2"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Verifying: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N3"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 1}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
// trigger profile schedule to get the fleet-controlled profiles
s.awaitTriggerProfileSchedule(t)
// list the profiles to get a fleet-controlled profile UUID
gotProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, host1.UUID)
require.NoError(t, err)
var fleetReservedProfile string
for _, prof := range gotProfs {
// find the fleetd config one
if prof.Identifier == mobileconfig.FleetdConfigPayloadIdentifier {
fleetReservedProfile = prof.ProfileUUID
}
}
require.NotEmpty(t, fleetReservedProfile)
// fleet-reserved profiles are not returned by the API, only custom profiles
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", fleetReservedProfile), getMDMConfigProfileStatusRequest{}, http.StatusNotFound)
}
func (s *integrationMDMTestSuite) TestDeleteMDMProfileCancelsInstalls() {
t := s.T()
s.setSkipWorkerJobs(t)
// create some Apple, Windows and declaration profiles
profiles := []fleet.MDMProfileBatchPayload{
{
Name: "A1",
Contents: mobileconfigForTest("A1", "A1"),
},
{
Name: "A2",
Contents: mobileconfigForTest("A2", "A2"),
},
{
Name: "D1",
Contents: declarationForTest("D1"),
},
{
Name: "D2",
Contents: declarationForTest("D2"),
},
{
Name: "W1",
Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "W1", Data: "W1"}}),
},
{
Name: "W2",
Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "W2", Data: "W2"}}),
},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent)
// list the profiles to get the UUIDs
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
t.Logf("profile %s: %s", prof.Name, prof.ProfileUUID)
}
// deleting without any affected host is fine
var deleteResp deleteMDMConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A1"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["D1"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["W1"].ProfileUUID), nil, http.StatusOK, &deleteResp)
// create some Apple and Windows hosts
host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host3, mdmDevice3 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
host4, mdmDevice4 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
for i, h := range []*fleet.Host{host1, host2, host3, host4} {
t.Logf("host %d: %s", i+1, h.UUID)
}
s.awaitTriggerProfileSchedule(t)
s.awaitRunAppleMDMWorkerSchedule()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host4: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// for the declaration, set host1 as NULL and host2 as verified
forceSetAppleHostDeclarationStatus(t, s.ds, host1.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeInstall, "")
forceSetAppleHostDeclarationStatus(t, s.ds, host2.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
// delete the declaration, will have removed it for host1 and set to remove pending for host2
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["D2"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// for the Windows profile, set host4 as failed
forceSetWindowsHostProfileStatus(t, s.ds, host4.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["W2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
// delete the Windows profile. Both hosts had non-NULL status (pending/failed)
// so both are marked for removal with a <Delete> command enqueued.
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["W2"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "W2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
},
host4: {
{Name: "W2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
},
})
// for the Apple profile, set host1 as NULL (pending not queued yet), and leave host2 as actually pending (queued)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A2"]), fleet.MDMOperationTypeInstall, "")
assertIsCommandActiveForHostAndProfile := func(hostUUID, profileUUID string, wantActive bool) {
var active bool
ctx := t.Context()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &active, `SELECT neq.active
FROM
nano_enrollment_queue neq
JOIN host_mdm_apple_profiles hmap
ON hmap.command_uuid = neq.command_uuid AND hmap.host_uuid = neq.id
WHERE
hmap.host_uuid = ? AND
hmap.profile_uuid = ?`, hostUUID, profileUUID)
})
if wantActive {
require.True(t, active)
} else {
require.False(t, active)
}
}
assertIsCommandActiveForHostAndProfile(host1.UUID, profNameToPayload["A2"].ProfileUUID, true)
assertIsCommandActiveForHostAndProfile(host2.UUID, profNameToPayload["A2"].ProfileUUID, true)
// delete the profile, will remove the row for host1 and set host2 to pending remove (and will deactivate the associated nano command)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A2"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
assertIsCommandActiveForHostAndProfile(host2.UUID, profNameToPayload["A2"].ProfileUUID, false)
// set the remove operations to verifying and reconcile profiles
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A2"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying)
forceSetAppleHostDeclarationStatus(t, s.ds, host2.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// Both Windows hosts still have the W2 remove+pending row from the
// earlier deletion -- no simulated device check-in has processed the
// <Delete> command yet.
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "W2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
},
host4: {
{Name: "W2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
},
})
// Simulate device check-ins to process the <Delete> commands. After
// the device responds with OK, the remove+verified rows are cleaned up.
for _, device := range []*mdmtest.TestWindowsMDMClient{mdmDevice3, mdmDevice4} {
cmds, err := device.StartManagementSession()
require.NoError(t, err)
msgID, err := device.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
status := syncml.CmdStatusOK
device.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &c.Cmd.CmdID.Value,
Cmd: ptr.String(c.Verb),
Data: &status,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
_, err = device.SendResponse()
require.NoError(t, err)
}
// After the devices confirmed the <Delete>, the remove rows should be
// cleaned up (verified removes are deleted from host_mdm_windows_profiles).
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {},
host4: {},
})
// add new profile A3 and re-add A2, behaves as if a new profile because it has a new uuid
oldA2Contents := profiles[1].Contents
profiles = []fleet.MDMProfileBatchPayload{
{
Name: "A2",
Contents: oldA2Contents,
},
{
Name: "A3",
Contents: mobileconfigForTest("A3", "A3"),
},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent)
// list the profiles to get the UUIDs
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload = make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
t.Logf("new profile %s: %s", prof.Name, prof.ProfileUUID)
}
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// set A3 as failed on host1, and removed on host2
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryFailed)
// delete the profile, will mark host1 as pending remove and will not touch host2 (not installed)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A3"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryFailed},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryFailed},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
}
// those helper functions to force-set a host profile status are copied from the Datastore
// tests, couldn't put them in the test package due to circular dependency with mysql, would
// be nice to find a way to avoid this copy eventually.
func forceSetAppleHostProfileStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMAppleConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
ctx := t.Context()
// empty status string means set to NULL
var actualStatus *fleet.MDMDeliveryStatus
if status != "" {
actualStatus = &status
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_profiles
(profile_identifier, host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid)
VALUES
(?, ?, ?, ?, ?, ?, UNHEX(MD5(?)), ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type)
`,
profile.Identifier, hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.Mobileconfig, profile.ProfileUUID)
return err
})
}
func forceSetWindowsHostProfileStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMWindowsConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
ctx := t.Context()
// empty status string means set to NULL
var actualStatus *fleet.MDMDeliveryStatus
if status != "" {
actualStatus = &status
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_windows_profiles
(host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid)
VALUES
(?, ?, ?, ?, ?, UNHEX(MD5(?)), ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type)
`,
hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.SyncML, profile.ProfileUUID)
return err
})
}
func forceSetAppleHostDeclarationStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMAppleDeclaration, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
ctx := t.Context()
// empty status string means set to NULL
var actualStatus *fleet.MDMDeliveryStatus
if status != "" {
actualStatus = &status
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_declarations
(declaration_identifier, host_uuid, status, operation_type, token, declaration_name, declaration_uuid)
VALUES
(?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type)
`,
profile.Identifier, hostUUID, actualStatus, operation, test.MakeTestBytes(), profile.Name, profile.DeclarationUUID)
return err
})
}
func (s *integrationMDMTestSuite) TestVerifyUserScopedProfiles() {
t := s.T()
ctx := t.Context()
s.setSkipWorkerJobs(t)
// create a macOS host, will enroll only with device
host, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// create some profiles, system- and user-scoped
payloadScopeSystem := fleet.PayloadScopeSystem
payloadScopeUser := fleet.PayloadScopeUser
profiles := []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: scopedMobileconfigForTest("A1", "A1", &payloadScopeSystem)},
{Name: "A2", Contents: scopedMobileconfigForTest("A2", "A2.user", &payloadScopeUser)},
{Name: "A3", Contents: scopedMobileconfigForTest("A3", "A3.user", &payloadScopeUser)},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent)
// ensure we are at least 1s after the profiles uploaded-at timestamp
time.Sleep(time.Second)
// list the profiles to get the UUIDs
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
t.Logf("profile %s: %s", prof.Name, prof.ProfileUUID)
}
type hostProfile struct {
ProfileUUID string `db:"profile_uuid"`
ProfileIdentifier string `db:"profile_identifier"`
ProfileName string `db:"profile_name"`
Status *string `db:"status"`
OperationType *string `db:"operation_type"`
Retries int `db:"retries"`
Scope string `db:"scope"`
}
assertHostProfiles := func(want []hostProfile) {
var got []hostProfile
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// for the purpose of this test, we ignore the Fleet-internal profiles
// (we only care about the custom profiles)
return sqlx.SelectContext(t.Context(), q, &got, `
SELECT profile_uuid, profile_identifier, profile_name, status, operation_type, retries, scope
FROM host_mdm_apple_profiles
WHERE host_uuid = ? AND profile_identifier NOT IN (?, ?)`,
host.UUID, mobileconfig.FleetdConfigPayloadIdentifier, mobileconfig.FleetCARootConfigPayloadIdentifier)
})
require.ElementsMatch(t, want, got)
}
forceProfileUploadeddAtTimestamp := func(ident string, ts time.Time) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE mdm_apple_configuration_profiles
SET uploaded_at = ? WHERE identifier = ?`, ts, ident)
return err
})
}
// cron job hasn't run yet, so no profile exist for the host
assertHostProfiles([]hostProfile{})
// ensure fleet profiles
s.awaitTriggerProfileSchedule(t)
s.awaitRunAppleMDMWorkerSchedule()
require.NoError(t, s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+host.UUID))
// user-scoped profiles show up as status nil (no user-enrollment yet)
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// verify the profiles, only the system one is reported as installed
host.DetailUpdatedAt = time.Now().UTC()
err := s.ds.UpdateHost(ctx, host)
require.NoError(t, err)
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
// user-scoped profiles were left untouched
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// create the user-enrollment
err = device.UserEnroll()
require.NoError(t, err)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// user-scoped profiles have been added as pending (not nil)
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// verify the profiles, A3 is missing but still within the grace period
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
profNameToPayload["A2"].Identifier: {
DisplayName: profNameToPayload["A2"].Name,
Identifier: profNameToPayload["A2"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
// A2 is now verified, A3 is still pending
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// rewind the uploaded_at timestamp of A3 so it is not in the grace period
forceProfileUploadeddAtTimestamp(profNameToPayload["A3"].Identifier, time.Now().Add(-24*time.Hour))
// report as still missing
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
profNameToPayload["A2"].Identifier: {
DisplayName: profNameToPayload["A2"].Name,
Identifier: profNameToPayload["A2"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
// A3 is now missing and retries
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 1,
Scope: string(fleet.PayloadScopeUser),
},
})
s.awaitTriggerProfileSchedule(t)
// force-set it to Verifying and set retries to max-1 so that by being missing again it goes to failed
// (it doesn't go to failed if it is pending)
forceSetAppleHostProfileStatus(t, s.ds, host.UUID,
test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET retries = ? WHERE host_uuid = ? AND profile_identifier = ?`,
servermdm.MaxAppleProfileRetries, host.UUID, profNameToPayload["A3"].Identifier)
return err
})
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
profNameToPayload["A2"].Identifier: {
DisplayName: profNameToPayload["A2"].Name,
Identifier: profNameToPayload["A2"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: ptr.String(string(fleet.MDMDeliveryFailed)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: servermdm.MaxAppleProfileRetries,
Scope: string(fleet.PayloadScopeUser),
},
})
}
func (s *integrationMDMTestSuite) TestMDMAppleProfileScopeChanges() {
t := s.T()
ctx := context.Background()
// add a couple global profiles
payloadScopeSystem := fleet.PayloadScopeSystem
payloadScopeUser := fleet.PayloadScopeUser
globalProfiles := [][]byte{
mobileconfigForTest("G1", "G1"),
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser),
scopedMobileconfigForTest("G4", "G4.user-but-actually-system", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// Create a profile with a scope that is System in the DB but User in the XML. This mimics
// our upgrade behavior from versions prior to 4.71 to 4.71+ when we added support for User
// scoped profiles
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE mdm_apple_configuration_profiles SET scope=? WHERE identifier=?;`
_, err := q.ExecContext(context.Background(), stmt, fleet.PayloadScopeSystem, "G4.user-but-actually-system")
return err
})
// create a team with a couple profiles
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_1"})
require.NoError(t, err)
tm1Profiles := [][]byte{
mobileconfigForTest("T1.1", "T1.1"),
scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem),
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
// create a second team with different profiles
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_2"})
require.NoError(t, err)
tm2Profiles := [][]byte{
mobileconfigForTest("T2.1", "T2.1"),
scopedMobileconfigForTest("T2.2", "T2.2.user", &payloadScopeSystem),
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm2.ID))
// Do a no-op update of each team's profiles, verify no errors are returned
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm2.ID))
// Test a modification of an existing global profile with an implicit scope change
newGlobalProfiles := [][]byte{
globalProfiles[0],
globalProfiles[1],
globalProfiles[2],
scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser),
}
response := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newGlobalProfiles}, http.StatusBadRequest)
errMsg := extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't edit configuration profile (G4.user-but-actually-system) because it was previously delivered to some hosts on the device channel")
// Test a conflict of a profile on a team with an existing global profile and an implicit scope change
// Should error because "G4.user-but-actually-system" conflicts with global
// "G4.user-but-actually-system" profile scope
newTm1Profiles := [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser),
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't add configuration profile. This profile has the same \"PayloadIdentifier\" but a different \"PayloadScope\" as another profile in a separate team.")
// Test a conflict of a profile on a team with an existing global profile
// Should error because "G2" conflicts with global "G2" profile
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("G2", "G2", &payloadScopeUser),
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't add configuration profile. This profile has the same \"PayloadIdentifier\" but a different \"PayloadScope\" as another profile in a separate team.")
// Test a conflict of a profile on a team versus one with the same identifier but different
// scope on a different team.
// Should error because "T2.3.user" system-scoped profile conflicts with team2 "T2.3.user" user-scoped profile
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeSystem), // T2.3.user changed to system scope
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't add configuration profile. This profile has the same \"PayloadIdentifier\" but a different \"PayloadScope\" as another profile in a separate team.")
// Profile edit of existing profile on team1 with a new scope
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeSystem), // T1.3.user changed to system scope
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't edit configuration profile (T1.3.user) because the profile's \"PayloadScope\" has changed")
// Should be able to add these profiles to team1 with the proper scopes
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), // T2.3.user changed to system scope
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
}
func (s *integrationMDMTestSuite) TestWindowsProfilesWithFleetVariables() {
t := s.T()
ctx := t.Context()
// Create a team for team-scoped tests
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test_windows_fleet_vars"})
require.NoError(t, err)
testCases := []struct {
name string
profiles []fleet.MDMProfileBatchPayload
teamID *uint
wantStatus int
wantErrContains string
}{
{
name: "HOST_UUID variable accepted for team",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestHostUUID",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "HOST_UUID variable with braces accepted",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestHostUUIDBraces",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "${FLEET_VAR_HOST_UUID}"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "unsupported variable rejected",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestUnsupported",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Serial", Data: "$FLEET_VAR_HOST_FAKE"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusUnprocessableEntity,
wantErrContains: "Fleet variable $FLEET_VAR_HOST_FAKE is not supported in Windows profiles",
},
{
name: "mixed supported and unsupported variables rejected",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestMixed",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Email", Data: "$FLEET_VAR_BOGUS"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusUnprocessableEntity,
wantErrContains: "Fleet variable $FLEET_VAR_BOGUS is not supported in Windows profiles",
},
{
name: "HOST_UUID variable accepted globally",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "GlobalHostUUID",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
}),
},
},
teamID: nil, // global profile
wantStatus: http.StatusNoContent,
},
{
name: "batch with regular and variable profiles accepted",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "RegularProfile",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"},
}),
},
{
Name: "ProfileWithVar",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "multiple HOST_UUID variables in single profile accepted",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "MultipleHostUUID",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/BackupID", Data: "${FLEET_VAR_HOST_UUID}"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "unknown Fleet variable rejected",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "UnknownVar",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/SomeValue", Data: "${FLEET_VAR_UNKNOWN_VAR}"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusUnprocessableEntity,
wantErrContains: "Fleet variable $FLEET_VAR_UNKNOWN_VAR is not supported in Windows profiles",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var resp *http.Response
// Execute request with or without team_id
if tc.teamID != nil {
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: tc.profiles},
tc.wantStatus,
"team_id", fmt.Sprint(*tc.teamID))
} else {
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: tc.profiles},
tc.wantStatus)
}
// Check error message if expected
if tc.wantErrContains != "" {
errMsg := extractServerErrorText(resp.Body)
require.Contains(t, errMsg, tc.wantErrContains)
}
})
}
}
func (s *integrationMDMTestSuite) TestWindowsProfilesFleetVariableSubstitution() {
t := s.T()
ctx := context.Background()
// Create a team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team"})
require.NoError(t, err)
// Create and enroll three Windows hosts (two global, one in team)
hostGlobal1, device1 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
hostGlobal2, device2 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
hostTeam, deviceTeam := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// Add the team host to the team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hostTeam.ID}))
require.NoError(t, err)
// Create profiles with HOST_UUID variable for global and team
globalProfile := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "Device ID: $FLEET_VAR_HOST_UUID"},
})
teamProfile := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/TeamDevice/ID", Data: "Team Device: ${FLEET_VAR_HOST_UUID}"},
})
// Upload global profile
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "GlobalProfileWithVar", Contents: globalProfile},
}},
http.StatusNoContent)
// Upload team profile
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "TeamProfileWithVar", Contents: teamProfile},
}},
http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// Helper to verify profile contains substituted UUID
verifyProfileSubstitution := func(device *mdmtest.TestWindowsMDMClient, expectedUUID string, expectedData string) {
s.awaitTriggerProfileSchedule(t)
cmds, err := device.StartManagementSession()
require.NoError(t, err)
// Find the Atomic command containing the profile
var foundProfile bool
msgID, err := device.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
// Send status response for each command
device.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusOK),
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
if cmd.Verb == "Atomic" {
// Check if the command contains our expected data with UUID substituted
for _, replaceCmd := range cmd.Cmd.ReplaceCommands {
for _, item := range replaceCmd.Items {
if item.Data != nil && item.Data.Content != "" {
if strings.Contains(item.Data.Content, expectedData) {
// Verify the UUID was substituted correctly
require.Contains(t, item.Data.Content, expectedUUID)
require.NotContains(t, item.Data.Content, "$FLEET_VAR_HOST_UUID")
require.NotContains(t, item.Data.Content, "${FLEET_VAR_HOST_UUID}")
foundProfile = true
}
}
}
}
}
}
require.True(t, foundProfile, "Expected profile with UUID substitution not found")
// Send the response to complete the session
cmds, err = device.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
// Verify global hosts receive profile with their UUID substituted
verifyProfileSubstitution(device1, hostGlobal1.UUID, "Device ID: "+hostGlobal1.UUID)
verifyProfileSubstitution(device2, hostGlobal2.UUID, "Device ID: "+hostGlobal2.UUID)
// Verify team host receives team profile with UUID substituted
verifyProfileSubstitution(deviceTeam, hostTeam.UUID, "Team Device: "+hostTeam.UUID)
// Check that profile statuses are updated correctly in the database
checkHostProfileStatus := func(hostUUID string, profileName string, expectedStatus fleet.MDMDeliveryStatus) {
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, hostUUID)
require.NoError(t, err)
// Find the specific profile by name
var foundProfile *fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName {
foundProfile = &p
break
}
}
require.NotNil(t, foundProfile, "Profile %s not found for host %s", profileName, hostUUID)
require.NotNil(t, foundProfile.Status, "Profile %s status is nil for host %s", profileName, hostUUID)
assert.Equal(t, expectedStatus, *foundProfile.Status, "Profile %s has unexpected status for host %s", profileName, hostUUID)
}
checkHostProfileStatus(hostGlobal1.UUID, "GlobalProfileWithVar", fleet.MDMDeliveryVerified)
checkHostProfileStatus(hostGlobal2.UUID, "GlobalProfileWithVar", fleet.MDMDeliveryVerified)
checkHostProfileStatus(hostTeam.UUID, "TeamProfileWithVar", fleet.MDMDeliveryVerified)
// Now let's check profile verification
// Also create and test a host without Fleet variables to ensure normal verification still works
hostNoVars, deviceNoVars := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// Create a profile without variables
profileNoVars := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/Static/Value", Data: "Static Value: NoSubstitution"},
})
// Upload profile without variables
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "ProfileNoVars", Contents: profileNoVars},
}},
http.StatusNoContent)
// Let the host get the profile
s.awaitTriggerProfileSchedule(t)
cmds, err := deviceNoVars.StartManagementSession()
require.NoError(t, err)
msgID, err := deviceNoVars.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
deviceNoVars.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusOK),
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
cmds, err = deviceNoVars.SendResponse()
require.NoError(t, err)
require.Len(t, cmds, 1) // ack
checkHostProfileStatus(hostNoVars.UUID, "ProfileNoVars", fleet.MDMDeliveryVerified)
// To ensure any verification failures result in retry (pending) status instead of staying as verifying,
// we need to be outside the grace period. The grace period check is:
// hostDetailUpdatedAt.Before(profileEarliestInstallDate.Add(1 hour))
//
// We need to make sure the host checked in recently (detail_updated_at = now)
// but the profiles are old (created more than 1 hour ago)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// Set profile timestamps to 2 hours ago
// IMPORTANT: uploaded_at is used as EarliestInstallDate in verification logic
_, err := q.ExecContext(ctx,
`UPDATE mdm_windows_configuration_profiles
SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR),
uploaded_at = DATE_SUB(NOW(), INTERVAL 2 HOUR)
WHERE name IN (?, ?, ?)`,
"GlobalProfileWithVar", "TeamProfileWithVar", "ProfileNoVars")
if err != nil {
return err
}
// Also update the host profile associations to have old timestamps (only created_at)
_, err = q.ExecContext(ctx,
`UPDATE host_mdm_windows_profiles
SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR)
WHERE host_uuid IN (?, ?, ?, ?)`,
hostGlobal1.UUID, hostGlobal2.UUID, hostTeam.UUID, hostNoVars.UUID)
if err != nil {
return err
}
// Set host detail_updated_at to now (recent check-in)
_, err = q.ExecContext(ctx, `UPDATE hosts SET detail_updated_at = NOW() WHERE id IN (?, ?, ?, ?)`,
hostGlobal1.ID, hostGlobal2.ID, hostTeam.ID, hostNoVars.ID)
return err
})
// Normal profile should be verified successfully
checkHostProfileStatus(hostNoVars.UUID, "ProfileNoVars", fleet.MDMDeliveryVerified)
// Team host has TeamProfileWithVar which now correctly verifies with Fleet variables
// The fix has been implemented and the profile should be verified successfully
checkHostProfileStatus(hostTeam.UUID, "TeamProfileWithVar", fleet.MDMDeliveryVerified)
// Hit the host details API and check the status in the mdm.profiles section
// Verify team host
var hostRespTeam getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", hostTeam.ID), getHostRequest{}, http.StatusOK, &hostRespTeam)
require.NotNil(t, hostRespTeam.Host.MDM.Profiles)
require.Len(t, *hostRespTeam.Host.MDM.Profiles, 1)
require.Equal(t, "TeamProfileWithVar", (*hostRespTeam.Host.MDM.Profiles)[0].Name)
require.EqualValues(t, fleet.MDMDeliveryVerified, *(*hostRespTeam.Host.MDM.Profiles)[0].Status,
"Profile should be verified in host details API for team host")
// Verify no-vars host
var hostRespNoVars getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", hostNoVars.ID), getHostRequest{}, http.StatusOK, &hostRespNoVars)
require.NotNil(t, hostRespNoVars.Host.MDM.Profiles)
require.Len(t, *hostRespNoVars.Host.MDM.Profiles, 1)
require.Equal(t, "ProfileNoVars", (*hostRespNoVars.Host.MDM.Profiles)[0].Name)
require.EqualValues(t, fleet.MDMDeliveryVerified, *(*hostRespNoVars.Host.MDM.Profiles)[0].Status,
"Profile should be verified in host details API for no-vars host")
}
//go:embed testdata/profiles/windows-device-scep.xml
var windowsDeviceSCEPProfileBytes []byte
func (s *integrationMDMTestSuite) TestWindowsDeviceSCEPProfile() {
testWindowsSCEPProfile(s, windowsDeviceSCEPProfileBytes)
}
//go:embed testdata/profiles/windows-user-scep.xml
var windowsUserSCEPProfileBytes []byte
func (s *integrationMDMTestSuite) TestWindowsUserSCEPProfile() {
testWindowsSCEPProfile(s, windowsUserSCEPProfileBytes)
}
func testWindowsSCEPProfile(s *integrationMDMTestSuite, windowsScepProfile []byte) {
t := s.T()
ctx := context.Background()
scepServer := scep_server.StartTestSCEPServer(t)
scepServerURL := scepServer.URL + "/scep"
// Create windows host and enroll in MDM
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
verifyCommands := func(wantProfiles int, status string) {
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
// profile installs + 2 protocol commands acks
require.Len(t, cmds, wantProfiles+2)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
atomicCmds := 0
for _, c := range cmds {
if c.Verb == "Atomic" {
atomicCmds++
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(status),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.Equal(t, wantProfiles, atomicCmds)
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
// Upload SCEP profile with missing CA
resp := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "WindowsSCEPProfile", Contents: windowsScepProfile},
}},
http.StatusBadRequest)
errMsg := extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "Fleet variable $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_INTEGRATION does not exist.")
// Create Custom SCEP CA
ca := &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String("INTEGRATION"),
Challenge: ptr.String("integration-test"),
URL: ptr.String(scepServerURL),
}
_, err := s.ds.NewCertificateAuthority(ctx, ca)
require.NoError(t, err)
// Fail on missing OU
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "WindowsSCEPProfile", Contents: bytes.ReplaceAll(windowsScepProfile, []byte(fleet.FleetVarSCEPRenewalID.WithPrefix()), []byte("BOGUS"))},
}},
http.StatusBadRequest)
errMsg = extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "WindowsSCEPProfile", Contents: windowsScepProfile},
}},
http.StatusNoContent)
// Verify host receives the profile
s.awaitTriggerProfileSchedule(t)
// Check that profile status is Pending
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var foundProfile bool
for _, p := range profiles {
if p.Name == "WindowsSCEPProfile" {
foundProfile = true
require.NotNil(t, p.Status)
assert.EqualValues(t, fleet.MDMDeliveryPending, *p.Status)
}
}
require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
verifyCommands(1, syncml.CmdStatusOK)
// Verify profile status is Verified due to successful response
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
foundProfile = false
profileUUID := ""
for _, p := range profiles {
if p.Name == "WindowsSCEPProfile" {
foundProfile = true
profileUUID = p.ProfileUUID
require.NotNil(t, p.Status)
assert.EqualValues(t, fleet.MDMDeliveryVerified, *p.Status)
}
}
require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
// Attempt simple SCEP call with GetCACaps operation to verify SCEP server is reachable
identifier := host.UUID + "," + profileUUID + "," + "INTEGRATION"
scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+"/pkiclient.exe", nil, http.StatusOK, nil, "operation", "GetCACaps")
body, err := io.ReadAll(scepRes.Body)
require.NoError(t, err)
assert.Equal(t, scepserver.DefaultCACaps, string(body))
}
// This test verifies that there is no longer a race condition in apple profile resending
func (s *integrationMDMTestSuite) TestAppleProfileResendRaceCondition() {
t := s.T()
ctx := context.Background()
// Create a host and enroll it in MDM
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
// Delete the key to let the reconciler pick up the profiles
err := s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+host.UUID)
require.NoError(t, err)
scimUserID, err := s.ds.CreateScimUser(ctx, &fleet.ScimUser{UserName: "user@example.com"})
require.NoError(t, err)
// Assign scim user to host
hostIdStr := fmt.Sprint(host.ID)
s.Do("PUT", "/api/latest/fleet/hosts/"+hostIdStr+"/device_mapping", putHostDeviceMappingRequest{
Email: "user@example.com",
Source: "idp",
}, http.StatusOK)
// Create a profile that uses IDP variables
profileWithIDPVar := mobileconfigForTestWithContent("TestProfile", "com.test.profile", "com.test.profile.content", "com.test.profile", "Test IDP Variable Profile")
// Replace the profile content to include an IDP variable
profileContent := string(profileWithIDPVar)
profileContent = strings.Replace(profileContent, "<key>ShowRecoveryKey</key>", "<key>TestVariable</key>", 1)
profileContent = strings.Replace(profileContent, "<false/>", "<string>$FLEET_VAR_HOST_END_USER_IDP_USERNAME</string>", 1)
profileWithIDPVar = []byte(profileContent)
// Upload the profile using the new endpoint that supports variables
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "TestProfile", Contents: profileWithIDPVar},
}}, http.StatusNoContent) // Setup SCIM user data for the host
// No profiles until reconciler
hostProfiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
require.Empty(t, hostProfiles, "Host should not have any profiles before sync")
// Trigger initial profile sync - profile should be set to pending/installing
s.awaitTriggerProfileSchedule(t)
// Check that install command was sent, but do not acknowledge it yet
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
seenProfile := false
profileCmdID := ""
for cmd != nil {
if cmd.Command.RequestType != "InstallProfile" {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
seenProfile = true
profileCmdID = cmd.CommandUUID
break
}
// Acknowledge other commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.True(t, seenProfile, "Expected install command for TestProfile not found")
// Verify profile is in pending status
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
var testProfile *fleet.HostMDMAppleProfile
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status)
// Now simulate the race condition:
// we trigger a resend before the acknowledgement comes back
// 1. Trigger an IDP variable change by updating SCIM user
err = s.ds.ReplaceScimUser(ctx, &fleet.ScimUser{ID: scimUserID, UserName: "newuser@example.com"})
require.NoError(t, err)
// 2. At this point, the profile should be marked for resend (status = NULL)
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
fmt.Printf("%v\n", p.Status)
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status) // Should be NULL (pending for the user)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status *fleet.MDMDeliveryStatus
err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier)
require.Nil(t, status)
return err
})
// Acknowledge the original install command now, simulating the device response
cmd, err = mdmDevice.Acknowledge(profileCmdID)
require.NoError(t, err)
// Now check if we see any new TestProfile cmds (we need to ack them here, since we might have skipped some above.)
seenProfile = false
for cmd != nil {
if cmd.Command.RequestType != "InstallProfile" {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
seenProfile = true
// Acknowledge the resend command
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
} else {
// Acknowledge other commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
require.Nil(t, cmd, "No further commands should be pending after acknowledging install")
require.False(t, seenProfile, "No resend install command for TestProfile should be sent due to race condition")
// Verify the profile is still in pending status (null in the DB)
// aka. no race condition.
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status *fleet.MDMDeliveryStatus
err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier)
require.Nil(t, status)
return err
})
// run reconciler to resend any pending profiles
s.awaitTriggerProfileSchedule(t)
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
// Now we should see the resend command
seenProfile = false
for cmd != nil {
if cmd.Command.RequestType != "InstallProfile" {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
seenProfile = true
// Acknowledge the resend command
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
} else {
// Acknowledge other commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
require.True(t, seenProfile, "Resend install command for TestProfile should be sent after reconciler runs")
// And we should now also see the profile being marked as verifying
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryVerifying, *testProfile.Status)
}
func (s *integrationMDMTestSuite) TestWindowsProfileRetry() {
t := s.T()
ctx := t.Context()
// Create a host and enroll it in MDM
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
t.Run("Command gets retried with Replace after 418", func(t *testing.T) {
profilePayload := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"},
})
profilePayloadNonAtomic := syncml.ForTestWithDataNonAtomic([]syncml.TestCommand{
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AtomicLocation", Data: "1"},
})
profileName := "RetryProfile"
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: profileName, Contents: profilePayload},
{Name: profileName + "NonAtomic", Contents: profilePayloadNonAtomic},
}},
http.StatusNoContent)
expectRetry := func(profileName string, expectedRetries int) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var retryCount int
err := sqlx.GetContext(t.Context(), q, &retryCount,
`SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
host.UUID, profileName)
require.NoError(t, err)
require.Equal(t, expectedRetries, retryCount, "Unexpected retry count for profile %s", profileName)
return nil
})
}
// Trigger profile schedule
s.awaitTriggerProfileSchedule(t)
// Get initial host profile
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var initialProfile fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName {
initialProfile = p
break
}
}
require.NotNil(t, initialProfile)
require.Equal(t, fleet.MDMDeliveryPending, *initialProfile.Status)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
var syncCmd fleet.SyncMLCmd
if cmd.Verb == "Atomic" {
syncCmd = fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
} else {
syncCmd = fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAlreadyExists),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
}
mdmDevice.AppendResponse(syncCmd)
for _, addCmd := range cmd.Cmd.AddCommands {
for range addCmd.Items {
itemCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &addCmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
// 418 triggers Replace resend logic
Data: ptr.String(syncml.CmdStatusAlreadyExists),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(itemCmd)
}
}
}
cmds, err = mdmDevice.SendResponse() // we have atomic replace and normal replace (resend after 418 attempt in this cmd list here)
require.NoError(t, err)
require.Len(t, cmds, 3) // status + atomic replace + replace
// After initial 418 resend: pending, empty detail, retries = 0.
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var updatedProfile fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName {
updatedProfile = p
break
}
}
require.NotNil(t, updatedProfile)
require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status)
require.Empty(t, updatedProfile.Detail)
expectRetry(profileName, 0)
// Second session: fail Atomic to trigger normal retry (status NULL, retries++ -> 1).
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
// Verify raw DB status is NULL and retries = 1.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status sql.NullString
var retries int
err := sqlx.GetContext(t.Context(), q, &status,
`SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
host.UUID, profileName)
require.NoError(t, err)
require.False(t, status.Valid, "status should be NULL")
err = sqlx.GetContext(t.Context(), q, &retries,
`SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
host.UUID, profileName)
require.NoError(t, err)
require.Equal(t, 1, retries)
return nil
})
// Third session: Add 418 again to requeue Replace; retries decremented back to 0.
s.awaitTriggerProfileSchedule(t)
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, addCmd := range cmd.Cmd.AddCommands {
for range addCmd.Items {
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &addCmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
Data: ptr.String(syncml.CmdStatusAlreadyExists), // 418
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
}
}
newCmds, err := mdmDevice.SendResponse()
require.NoError(t, err)
require.Len(t, newCmds, 2) // status + atomic replace
// Pending and retries back to 0.
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range profiles {
if p.Name == profileName {
updatedProfile = p
break
}
}
require.NotNil(t, updatedProfile)
require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status)
require.Empty(t, updatedProfile.Detail)
expectRetry(profileName, 1)
// Fourth session: Replace succeeds (Atomic OK + item 200) → verifying.
s.awaitTriggerProfileSchedule(t)
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusOK),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, repCmd := range cmd.Cmd.ReplaceCommands {
for range repCmd.Items {
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
Data: ptr.String(syncml.CmdStatusOK), // 200
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
}
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range profiles {
if p.Name == profileName {
updatedProfile = p
break
}
}
require.NotNil(t, updatedProfile)
require.Equal(t, fleet.MDMDeliveryVerified, *updatedProfile.Status)
require.Empty(t, updatedProfile.Detail)
expectRetry(profileName, 1)
})
t.Run("No resend on non-retryable error", func(t *testing.T) {
profilePayload2 := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowCamera", Data: "1"},
})
profileName2 := "NonRetryProfile"
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: profileName2, Contents: profilePayload2},
}},
http.StatusNoContent)
// Trigger profile schedule
s.awaitTriggerProfileSchedule(t)
// Get initial host profile
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var initialProfile2 fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName2 {
initialProfile2 = p
break
}
}
require.NotNil(t, initialProfile2)
require.Equal(t, fleet.MDMDeliveryPending, *initialProfile2.Status)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed), // Generic failure, should not retry
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, addCmd := range cmd.Cmd.AddCommands {
for range addCmd.Items {
itemCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &addCmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
// 500 generic failure
Data: ptr.String(syncml.CmdStatusBadRequest),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(itemCmd)
}
}
}
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
require.Len(t, cmds, 1) // only ack returned
})
t.Run("Other hosts can not get all commands", func(t *testing.T) {
// Let's insert a command for the original host, with some random raw_command data
commandUUID := uuid.NewString()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
commandData := `<Add><!-- CmdID generated by Fleet --><CmdID>` + commandUUID + `</CmdID><Item><Target><LocURI>./BogusLocURI</LocURI></Target><Meta><Type xmlns="syncml:metinf">text/plain</Type><Format xmlns="syncml:metinf">bool</Format></Meta><Data>true</Data></Item></Add>`
_, err := q.ExecContext(ctx, `INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri) VALUES (?, ?, '')`, commandUUID, commandData)
require.NoError(t, err)
return nil
})
// Create another host and enroll in MDM
_, mdmDevice2 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// Start connection, and then reply with a missing CmdRef
cmds, err := mdmDevice2.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice2.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
Cmd: ptr.String("Add"),
CmdRef: ptr.String(""),
Data: ptr.String(syncml.CmdStatusAlreadyExists),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice2.AppendResponse(syncCmd)
cmds, err = mdmDevice2.SendResponse()
require.NoError(t, err)
require.Len(t, cmds, 1) // Only 1 status message
for _, cmd := range cmds {
if cmd.Verb != "Status" {
t.Errorf("Expected only Status command, got %s", cmd.Verb)
}
}
})
}
func (s *integrationMDMTestSuite) TestHostMDMAndroidProfilesStatus() {
t := s.T()
ctx := context.Background()
s.setSkipWorkerJobs(t)
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam", Secrets: []*fleet.EnrollSecret{{Secret: uuid.NewString()}}})
require.NoError(t, err)
enterpriseID := s.enableAndroidMDM(t)
s.runWorkerUntilDoneWithChecks(true)
host1, deviceInfo1, pubSubToken := s.createAndEnrollAndroidDevice(t, "host-1", &testTeam.ID, false)
s.createAndEnrollAndroidDevice(t, "host-2", &testTeam.ID, false)
var hosts listHostsResponse
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts)
assert.Len(t, hosts.Hosts, 2)
bytes := []byte(`{
"removeUserDisabled": false
}`)
fields := make(map[string][]string)
fields["team_id"] = []string{fmt.Sprintf("%d", testTeam.ID)}
body, headers := generateNewProfileMultipartRequest(t, "remove-user-disabled.json", bytes, s.token, fields)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
require.NotNil(t, res)
// profiles should be added with NULL status even before the cron job (s.awaitTriggerAndroidProfileSchedule(t))
var profiles []fleet.HostMDMAndroidProfile
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
err := sqlx.SelectContext(ctx, q, &profiles, "SELECT host_uuid, status, operation_type FROM host_mdm_android_profiles")
require.NoError(t, err)
return nil
})
require.Len(t, profiles, 2)
require.Nil(t, profiles[0].Status)
require.Nil(t, profiles[1].Status)
overrideProfile1 := []byte(`{
"maximumTimeToLock": "1"
}`)
overrideProfile2 := []byte(`{
"maximumTimeToLock": "2"
}`)
body, headers = generateNewProfileMultipartRequest(t, "maximum-time-to-lock-1.json", overrideProfile1, s.token, fields)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
require.NotNil(t, res)
body, headers = generateNewProfileMultipartRequest(t, "maximum-time-to-lock-2.json", overrideProfile2, s.token, fields)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
require.NotNil(t, res)
s.awaitTriggerAndroidProfileSchedule(t)
getHostProfiles := func(hostID uint, wantStatus []string) {
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostID), nil, http.StatusOK, &hostResp)
require.NotNil(t, hostResp.Host.MDM.Profiles)
require.Len(t, *hostResp.Host.MDM.Profiles, len(wantStatus))
actualProfileStatuses := make([]string, 0, len(wantStatus))
for _, p := range *hostResp.Host.MDM.Profiles {
if p.Status != nil {
actualProfileStatuses = append(actualProfileStatuses, *p.Status)
}
}
require.ElementsMatch(t, wantStatus, actualProfileStatuses)
}
getHostProfiles(host1.ID, []string{string(fleet.MDMDeliveryPending), string(fleet.MDMDeliveryPending), string(fleet.MDMDeliveryFailed)})
// send a pub-sub status report
policyName := fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host1.UUID)
reportMsg := statusReportMessageWithEnterpriseSpecificID(
t,
androidmanagement.Device{
Name: deviceInfo1.Name,
EnrollmentTokenData: deviceInfo1.EnrollmentTokenData,
AppliedPolicyName: policyName,
AppliedPolicyVersion: 2,
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
},
host1.UUID,
)
req := android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
// Profiles that failed because they were overriden should stay failed
getHostProfiles(host1.ID, []string{string(fleet.MDMDeliveryVerified), string(fleet.MDMDeliveryVerified), string(fleet.MDMDeliveryFailed)})
}
// TestSpecTeamsOSUpdatesDeployToHosts verifies that POST /api/latest/fleet/spec/teams
// deploys OS updates declarations/profiles to enrolled Windows, iOS, iPadOS, and macOS
// hosts when OS update settings are initially set or when deadline/minimum version changes.
func (s *integrationMDMTestSuite) TestSpecTeamsOSUpdatesDeployToHosts() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
// Create a team via the API so agent ops are initialized.
teamName := t.Name() + "team1"
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", &fleet.Team{Name: teamName}, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
teamID := createTeamResp.Team.ID
// Enroll one host per platform.
macOSHost, macOSDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
iOSHost, iOSDevice := s.createAppleMobileHostThenEnrollMDM("ios")
iPadOSHost, iPadOSDevice := s.createAppleMobileHostThenEnrollMDM("ipados")
// Add all hosts to the team.
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &teamID, HostIDs: []uint{macOSHost.ID, windowsHost.ID, iOSHost.ID, iPadOSHost.ID}},
http.StatusOK)
// OS update declarations are scoped to platform-specific built-in labels:
// - macOS: "macOS 14+ (Sonoma+)" (dynamic label)
// - iOS: "iOS" (manual label)
// - iPadOS: "iPadOS" (manual label)
// Add each host to the appropriate label so the declarations/profiles are queued.
lblIDs, err := s.ds.LabelIDsByName(ctx,
[]string{fleet.BuiltinLabelMacOS14Plus, fleet.BuiltinLabelIOS, fleet.BuiltinLabelIPadOS},
fleet.TeamFilter{})
require.NoError(t, err)
// macOS 14+ is a dynamic label; use RecordLabelQueryExecutions to simulate osquery results.
require.Contains(t, lblIDs, fleet.BuiltinLabelMacOS14Plus)
require.NoError(t, s.ds.RecordLabelQueryExecutions(ctx, macOSHost,
map[uint]*bool{lblIDs[fleet.BuiltinLabelMacOS14Plus]: ptr.Bool(true)}, time.Now(), false))
// iOS and iPadOS are manual labels; insert label membership directly.
require.Contains(t, lblIDs, fleet.BuiltinLabelIOS)
require.Contains(t, lblIDs, fleet.BuiltinLabelIPadOS)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
`INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?), (?, ?)`,
iOSHost.ID, lblIDs[fleet.BuiltinLabelIOS],
iPadOSHost.ID, lblIDs[fleet.BuiltinLabelIPadOS])
return err
})
// assertAppleDeclarationQueued verifies that the declaration row for a host
// has been created with NULL status, meaning it has been queued for sync by
// BulkSetPendingMDMHostProfiles but not yet processed by ReconcileAppleDeclarations.
assertAppleDeclarationQueued := func(hostUUID, identifier string) {
t.Helper()
var count int
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &count,
`SELECT COUNT(*) FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_identifier = ?`,
hostUUID, identifier)
})
require.Equal(t, 1, count, "expected declaration row to exist for host %s identifier %s", hostUUID, identifier)
}
// assertAppleDeclarationGone verifies that the declaration row for a host
// has been removed from host_mdm_apple_declarations (i.e. the removal was
// processed by MDMAppleStoreDDMStatusReport).
assertAppleDeclarationGone := func(hostUUID, identifier string) {
t.Helper()
var count int
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &count,
`SELECT COUNT(*) FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_identifier = ?`,
hostUUID, identifier)
})
require.Equal(t, 0, count, "expected declaration row to be gone for host %s identifier %s", hostUUID, identifier)
}
// assertAppleDeclarationVerifying verifies that the declaration for a host
// is in the verifying state (device has acknowledged the DDM sync command).
assertAppleDeclarationVerifying := func(hostUUID, identifier string) {
t.Helper()
var gotStatus *fleet.MDMDeliveryStatus
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &gotStatus,
`SELECT status FROM host_mdm_apple_declarations WHERE host_uuid = ? AND declaration_identifier = ?`,
hostUUID, identifier)
})
require.NotNil(t, gotStatus)
require.Equal(t, fleet.MDMDeliveryVerifying, *gotStatus)
}
// checkWindowsProfileQueued verifies that the Windows OS updates profile
// has been queued for the host (inserted into host_mdm_windows_profiles).
checkWindowsProfileQueued := func(profUUID string) {
t.Helper()
var count int
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &count,
`SELECT COUNT(*) FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_uuid = ?`,
windowsHost.UUID, profUUID)
})
require.Equal(t, 1, count)
}
// ackAllCmds is used to ack commands sent during enrollment (like fleetd configuration).
ackAllCmds := func(device *mdmtest.TestAppleMDMClient) {
t.Helper()
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
// checkDDMSync verifies that a DDM (DeclarativeManagement) sync command
// is pending for the Apple device and acknowledges it.
checkDDMSync := func(device *mdmtest.TestAppleMDMClient) {
t.Helper()
cmd, err := device.Idle()
require.NoError(t, err)
require.NotNil(t, cmd, "expected a DDM sync command to be pending (DeclarativeManagement)")
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType, "%s", cmd.Raw)
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd)
}
s.awaitTriggerProfileSchedule(t)
// Acknowledge all commands to clear the command queue.
ackAllCmds(macOSDevice)
ackAllCmds(iOSDevice)
ackAllCmds(iPadOSDevice)
// OS update declaration identifiers used by Fleet (suffix is fixed in the service code).
const (
macOSUpdateIdent = "macos-software-update-94f4bbdf-f439-4fb1-8d27-ae1bb793e105"
iOSUpdateIdent = "ios-software-update-94f4bbdf-f439-4fb1-8d27-ae1bb793e105"
iPadOSUpdateIdent = "ipados-software-update-94f4bbdf-f439-4fb1-8d27-ae1bb793e105"
)
// -------------------------------------------------------------------------
// Step 1: Set initial OS updates for all platforms via POST /spec/teams.
// -------------------------------------------------------------------------
var applyResp applyTeamSpecsResponse
s.DoJSON("POST", "/api/latest/fleet/spec/teams", applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("14.6.1"),
Deadline: optjson.SetString("2024-03-01"),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("17.6.1"),
Deadline: optjson.SetString("2024-03-01"),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("17.6.1"),
Deadline: optjson.SetString("2024-03-01"),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(5),
GracePeriodDays: optjson.SetInt(1),
},
},
}}}, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// Verify that OS update declarations/profiles were created for the team.
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetMacOSUpdatesProfileName, true)
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetIOSUpdatesProfileName, true)
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetIPadOSUpdatesProfileName, true)
windowsOSUpdatesProfileUUID := checkWindowsOSUpdatesProfile(t, s.ds, &teamID,
&fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(5), GracePeriodDays: optjson.SetInt(1)})
// Verify that the Apple declarations are immediately queued for the enrolled
// hosts in the team. BulkSetPendingMDMHostProfiles is called synchronously
// when OS updates are edited via spec/teams. The status is NULL at this point
// (set to pending by ReconcileAppleDeclarations on the next run).
assertAppleDeclarationQueued(macOSHost.UUID, macOSUpdateIdent)
assertAppleDeclarationQueued(iOSHost.UUID, iOSUpdateIdent)
assertAppleDeclarationQueued(iPadOSHost.UUID, iPadOSUpdateIdent)
// Trigger the profile schedule so that:
// - ReconcileAppleDeclarations sends DDM sync commands to Apple hosts.
// - ReconcileWindowsProfiles queues the OS updates profile for the Windows host.
s.awaitTriggerProfileSchedule(t)
// Each Apple device should now have a pending DeclarativeManagement command.
// Acknowledging the command transitions the declaration to "verifying".
checkDDMSync(macOSDevice)
assertAppleDeclarationVerifying(macOSHost.UUID, macOSUpdateIdent)
checkDDMSync(iOSDevice)
assertAppleDeclarationVerifying(iOSHost.UUID, iOSUpdateIdent)
checkDDMSync(iPadOSDevice)
assertAppleDeclarationVerifying(iPadOSHost.UUID, iPadOSUpdateIdent)
// The Windows OS updates profile should be queued for the Windows host.
checkWindowsProfileQueued(windowsOSUpdatesProfileUUID)
// -------------------------------------------------------------------------
// Step 2: Update deadline and minimum version via POST /spec/teams.
// -------------------------------------------------------------------------
s.DoJSON("POST", "/api/latest/fleet/spec/teams", applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("14.3.0"),
Deadline: optjson.SetString("2025-06-01"),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("16.7.2"),
Deadline: optjson.SetString("2025-06-01"),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString("16.7.2"),
Deadline: optjson.SetString("2025-06-01"),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(7),
GracePeriodDays: optjson.SetInt(2),
},
},
}}}, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// Verify that the OS update declarations/profiles were updated for the team.
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetMacOSUpdatesProfileName, true)
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetIOSUpdatesProfileName, true)
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetIPadOSUpdatesProfileName, true)
checkWindowsOSUpdatesProfile(t, s.ds, &teamID,
&fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(7), GracePeriodDays: optjson.SetInt(2)})
// The updated OS update settings should cause the Apple declarations to be
// re-queued (status reset to NULL) for all enrolled hosts.
assertAppleDeclarationQueued(macOSHost.UUID, macOSUpdateIdent)
assertAppleDeclarationQueued(iOSHost.UUID, iOSUpdateIdent)
assertAppleDeclarationQueued(iPadOSHost.UUID, iPadOSUpdateIdent)
// Trigger the profile schedule again to reconcile the updated declarations
// and the updated Windows profile.
s.awaitTriggerProfileSchedule(t)
// Each Apple device should receive another DDM sync command for the updated
// declaration content, and transition back to "verifying" after acknowledging.
checkDDMSync(macOSDevice)
assertAppleDeclarationVerifying(macOSHost.UUID, macOSUpdateIdent)
checkDDMSync(iOSDevice)
assertAppleDeclarationVerifying(iOSHost.UUID, iOSUpdateIdent)
checkDDMSync(iPadOSDevice)
assertAppleDeclarationVerifying(iPadOSHost.UUID, iPadOSUpdateIdent)
// The Windows OS updates profile (same UUID, updated content) should be
// re-queued for the Windows host with the new deadline/grace period.
checkWindowsProfileQueued(windowsOSUpdatesProfileUUID)
// -------------------------------------------------------------------------
// Step 3: Clear deadline and minimum version via POST /spec/teams.
// -------------------------------------------------------------------------
s.DoJSON("POST", "/api/latest/fleet/spec/teams", applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString(""),
Deadline: optjson.SetString(""),
},
IOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString(""),
Deadline: optjson.SetString(""),
},
IPadOSUpdates: fleet.AppleOSUpdateSettings{
MinimumVersion: optjson.SetString(""),
Deadline: optjson.SetString(""),
},
WindowsUpdates: fleet.WindowsUpdates{
DeadlineDays: optjson.Int{},
GracePeriodDays: optjson.Int{},
},
},
}}}, http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// Verify that the OS update declarations/profiles were removed for the team.
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetMacOSUpdatesProfileName, false)
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetIOSUpdatesProfileName, false)
s.assertMacOSDeclarationsByName(&teamID, servermdm.FleetIPadOSUpdatesProfileName, false)
checkWindowsOSUpdatesProfile(t, s.ds, &teamID, nil)
// The updated OS update settings should cause the Apple declarations to be
// re-queued (status reset to NULL) for all enrolled hosts.
assertAppleDeclarationQueued(macOSHost.UUID, macOSUpdateIdent)
assertAppleDeclarationQueued(iOSHost.UUID, iOSUpdateIdent)
assertAppleDeclarationQueued(iPadOSHost.UUID, iPadOSUpdateIdent)
// Trigger the profile schedule again to reconcile the removed declarations.
s.awaitTriggerProfileSchedule(t)
// Each Apple device should receive a DDM sync command to remove the OS update
// declaration. After acknowledging, the device sends an empty status report,
// which causes Fleet to delete the pending remove rows via deletePendingRemovesStmt
// in MDMAppleStoreDDMStatusReport.
checkDDMSync(macOSDevice)
resp, err := macOSDevice.DeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
require.NoError(t, err)
resp.Body.Close()
assertAppleDeclarationGone(macOSHost.UUID, macOSUpdateIdent)
checkDDMSync(iOSDevice)
resp, err = iOSDevice.DeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
require.NoError(t, err)
resp.Body.Close()
assertAppleDeclarationGone(iOSHost.UUID, iOSUpdateIdent)
checkDDMSync(iPadOSDevice)
resp, err = iPadOSDevice.DeclarativeManagement("status", fleet.MDMAppleDDMStatusReport{})
require.NoError(t, err)
resp.Body.Close()
assertAppleDeclarationGone(iPadOSHost.UUID, iPadOSUpdateIdent)
}