fleet/server/service/integration_mdm_profiles_test.go
Roberto Dip 05818902cd
implement OTA enrollment (#21942)
for #21019

# Checklist for submitter

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

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/Committing-Changes.md#changes-files)
for more information.
- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality
2024-09-10 16:52:17 -03:00

4851 lines
224 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"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"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"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) {
t := s.T()
rootCA := x509.NewCertPool()
assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{
fleet.MDMAssetCACert,
})
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 := append(
globalProfiles,
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)
// 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 := append(
teamProfiles,
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", strconv.Itoa(int(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)
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
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
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, &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
// set new team profiles (delete + addition)
teamProfiles = [][]byte{
mobileconfigForTest("N4", "I4"),
mobileconfigForTest("N5", "I5"),
}
wantTeamProfiles = teamProfiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// 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
// with no changes
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.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, "idenfifer-"+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, []byte("checksum-"+mcUUID))
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/resend/%s", 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/resend/%s", 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/resend/%s", 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)
// can't resend profile while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", 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/resend/%s", 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't resend declaration while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), 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 declaration to verified, can resend
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
})
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusAccepted)
checkDDMSync(mdmDevice)
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": "some-declaration"}`, host.ID, host.DisplayName()),
0)
// transfer the host to the global team
err = s.ds.AddHostsToTeam(ctx, 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{Verifying: 1}
expectedTeamSummary = fleet.MDMProfilesSummary{}
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // host now verifying global 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/resend/%s", host.ID, mcUUID), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Unable to match profile to host")
// add a Windows profile, resend not supported when host is macOS
wpUUID := mysql.InsertWindowsProfileForTest(t, s.ds, 0)
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, wpUUID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Profile is not compatible with host platform")
// invalid profile UUID prefix should return 404
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", 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": "13.3.7"
}
}
}`), 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("13.1.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 := append(
testProfiles,
setupExpectedFleetdProfile(t, s.server.URL, enrollSecret, nil),
setupExpectedCAProfile(t, s.ds),
)
h, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
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) {
// report osquery results with I1 missing and confirm that the I1 marked as pending (initial retry)
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I1"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["I1"] = 1
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 and confirm that the I1 marked as failed (max retries exceeded)
reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
expectedProfileStatuses["I1"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// 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)
// trigger a profile sync and confirm that the install profile command for I3 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["I3"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// report osquery results with I3 missing and confirm that the I3 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")
// 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["I4"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["I4"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile command for I4 was sent and
// simulate a second device error
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 I3 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, this time I5 is missing and confirm that the I5 marked as
// failed (max retries exceeded)
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(map[string]string{"L1": "D1"})},
{Name: "N2", Contents: syncml.ForTestWithData(map[string]string{"L2": "D2", "L3": "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)
}
}
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)
}
}
type profileData struct {
Status string
LocURI string
Data string
}
hostProfileReports := map[string][]profileData{
"N1": {{"200", "L1", "D1"}},
"N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
}
reportHostProfs := func(t *testing.T, profileNames ...string) {
var responseOps []*fleet.SyncMLCmd
for _, profileName := range profileNames {
report, ok := hostProfileReports[profileName]
require.True(t, ok)
for _, p := range report {
ref := microsoft_mdm.HashLocURI(profileName, p.LocURI)
responseOps = append(responseOps, &fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
CmdID: fleet.CmdID{Value: uuid.NewString()},
CmdRef: &ref,
Data: ptr.String(p.Status),
})
// the protocol can respond with only a `Status`
// command if the status failed
if p.Status != "200" || p.Data != "" {
responseOps = append(responseOps, &fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdResults},
CmdID: fleet.CmdID{Value: uuid.NewString()},
CmdRef: &ref,
Items: []fleet.CmdItem{
{Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}},
},
})
}
}
}
msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps)
require.NoError(t, err)
out, err := xml.Marshal(msg)
require.NoError(t, err)
require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, out))
}
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("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/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// profiles to install + 2 boilerplate <Status>
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
checkProfilesStatus(t) // all profiles verifying
checkRetryCounts(t) // no retries yet
// report osquery results with N2 missing and confirm N2 marked
// as verifying and other profiles are marked as verified
reportHostProfs(t, "N1")
expectedProfileStatuses["N2"] = fleet.MDMDeliveryPending
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
expectedRetryCounts["N2"] = 1
checkRetryCounts(t)
// report osquery results with N2 present and confirm that all profiles are verified
verifyCommands(1, syncml.CmdStatusOK)
reportHostProfs(t, "N1", "N2")
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
})
t.Run("retry after verification", func(t *testing.T) {
// report osquery results with N1 missing and confirm that the N1 marked as pending (initial retry)
reportHostProfs(t, "N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["N1"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile command for N1 was resent
verifyCommands(1, syncml.CmdStatusOK)
// report osquery results with N1 missing again and confirm that the N1 marked as failed (max retries exceeded)
reportHostProfs(t, "N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for N1 was not resent
verifyCommands(0, syncml.CmdStatusOK)
})
t.Run("retry after device error", func(t *testing.T) {
// add another profile
newProfile := syncml.ForTestWithData(map[string]string{"L3": "D3"})
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "N3",
Contents: newProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// trigger a profile sync and confirm that the install profile command for N3 was sent and
// simulate a device error
verifyCommands(1, syncml.CmdStatusAtomicFailed)
expectedProfileStatuses["N3"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["N3"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile command for N3 was sent and
// simulate a device ack
verifyCommands(1, syncml.CmdStatusOK)
expectedProfileStatuses["N3"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// report osquery results with N3 missing and confirm that the N3 marked as failed (max
// retries exceeded)
reportHostProfs(t, "N2")
expectedProfileStatuses["N3"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for N3 was not resent
verifyCommands(0, syncml.CmdStatusOK)
})
t.Run("repeated device error", func(t *testing.T) {
// add another profile
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "N4",
Contents: syncml.ForTestWithData(map[string]string{"L4": "D4"}),
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// trigger a profile sync and confirm that the install profile command for N4 was sent and
// simulate a device error
verifyCommands(1, syncml.CmdStatusAtomicFailed)
expectedProfileStatuses["N4"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["N4"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile
// command for N4 was sent and simulate a second device error
verifyCommands(1, syncml.CmdStatusAtomicFailed)
expectedProfileStatuses["N4"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile
// command for N4 was not resent
verifyCommands(0, syncml.CmdStatusOK)
})
t.Run("retry count does not reset", func(t *testing.T) {
// add another profile
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "N5",
Contents: syncml.ForTestWithData(map[string]string{"L5": "D5"}),
})
// hostProfsByIdent["N5"] = &fleet.HostMacOSProfile{Identifier: "N5", DisplayName: "N5", InstallDate: time.Now()}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// trigger a profile sync and confirm that the install profile
// command for N5 was sent and simulate a device error
verifyCommands(1, syncml.CmdStatusAtomicFailed)
expectedProfileStatuses["N5"] = fleet.MDMDeliveryPending
checkProfilesStatus(t)
expectedRetryCounts["N5"] = 1
checkRetryCounts(t)
// trigger a profile sync and confirm that the install profile
// command for N5 was sent and simulate a device ack
verifyCommands(1, syncml.CmdStatusOK)
expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// report osquery results with N5 found and confirm that the N5 marked as verified
hostProfileReports["N5"] = []profileData{{"200", "L5", "D5"}}
reportHostProfs(t, "N2", "N5")
expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for N5 was not resent
verifyCommands(0, syncml.CmdStatusOK)
// report osquery results again, this time N5 is missing and confirm that the N5 marked as
// failed (max retries exceeded)
reportHostProfs(t, "N2")
expectedProfileStatuses["N5"] = fleet.MDMDeliveryFailed
checkProfilesStatus(t)
checkRetryCounts(t) // unchanged
// trigger a profile sync and confirm that the install profile command for N5 was not resent
verifyCommands(0, syncml.CmdStatusOK)
})
}
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.runWorker()
// 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.Team(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}`, tm1.ID, tm1.Name),
0)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name),
0)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeTransferredHostsToTeam{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q, "host_ids": [%d], "host_display_names": [%q]}`,
tm1.ID, tm1.Name, h.ID, h.DisplayName()),
0)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "name": %q, "team_name": %q}`,
tm1.ID, globalAsstResp.Name, 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.runWorker()
// 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)
// tm2 still has disk encryption and release device manually disabled
tm2, err = s.ds.Team(ctx, *h.TeamID)
require.NoError(t, err)
require.False(t, tm2.Config.MDM.EnableDiskEncryption)
require.False(t, tm2.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: "i2", OperationType: fleet.MDMOperationTypeRemove, 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},
{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: &tm2.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 tm2, and nothing be done since the host is already in tm2
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 tm2
h, err = s.ds.Host(ctx, mdmHost2.ID)
require.NoError(t, err)
require.NotNil(t, h.TeamID)
require.Equal(t, tm2.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)
s.runWorker()
// 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.Team(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.Team(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.Team(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)
mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.runWorker()
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)
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)
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)
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 {
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, &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" 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" 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" 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}`,
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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.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", strconv.Itoa(int(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", strconv.Itoa(int(tm.ID)))
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
}
func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() {
t := s.T()
ctx := context.Background()
createManualMDMEnrollWithOrbit := func(secret string) *fleet.Host {
// 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", 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)
return h
}
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 {
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending)
return err
})
}
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
globalProfiles := [][]byte{
mobileconfigForTest("G1", "G1"),
mobileconfigForTest("G2", "G2"),
}
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"),
mobileconfigForTest("T1.2", "T1.2"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", strconv.Itoa(int(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", strconv.Itoa(int(tm2.ID)))
// enroll a couple hosts in no team
h1 := createManualMDMEnrollWithOrbit(globalEnrollSec)
require.Nil(t, h1.TeamID)
h2 := createManualMDMEnrollWithOrbit(globalEnrollSec)
require.Nil(t, h2.TeamID)
// run the cron
s.awaitTriggerProfileSchedule(t)
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: 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: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// enroll a couple hosts in team 1
h3 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
require.NotNil(t, h3.TeamID)
require.Equal(t, tm1.ID, *h3.TeamID)
h4 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
require.NotNil(t, h4.TeamID)
require.Equal(t, tm1.ID, *h4.TeamID)
// run the cron
s.awaitTriggerProfileSchedule(t)
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: 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: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// switch a no team host (h1) to a team (tm2)
var moveHostResp addHostsToTeamResponse
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h1.ID}}, http.StatusOK, &moveHostResp)
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: "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: 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)
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: "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: 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
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{h4.ID}}, http.StatusOK, &moveHostResp)
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: "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: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", 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
triggerReconcileProfiles()
// add a profile to no team (h2 and h4 are now part of no team)
body, headers := generateNewProfileMultipartRequest(t,
"some_name", mobileconfigForTest("G3", "G3"), 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: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", 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: "G3", 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", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// 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
triggerReconcileProfiles()
// 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", 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", 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
triggerReconcileProfiles()
// bulk-set profiles for no team, with add/delete/edit
g2Edited := mobileconfigForTest("G2b", "G2b")
g4Content := mobileconfigForTest("G4", "G4")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
// G3 is deleted
g4Content,
},
}, 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: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", 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", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", 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
triggerReconcileProfiles()
// 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,
g4Content,
},
}, 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{
mobileconfigForTest("T1.3", "T1.3"),
},
}, 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: "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},
},
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: "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 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: "G4", 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: "G4", 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
triggerReconcileProfiles()
// all profiles now verifying
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G2b", 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},
},
h2: {
{Identifier: "G2b", 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},
},
h3: {
{Identifier: "G2b", 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},
},
h4: {
{Identifier: "G2b", 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},
},
})
// 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: "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},
},
h2: {
{Identifier: "G2b", 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},
},
h3: {
{Identifier: "G2b", 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},
},
h4: {
{Identifier: "G2b", 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},
},
})
// 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
},
)
triggerReconcileProfiles()
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", 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: "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},
},
h3: {
{Identifier: "G2b", 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},
},
h4: {
{Identifier: "G2b", 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},
},
})
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", 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: "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},
},
h3: {
{Identifier: "G2b", 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},
},
h4: {
{Identifier: "G2b", 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},
},
})
}
func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
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).
addLabelsFields := func(labelNames []string) map[string][]string {
var deprLabels, inclLabels, exclLabels []string
for _, lbl := range labelNames {
if strings.HasPrefix(lbl, "-") {
exclLabels = append(exclLabels, strings.TrimPrefix(lbl, "-"))
} else if strings.HasPrefix(lbl, "!") {
deprLabels = append(deprLabels, strings.TrimPrefix(lbl, "!"))
} else {
inclLabels = append(inclLabels, lbl)
}
}
fields := make(map[string][]string)
if len(deprLabels) > 0 {
fields["labels"] = deprLabels
}
if len(inclLabels) > 0 {
fields["labels_include_all"] = inclLabels
}
if len(exclLabels) > 0 {
fields["labels_exclude_any"] = exclLabels
}
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, "profile_name": %q, "profile_identifier": %q}`, name, ident)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "profile_identifier": %q}`, 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, "profile_name": %q, "identifier": %q}`, name, ident)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "identifier": %q}`, 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, "profile_name": %q}`, name)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.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)
// Windows profile name conflicts with Apple's for no team
assertWindowsProfile("apple-global-profile.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
// 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, "Couldn't upload. A configuration profile with this name already exists.")
// 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, "Couldn't upload. A configuration profile with this name already exists.")
// 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, "Couldn't upload. A configuration profile with this name already exists.")
// but no conflict for no-team
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "")
// 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, "apple-declaration already exists")
// 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, "apple-global-profile already exists")
// 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, "win-global-profile already exists")
// windows profile name conflicts with existing declaration
assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
// macOS profile name conflicts with existing declaration
assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
// 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.")
// Windows-reserved LocURI
assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.")
assertWindowsProfile("updates.xml", syncml.FleetOSUpdateTargetLocURI, testTeam.ID, nil, http.StatusBadRequest, "Couldn't upload. 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 upload. 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, "some or all the labels provided don't exist")
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
// 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, "some or all the labels provided don't exist")
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, "some or all the labels provided don't exist")
// 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" 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" 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" 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, "")
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, "")
// 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 upload. 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, "mobileconfig is not XML nor PKCS7 parseable")
// Apple 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 upload. The file should include valid JSON:")
// get the existing profiles work
expectedProfiles := []fleet.MDMConfigProfilePayload{
{ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil},
{ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID},
{ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
{ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-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,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{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},
},
},
}
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
})
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, "a") {
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 {
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")
var deleteResp deleteMDMConfigProfileResponse
// delete existing Apple profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp)
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}`,
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)
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)
// 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)
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)
require.NoError(t, err)
} else {
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(`<Replace></Replace>`)})
require.NoError(t, err)
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(`<Replace></Replace>`)})
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)
// 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)
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},
},
})
require.NoError(t, err)
// break lblFoo by deleting it
require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name))
// 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, 2)
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{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfF.ProfileUUID,
TeamID: tm2ProfF.TeamID,
Name: tm2ProfF.Name,
Platform: "darwin",
Identifier: tm2ProfF.Identifier,
Checksum: tm2ProfF.Checksum,
// 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])
// 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,
// labels are ordered by name
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, 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"},
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.MaxProfileRetries && 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) {
mdmResponseStatus := syncml.CmdStatusOK
if fail {
mdmResponseStatus = syncml.CmdStatusAtomicFailed
}
s.awaitTriggerProfileSchedule(t)
cmds, err := device.StartManagementSession()
require.NoError(t, err)
// 2 Status + n profiles
require.Len(t, cmds, n+2)
var atomicCmds []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" {
atomicCmds = append(atomicCmds, c)
status = mdmResponseStatus
require.NotEmpty(t, c.Cmd.ReplaceCommands)
for _, rc := range c.Cmd.ReplaceCommands {
require.NotEmpty(t, rc.CmdID)
}
}
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, atomicCmds, n)
// before we send the response, commands should be "pending"
verifyHostProfileStatus(atomicCmds, "")
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(atomicCmds, 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 = ?`
return sqlx.SelectContext(context.Background(), q, &gotUUIDs, stmt, host.UUID)
})
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.Equal(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
verifyProfiles(mdmDevice, 3, false)
checkHostsProfilesMatch(host, globalProfiles)
checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
// can't resend a profile while it is verifying
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusConflict)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// 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, ""))
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Verifying: 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.MDMDeliveryVerifying)
// os updates profile status doesn't matter for filtered hosts results or summaries
checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying)
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Verifying: 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.MDMDeliveryVerifying, nil, label)
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Verifying: 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.MDMDeliveryVerifying, nil, label) // expect no hosts
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, nil, label) // expect host
s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
Failed: 1,
Verifying: 0,
}, nil)
// can resend a profile after it has failed
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusAccepted)
verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
require.NoError(t, err)
// trigger a profile sync, device gets the team profile
verifyProfiles(mdmDevice, 2, false)
checkHostsProfilesMatch(host, teamProfiles)
checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
// set new team profiles (delete + addition)
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, teamProfiles[1])
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.MDMDeliveryVerifying)
// can't resend a profile while it is verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldnt resend. Configuration profiles with “pending” or “verifying” status cant be resent.")
// can't resend a profile from the wrong team
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Unable to match profile to host.")
// another sync shouldn't return profiles
verifyProfiles(mdmDevice, 0, false)
// set new team profiles (delete + addition)
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, teamProfiles[1])
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.MDMDeliveryVerifying)
// os updates profile status doesn't matter for filtered hosts results or summaries
checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying)
checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Verifying: 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.MDMDeliveryVerifying, &tm.ID, label)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Verifying: 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.MDMDeliveryVerifying, &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,
Verifying: 0,
}, nil)
// can resend a profile after it has failed
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so back to verifying
s.lastActivityMatches(
fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+teamProfiles[0]),
0)
// add a macOS profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+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, []byte("checksum-"+mcUUID))
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/resend/%s", host.ID, mcUUID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Profile is not compatible with host platform")
}
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" 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")
// 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}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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() {
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", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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.StatusBadRequest, "team_id", strconv.Itoa(int(tm.ID)))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "The file should include valid JSON")
// profiles with reserved Windows location URIs
// bitlocker
res = s.Do("POST", "/api/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", strconv.Itoa(int(tm.ID)))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
// 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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(tm.ID)))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
// batch-apply profiles with labels
lbl1, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L1", Query: "select 1;"})
require.NoError(t, err)
lbl2, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L2", Query: "select 1;"})
require.NoError(t, err)
lbl3, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L3", Query: "select 1;"})
require.NoError(t, err)
// attempt with an invalid label name
res = s.Do("POST", "/api/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, "some or all the labels provided don't exist")
// 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" 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' (Windows .xml file name or macOS .mobileconfig PayloadDisplayName).",
},
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1' (macOS .json file name or Windows .xml file name).",
},
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}},
expectErr: "More than one configuration profile have the same name 'N1' (macOS .json file name or macOS .mobileconfig PayloadDisplayName).",
},
} {
// team profiles
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(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)
}
}
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}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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() {
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", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.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", strconv.Itoa(int(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", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
// 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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(tm.ID)))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
}
func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() {
s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/enrollment_profiles/manual")
}
func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() {
t := s.T()
ctx := context.Background()
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, s.logger))
// 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": "13.3.7"
}
}
}`), 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(map[string]string{"l1": "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("13.3.8"),
},
},
},
}, 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, "13.3.8", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger))
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", strconv.Itoa(int(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", strconv.Itoa(int(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", strconv.Itoa(int(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)
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)
}
// 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)
// list profiles (no team)
expectedCP := testProfiles["TestNoTeam"]
var listResp listMDMAppleConfigProfilesResponse
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)
var deleteResp deleteMDMAppleConfigProfileResponse
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)})
// 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()
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
}
// 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.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},
},
})
// 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 go straight to removed without getting deleted on the host
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {},
})
// 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: {},
})
// 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},
},
})
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},
},
})
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)
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},
},
})
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)
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)
require.NoError(t, err)
err = s.ds.DeleteLabel(ctx, labels[4].Name)
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},
},
})
}
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)
// Get profile with that enroll secret
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.Contains(t, string(b), cfg.OrgInfo.OrgName)
}