package service
import (
"bytes"
"context"
"crypto/md5" // nolint:gosec // used only for tests
"crypto/x509"
"database/sql"
_ "embed"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"testing"
"time"
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
servermdm "github.com/fleetdm/fleet/v4/server/mdm"
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/mdm/nanodep/godep"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/contract"
"github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/micromdm/plist"
"github.com/smallstep/pkcs7"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) {
t := s.T()
rootCA := x509.NewCertPool()
assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{
fleet.MDMAssetCACert,
}, nil)
require.NoError(t, err)
require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value))
// verify that all the profiles were signed usign the SCEP certificate,
// and grab their contents
signedContents := [][]byte{}
for _, prof := range got {
p7, err := pkcs7.Parse(prof)
require.NoError(t, err)
require.NoError(t, p7.VerifyWithChain(rootCA))
signedContents = append(signedContents, p7.Content)
}
// verify that contents match
require.ElementsMatch(t, want, signedContents)
}
func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
}
wantGlobalProfiles := globalProfiles
wantGlobalProfiles = append(
wantGlobalProfiles,
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
)
// add global profiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// invalid secrets
invalidSecretsProfile := []byte(`
PayloadContent
PayloadDisplayName
My profile
PayloadIdentifier
$FLEET_SECRET_INVALID
PayloadType
Configuration
PayloadUUID
601E0B42-0989-4FAD-A61B-18656BA3670E
PayloadVersion
1
`)
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
invalidSecretsProfile = []byte(`
PayloadContent
PayloadDisplayName
$FLEET_SECRET_INVALID
PayloadIdentifier
N3
PayloadType
Configuration
PayloadUUID
601E0B42-0989-4FAD-A61B-18656BA3670E
PayloadVersion
1
`)
res = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "PayloadDisplayName cannot contain FLEET_SECRET variables")
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
// add an enroll secret so the fleetd profiles differ
var teamResp teamEnrollSecretsResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm.ID),
modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: "team1_enroll_sec"}},
}, http.StatusOK, &teamResp)
teamProfiles := [][]byte{
mobileconfigForTest("N3", "I3"),
}
wantTeamProfiles := teamProfiles
wantTeamProfiles = append(
wantTeamProfiles,
setupExpectedFleetdProfile(t, s.server.URL, "team1_enroll_sec", &tm.ID),
)
// add profiles to the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// create a non-macOS host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("non-macos-host"),
NodeKey: ptr.String("non-macos-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
Platform: "windows",
})
require.NoError(t, err)
// create a host that's not enrolled into MDM
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// 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, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// verify that we should install the team profile
s.signedProfilesMatch(wantTeamProfiles, installs)
// verify that we should delete both profiles
require.ElementsMatch(t, []string{"I1", "I2"}, removes)
expectedNoTeamSummary = fleet.MDMProfilesSummary{}
expectedTeamSummary = fleet.MDMProfilesSummary{
Pending: 0,
Failed: 0,
Verifying: 1,
Verified: 0,
}
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host now verifying team profiles
// Use secret variables in a profile
secretIdentifier := "secret-identifier-1"
secretType := "secret.type.1"
secretProfile := string(mobileconfigForTest("NS1", "IS1"))
req := createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier,
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType,
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile,
},
},
}
secretResp := createSecretVariablesResponse{}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
// set new team profiles (delete + addition)
teamProfiles = [][]byte{
mobileconfigForTest("N4", "I4"),
mobileconfigForTestWithContent("N5", "I5", "$FLEET_SECRET_IDENTIFIER", "${FLEET_SECRET_TYPE}",
"InnerName5"),
// The whole profile is one big secret.
[]byte("$FLEET_SECRET_PROFILE"),
}
// We deep copy one of the team profiles because we will modify the slice in place, and we want to keep the originals for later.
wantTeamProfiles = [][]byte{
teamProfiles[0],
make([]byte, len(teamProfiles[1])),
{},
}
copy(wantTeamProfiles[1], teamProfiles[1])
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// Manually replace the expected secret variables in the profile
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier))
wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "${FLEET_SECRET_TYPE}", secretType))
wantTeamProfiles[2] = []byte(secretProfile)
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfiles, installs)
// verify that we should delete the old team profiles
require.ElementsMatch(t, []string{"I3"}, removes)
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
// Upload the same profiles again. No changes expected.
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
// Change the secret variable and upload the profiles again. We should see the profile with updated secret installed.
secretType = "new.secret.type.1"
req = createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier, // did not change
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType, // changed
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile, // did not change
},
},
}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// Manually replace the expected secret variables in the profile
wantTeamProfilesChanged := [][]byte{
teamProfiles[1],
}
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER",
secretIdentifier))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType))
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfilesChanged, installs)
wantTeamProfiles[1] = wantTeamProfilesChanged[0]
// No profiles should be deleted
assert.Empty(t, removes)
// Clear the profiles using the new (non-deprecated) endpoint.
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id",
fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id",
fmt.Sprint(tm.ID), "dry_run", "false")
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
assert.Len(t, removes, 3)
// And reapply the same profiles using the new (non-deprecated) endpoint.
batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N4", Contents: teamProfiles[0]},
{Name: "N5", Contents: teamProfiles[1]},
{Name: "NS1", Contents: teamProfiles[2]},
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I4", false)
s.assertConfigProfilesByIdentifier(&tm.ID, "I5", false)
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
assert.Empty(t, removes)
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfiles, installs)
// Upload the same profiles again. No changes expected.
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Empty(t, installs)
require.Empty(t, removes)
// Change the secret variable and upload the profiles again. We should see the profile with updated secret installed.
secretType = "new2.secret.type.1"
req = createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_IDENTIFIER",
Value: secretIdentifier, // did not change
},
{
Name: "FLEET_SECRET_TYPE",
Value: secretType, // changed
},
{
Name: "FLEET_SECRET_PROFILE",
Value: secretProfile, // did not change
},
},
}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
// Manually replace the expected secret variables in the profile
wantTeamProfilesChanged = [][]byte{
teamProfiles[1],
}
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER",
secretIdentifier))
wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType))
// verify that we should install the team profiles
s.signedProfilesMatch(wantTeamProfilesChanged, installs)
wantTeamProfiles[1] = wantTeamProfilesChanged[0]
// No profiles should be deleted
assert.Empty(t, removes)
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.NotEmpty(t, hostResp.Host.MDM.Profiles)
resProfiles := *hostResp.Host.MDM.Profiles
// two extra profiles: fleetd config and root CA
require.Len(t, resProfiles, len(wantTeamProfiles)+2)
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
// add a new profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "identifier-"+mcUUID, mcUUID)
wantTeamProfiles = append(wantTeamProfiles, prof)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, test.MakeTestBytes())
return err
})
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend profile while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
// set the profile to pending, can't resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil)
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
// set the profile to failed, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// set the profile to failed, can resend from device endpoint
token := "good_token"
updateDeviceTokenForHost(t, s.ds, host.ID, token)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
return err
})
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
_ = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", token, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can't resend profile while verifying
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
// set the profile to verified, can resend
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID)
return err
})
_ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusAccepted)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, 1)
s.signedProfilesMatch([][]byte{prof}, installs)
require.Empty(t, removes)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
s.lastActivityMatches(
fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID),
0)
// add a declaration to the team
declIdent := "decl-ident-" + uuid.NewString()
fields := map[string][]string{
"team_id": {fmt.Sprintf("%d", tm.ID)},
}
body, headers := generateNewProfileMultipartRequest(
t, "some-declaration.json", declarationForTest(declIdent), s.token, fields,
)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
var resp newMDMConfigProfileResponse
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, "d", string(resp.ProfileUUID[0]))
declUUID := resp.ProfileUUID
checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger))
cmd, err := d.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
cmd, err = d.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd, fmt.Sprintf("expected no more commands, but got: %+v", cmd))
_, err = d.DeclarativeManagement("tokens")
require.NoError(t, err)
}
checkDDMSync(mdmDevice)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
// can not resend declarations as admin or from device endpoint
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, declUUID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.CantResendAppleDeclarationProfilesMessage)
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", token, declUUID), nil, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, fleet.CantResendAppleDeclarationProfilesMessage)
// set the declaration to verified
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID)
return err
})
// transfer the host to the global team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(nil, []uint{host.ID}))
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice, false)
require.Len(t, installs, len(wantGlobalProfiles))
s.signedProfilesMatch(wantGlobalProfiles, installs)
require.Len(t, removes, len(wantTeamProfiles))
expectedNoTeamSummary = fleet.MDMProfilesSummary{Pending: 1}
expectedTeamSummary = fleet.MDMProfilesSummary{}
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // host now removing team profiles
s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
// can't resend profile from another team
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Unable to match profile to host")
// invalid profile UUID prefix should return 404
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Invalid profile UUID prefix")
// set OS updates settings for no-team and team, should not change the
// summaries as this profile is ignored.
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_updates": {
"deadline": "2023-12-31",
"minimum_version": "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 := testProfiles
initialExpectedProfiles = append(
initialExpectedProfiles,
setupExpectedFleetdProfile(t, s.server.URL, enrollSecret, nil),
setupExpectedCAProfile(t, s.ds),
)
h, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
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)
})
}
type profileData struct {
Status string
LocURI string
Data string
}
// reportWindowsOSQueryProfiles simulates a Windows host reporting the status of MDM profiles from OSQuery results.
func (s *integrationMDMTestSuite) reportWindowsOSQueryProfiles(ctx context.Context, t *testing.T, host *fleet.Host, hostProfileReports map[string][]profileData) {
var responseOps []*fleet.SyncMLCmd
for profileName, report := range hostProfileReports {
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.logger, s.ds, host, out))
}
func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
t := s.T()
ctx := context.Background()
testProfiles := []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1"}})},
{Name: "N2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L2", Data: "D2"}, {Verb: "Add", LocURI: "L3", Data: "D3"}})},
}
h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
"N1": fleet.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)
}
}
hostProfileReports := map[string][]profileData{
"N1": {{"200", "L1", "D1"}},
"N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
}
reportHostProfs := func(profileNames ...string) {
selectedReports := make(map[string][]profileData)
for _, name := range profileNames {
if reports, exists := hostProfileReports[name]; exists {
selectedReports[name] = reports
}
}
s.reportWindowsOSQueryProfiles(ctx, t, h, selectedReports)
}
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
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("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("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("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("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([]syncml.TestCommand{{Verb: "Replace", LocURI: "L3", Data: "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("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([]syncml.TestCommand{{Verb: "Replace", LocURI: "L4", Data: "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([]syncml.TestCommand{{Verb: "Replace", LocURI: "L5", Data: "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("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("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)
})
}
// TestWindowsProfileResend verifies that a Windows profile is resent when its contents have been modified.
func (s *integrationMDMTestSuite) TestWindowsProfileResend() {
t := s.T()
ctx := context.Background()
testProfiles := []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1"}})},
{Name: "N2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L2", Data: "D2"}, {Verb: "Replace", LocURI: "L3", Data: "D3"}})},
}
h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
"N1": fleet.MDMDeliveryVerifying,
"N2": fleet.MDMDeliveryVerifying,
}
checkProfilesStatus := func(t *testing.T) {
storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Len(t, storedProfs, len(expectedProfileStatuses))
for _, p := range storedProfs {
want, ok := expectedProfileStatuses[p.Name]
require.True(t, ok, "unexpected profile: %s", p.Name)
require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name)
}
}
hostProfileReports := map[string][]profileData{
"N1": {{"200", "L1", "D1"}},
"N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
}
reportHostProfs := func(profileNames ...string) {
selectedReports := make(map[string][]profileData)
for _, name := range profileNames {
if reports, exists := hostProfileReports[name]; exists {
selectedReports[name] = reports
}
}
s.reportWindowsOSQueryProfiles(ctx, t, h, selectedReports)
}
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("do not resend if nothing changed", func(t *testing.T) {
t.Cleanup(func() {
// Clear the profiles
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{}},
http.StatusNoContent)
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// profiles to install + 2 boilerplate
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerifying
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t) // all profiles verifying
// report osquery results and confirm that all profiles are verified
reportHostProfs("N1", "N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
// Upload the same profiles again
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
})
t.Run("resend if contents changed", func(t *testing.T) {
t.Cleanup(func() {
// Clear the profiles
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{}},
http.StatusNoContent)
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
// profiles to install + 2 boilerplate
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerifying
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerifying
checkProfilesStatus(t) // all profiles verifying
// report osquery results and confirm that all profiles are verified
reportHostProfs("N1", "N2")
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t)
// trigger a profile sync and confirm that no profiles were sent
verifyCommands(0, syncml.CmdStatusOK)
// Change one profile and upload
copiedTestProfiles := make([]fleet.MDMProfileBatchPayload, len(testProfiles))
copy(copiedTestProfiles, testProfiles)
copiedTestProfiles[0].Contents = syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1-Modified"}})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: copiedTestProfiles}, http.StatusNoContent)
// Confirm that one profile was sent and its status
verifyCommands(1, syncml.CmdStatusOK)
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerifying
expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
checkProfilesStatus(t) // all profiles verifying
})
}
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.TeamLite(ctx, *h.TeamID)
require.NoError(t, err)
require.Equal(t, "g1", tm1.Name)
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
require.True(t, tm1.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
runWithAdminToken(func() {
// it create activities for the new team, the profiles assigned to it,
// the host moved to it, and setup assistant
s.lastActivityOfTypeMatches(
fleet.ActivityTypeCreatedTeam{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, 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)
// tmLite2 still has disk encryption and release device manually disabled
tmLite2, err := s.ds.TeamLite(ctx, *h.TeamID)
require.NoError(t, err)
require.False(t, tmLite2.Config.MDM.EnableDiskEncryption)
require.False(t, tmLite2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
// the host's profiles are:
// - the same as the team's and are pending (prof1 + prof4)
// - prof2 + old filevault are pending removal
// - fleetd config being reinstalled (for new enroll secret)
s.awaitTriggerProfileSchedule(t)
// useful for debugging
// mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// mysql.DumpTable(t, q, "host_mdm_apple_profiles")
// return nil
// })
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
mdmHost: {
{Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
// Profiles from previous team being deleted
{Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{
Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove,
Status: &fleet.MDMDeliveryPending,
},
},
})
// create a new mdm host enrolled in fleet
mdmHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// make it part of team 2
s.Do("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tmLite2.ID, HostIDs: []uint{mdmHost2.ID}}, http.StatusOK)
// simulate having its profiles installed
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
res, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID)
n, _ := res.RowsAffected()
require.Equal(t, 4, int(n))
return err
})
// preassign the MDM host using "g1" and "g4", should match existing
// team tmLite2, and nothing be done since the host is already in tmLite2
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm2"}, http.StatusNoContent)
// the host is still part of tmLite2
h, err = s.ds.Host(ctx, mdmHost2.ID)
require.NoError(t, err)
require.NotNil(t, h.TeamID)
require.Equal(t, tmLite2.ID, *h.TeamID)
// and its profiles have been left untouched
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
mdmHost2: {
{Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
}
// while s.TestPuppetMatchPreassignProfiles focuses on many edge cases/extra
// checks around profile assignment, this test is mainly focused on
// simulating a few puppet runs in scenarios we want to support, and ensuring that:
//
// - different hosts end up in the right teams
// - teams get edited as expected
// - commands to add/remove profiles are issued adequately
func (s *integrationMDMTestSuite) TestPuppetRun() {
t := s.T()
ctx := context.Background()
// define a few profiles
prof1, prof2, prof3, prof4 := mobileconfigForTest("n1", "i1"),
mobileconfigForTest("n2", "i2"),
mobileconfigForTest("n3", "i3"),
mobileconfigForTest("n4", "i4")
// create three hosts
host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host3, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.runWorker()
// Set up a mock Apple DEP API
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "session123"}`))
case "/account":
_, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo")))
case "/profile":
w.WriteHeader(http.StatusOK)
require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"}))
}
}))
// Use a gitops user for all Puppet actions
u := &fleet.User{
Name: "GitOps",
Email: "gitops-TestPuppetRun@example.com",
GlobalRole: ptr.String(fleet.RoleGitOps),
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err := s.ds.NewUser(context.Background(), u)
require.NoError(t, err)
s.setTokenForTest(t, "gitops-TestPuppetRun@example.com", test.GoodPassword)
// preassignAndMatch simulates the puppet module doing all the
// preassign/match calls for a given set of profiles.
preassignAndMatch := func(profs []fleet.MDMApplePreassignProfilePayload) {
require.NotEmpty(t, profs)
for _, prof := range profs {
s.Do(
"POST",
"/api/latest/fleet/mdm/apple/profiles/preassign",
preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: prof},
http.StatusNoContent,
)
}
s.Do(
"POST",
"/api/latest/fleet/mdm/apple/profiles/match",
matchMDMApplePreassignmentRequest{ExternalHostIdentifier: profs[0].ExternalHostIdentifier},
http.StatusNoContent,
)
}
// node default {
// fleetdm::profile { 'n1':
// template => template('n1.mobileconfig.erb'),
// group => 'base',
// }
//
// fleetdm::profile { 'n2':
// template => template('n2.mobileconfig.erb'),
// group => 'workstations',
// }
//
// fleetdm::profile { 'n3':
// template => template('n3.mobileconfig.erb'),
// group => 'workstations',
// }
//
// if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
// fleetdm::profile { 'n4':
// template => template('fleetdm/n4.mobileconfig.erb'),
// group => 'kiosks',
// }
// }
puppetRun := func(host *fleet.Host) {
payload := []fleet.MDMApplePreassignProfilePayload{
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof1,
Group: "base",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof2,
Group: "workstations",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof3,
Group: "workstations",
},
}
if host.UUID == host2.UUID {
payload = append(payload, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof4,
Group: "kiosks",
})
}
preassignAndMatch(payload)
}
// host1 checks in
puppetRun(host1)
// the host now belongs to a team
h1, err := s.ds.Host(ctx, host1.ID)
require.NoError(t, err)
require.NotNil(t, h1.TeamID)
// the team has the right name
tm1, err := s.ds.TeamWithExtras(ctx, *h1.TeamID)
require.NoError(t, err)
require.Equal(t, "base - workstations", tm1.Name)
// and the right profiles
profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// host2 checks in
puppetRun(host2)
// a new team is created
h2, err := s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.NotNil(t, h2.TeamID)
// the team has the right name
tm2, err := s.ds.TeamWithExtras(ctx, *h2.TeamID)
require.NoError(t, err)
require.Equal(t, "base - kiosks - workstations", tm2.Name)
// and the right profiles
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
require.Len(t, profs, 4)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
require.True(t, tm2.Config.MDM.EnableDiskEncryption)
// host3 checks in
puppetRun(host3)
// it belongs to the same team as host1
h3, err := s.ds.Host(ctx, host3.ID)
require.NoError(t, err)
require.Equal(t, h1.TeamID, h3.TeamID)
// prof2 is edited
oldProf2 := prof2
prof2 = mobileconfigForTest("n2", "i2-v2")
// host3 checks in again
puppetRun(host3)
// still belongs to the same team
h3, err = s.ds.Host(ctx, host3.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h3.TeamID)
// but the team has prof2 updated
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// host2 checks in, still belongs to the same team
puppetRun(host2)
h2, err = s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.Equal(t, tm2.ID, *h2.TeamID)
// but the team has prof2 updated as well
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
require.Len(t, profs, 4)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// the puppet manifest is changed, and prof3 is removed
// node default {
// fleetdm::profile { 'n1':
// template => template('n1.mobileconfig.erb'),
// group => 'base',
// }
//
// fleetdm::profile { 'n2':
// template => template('n2.mobileconfig.erb'),
// group => 'workstations',
// }
//
// if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
// fleetdm::profile { 'n4':
// template => template('fleetdm/n4.mobileconfig.erb'),
// group => 'kiosks',
// }
// }
puppetRun = func(host *fleet.Host) {
payload := []fleet.MDMApplePreassignProfilePayload{
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof1,
Group: "base",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof2,
Group: "workstations",
},
}
if host.UUID == host2.UUID {
payload = append(payload, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof4,
Group: "kiosks",
})
}
preassignAndMatch(payload)
}
// host1 checks in again
puppetRun(host1)
// still belongs to the same team
h1, err = s.ds.Host(ctx, host1.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h1.TeamID)
// but the team doesn't have prof3 anymore
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 2)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// same for host2
puppetRun(host2)
h2, err = s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.Equal(t, tm2.ID, *h2.TeamID)
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof4, []byte(profs[2].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// The puppet manifest is drastically updated, this time to use exclusions on host3:
//
// node default {
// fleetdm::profile { 'n1':
// template => template('n1.mobileconfig.erb'),
// group => 'base',
// }
//
// fleetdm::profile { 'n2':
// template => template('n2.mobileconfig.erb'),
// group => 'workstations',
// }
//
// if $facts['system_profiler']['hardware_uuid'] == 'host_3_uuid' {
// fleetdm::profile { 'n3':
// template => template('fleetdm/n3.mobileconfig.erb'),
// group => 'no-nudge',
// }
// } else {
// fleetdm::profile { 'n3':
// ensure => absent,
// template => template('fleetdm/n3.mobileconfig.erb'),
// group => 'workstations',
// }
// }
// }
puppetRun = func(host *fleet.Host) {
manifest := []fleet.MDMApplePreassignProfilePayload{
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof1,
Group: "base",
},
{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof2,
Group: "workstations",
},
}
if host.UUID == host3.UUID {
manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof3,
Group: "no-nudge",
Exclude: true,
})
} else {
manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
ExternalHostIdentifier: host.Hostname,
HostUUID: host.UUID,
Profile: prof3,
Group: "workstations",
})
}
preassignAndMatch(manifest)
}
// host1 checks in
puppetRun(host1)
// the host belongs to the same team
h1, err = s.ds.Host(ctx, host1.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h1.TeamID)
// the team has the right profiles
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
require.NoError(t, err)
require.Len(t, profs, 3)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
require.True(t, tm1.Config.MDM.EnableDiskEncryption)
// host2 checks in
puppetRun(host2)
// it is assigned to tm1
h2, err = s.ds.Host(ctx, host2.ID)
require.NoError(t, err)
require.Equal(t, tm1.ID, *h2.TeamID)
// host3 checks in
puppetRun(host3)
// it is assigned to a new team
h3, err = s.ds.Host(ctx, host3.ID)
require.NoError(t, err)
require.NotNil(t, h3.TeamID)
require.NotEqual(t, tm1.ID, *h3.TeamID)
require.NotEqual(t, tm2.ID, *h3.TeamID)
// a new team is created
tm3, err := s.ds.TeamWithExtras(ctx, *h3.TeamID)
require.NoError(t, err)
require.Equal(t, "base - no-nudge - workstations", tm3.Name)
// and the right profiles
profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm3.ID)
require.NoError(t, err)
require.Len(t, profs, 2)
require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
require.True(t, tm3.Config.MDM.EnableDiskEncryption)
}
func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
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, nil)
require.NoError(t, err)
p2, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p2", "p2.identifier", "p2.uuid"), &testTeam.ID)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *p2, nil)
require.NoError(t, err)
var resp listMDMAppleConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: 0}, http.StatusOK, &resp)
require.NotNil(t, resp.ConfigProfiles)
require.Len(t, resp.ConfigProfiles, 1)
require.Equal(t, p1.Name, resp.ConfigProfiles[0].Name)
require.Equal(t, p1.Identifier, resp.ConfigProfiles[0].Identifier)
resp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.ConfigProfiles)
require.Len(t, resp.ConfigProfiles, 1)
require.Equal(t, p2.Name, resp.ConfigProfiles[0].Name)
require.Equal(t, p2.Identifier, resp.ConfigProfiles[0].Identifier)
p3, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p3", "p3.identifier", "p3.uuid"), &testTeam.ID)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *p3, nil)
require.NoError(t, err)
resp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
require.NotNil(t, resp.ConfigProfiles)
require.Len(t, resp.ConfigProfiles, 2)
for _, p := range resp.ConfigProfiles {
if p.Name == p2.Name { //nolint:gocritic // ignore ifElseChain
require.Equal(t, p2.Identifier, p.Identifier)
} else if p.Name == p3.Name {
require.Equal(t, p3.Identifier, p.Identifier)
} else {
require.Fail(t, "unexpected profile name")
}
}
var hostProfilesResp getHostProfilesResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
require.NotNil(t, hostProfilesResp.Profiles)
require.Len(t, hostProfilesResp.Profiles, 1)
require.Equal(t, p1.Name, hostProfilesResp.Profiles[0].Name)
require.Equal(t, p1.Identifier, hostProfilesResp.Profiles[0].Identifier)
require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&testTeam.ID, []uint{mdmHost.ID}))
require.NoError(t, err)
hostProfilesResp = getHostProfilesResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
require.NotNil(t, hostProfilesResp.Profiles)
require.Len(t, hostProfilesResp.Profiles, 2)
require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
})
}
func (s *integrationMDMTestSuite) TestAppConfigMDMCustomSettings() {
t := s.T()
// set the macos custom settings fields with the deprecated Labels field
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": {
"custom_settings": [
{"path": "foo", "labels": ["baz"]},
{"path": "bar"}
]
}
}
}`), http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
// check that they are returned by a GET /config
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
// set the windows custom settings fields with included/excluded labels
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels_exclude_any": ["x", "y"]},
{"path": "bar", "labels_include_all": ["a", "b"]},
{"path": "baz", "labels": ["c"]}
]
}
}
}`), http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// check that they are returned by a GET /config
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// patch without specifying the windows/macos custom settings fields and an unrelated
// field, should not remove them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
}`), http.StatusOK, &acResp)
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// patch with explicitly empty macos/windows custom settings fields, would remove
// them but this is a dry-run
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": { "custom_settings": null },
"windows_settings": { "custom_settings": null }
}
}`), http.StatusOK, &acResp, "dry_run", "true")
assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings)
// patch with explicitly empty macos custom settings fields, removes them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": { "custom_settings": null },
"windows_settings": { "custom_settings": null }
}
}`), http.StatusOK, &acResp)
assert.Empty(t, acResp.MDM.MacOSSettings.CustomSettings)
assert.Equal(t, optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, acResp.MDM.WindowsSettings.CustomSettings)
// mix of labels fields returns an error
res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"macos_settings": {
"custom_settings": [
{"path": "foo", "labels": ["a"], "labels_exclude_any": ["b"]}
]
}
}
}`), http.StatusUnprocessableEntity)
msg := extractServerErrorText(res.Body)
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels_include_all": ["a"], "labels_exclude_any": ["b"]}
]
}
}
}`), http.StatusUnprocessableEntity)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels_include_any": ["a"], "labels_exclude_any": ["b"]}
]
}
}
}`), http.StatusUnprocessableEntity)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels": ["a"], "labels_include_any": ["b"]}
]
}
}
}`), http.StatusUnprocessableEntity)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
}
func (s *integrationMDMTestSuite) TestApplyTeamsMDMAppleProfiles() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
// apply with custom macos settings
teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{
"custom_settings": []map[string]interface{}{
{"path": "foo", "labels": []string{"a", "b"}},
{"path": "bar", "labels_exclude_any": []string{"c"}},
},
},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// retrieving the team returns the custom macos settings
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"a", "b"}},
{Path: "bar", LabelsExcludeAny: []string{"c"}},
}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with invalid macos settings subfield should fail
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"foo_bar": 123},
},
}}}
res := s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `unsupported key provided: "foo_bar"`)
// apply with some good and some bad macos settings subfield should fail
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []interface{}{"A", true}},
},
}}}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest)
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `invalid value type at 'macos_settings.custom_settings': expected array of MDMProfileSpecs but got bool`)
// apply without custom macos settings specified and unrelated field, should
// not replace existing settings
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
EnableDiskEncryption: optjson.SetBool(false),
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"a", "b"}},
{Path: "bar", LabelsExcludeAny: []string{"c"}},
}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with explicitly empty custom macos settings would clear the existing
// settings, but dry-run
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []map[string]interface{}{}},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"a", "b"}},
{Path: "bar", LabelsExcludeAny: []string{"c"}},
}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with explicitly empty custom macos settings clears the existing settings
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []map[string]interface{}{}},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Equal(t, []fleet.MDMProfileSpec{}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
// apply with invalid mix of labels fails
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{
"custom_settings": []map[string]interface{}{
{"path": "bar", "labels": []string{"x"}},
{"path": "foo", "labels": []string{"a", "b"}, "labels_include_all": []string{"c"}},
},
},
},
}}}
res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMAppleProfiles() {
t := s.T()
ctx := context.Background()
bigString := strings.Repeat("a", 1024*1024+1)
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil}, http.StatusNoContent)
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// Profile is too big
resp := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{[]byte(bigString)}},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "maximum configuration profile file size is 1 MB")
// duplicate profile names
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N1", "I2"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest(p, p),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
}
// payloads with reserved types
for p := range mobileconfig.FleetPayloadTypes() {
if p == mobileconfig.FleetCustomSettingsPayloadType {
// FileVault options in the custom settings payload are checked in file_vault_options_test.go
continue
}
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
switch p {
case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType:
assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg)
default:
assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
}
}
// payloads with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", p, "random", ""),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
// successfully apply a profile for the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
}
func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() {
t := s.T()
ctx := context.Background()
createManualMDMEnrollWithOrbit := func(secret string, doUserEnroll bool) (*fleet.Host, *fleet.NanoEnrollment, *mdmtest.TestAppleMDMClient) {
// orbit enrollment happens before mdm enrollment, otherwise the host would
// always receive the "no team" profiles on mdm enrollment since it would
// not be part of any team yet (team assignment is done when it enrolls
// with orbit).
mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{
SCEPChallenge: s.scepChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
}, "MacBookPro16,1")
// enroll the device with orbit
var resp EnrollOrbitResponse
s.DoJSON("POST", "/api/fleet/orbit/enroll", contract.EnrollOrbitRequest{
EnrollSecret: secret,
HardwareUUID: mdmDevice.UUID, // will not match any existing host
HardwareSerial: mdmDevice.SerialNumber,
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.OrbitNodeKey)
orbitNodeKey := resp.OrbitNodeKey
h, err := s.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey)
require.NoError(t, err)
h.OrbitNodeKey = &orbitNodeKey
h.Platform = "darwin"
err = mdmDevice.Enroll()
require.NoError(t, err)
var userEnrollment *fleet.NanoEnrollment
if doUserEnroll {
// Do a user enrollment with a bit of extra sanity checking
userEnrollment, err = s.ds.GetNanoMDMUserEnrollment(ctx, h.UUID)
require.NoError(t, err)
require.Nil(t, userEnrollment)
err = mdmDevice.UserEnroll()
require.NoError(t, err)
userEnrollment, err = s.ds.GetNanoMDMUserEnrollment(ctx, h.UUID)
require.NoError(t, err)
require.NotNil(t, userEnrollment)
}
return h, userEnrollment, mdmDevice
}
triggerReconcileProfilesMarkVerifying := func() {
t.Logf("[TestHostMDMAppleProfilesStatus] Calling awaitTriggerProfileSchedule at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] awaitTriggerProfileSchedule completed, updating profiles to verifying at %s", time.Now().Format(time.RFC3339))
// this will only mark them as "pending", as the response to confirm
// profile deployment is asynchronous, so we simulate it here by
// updating any "pending" (not NULL) profiles to "verifying"
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending)
return err
})
t.Logf("[TestHostMDMAppleProfilesStatus] Profiles updated to verifying status at %s", time.Now().Format(time.RFC3339))
}
assignHostToTeam := func(h *fleet.Host, teamID *uint) {
var moveHostResp addHostsToTeamResponse
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &moveHostResp)
h.TeamID = teamID
}
// add a couple global profiles
payloadScopeSystem := fleet.PayloadScopeSystem
payloadScopeUser := fleet.PayloadScopeUser
globalProfiles := [][]byte{
mobileconfigForTest("G1", "G1"),
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// create the no-team enroll secret
var applyResp applyEnrollSecretSpecResponse
globalEnrollSec := "global_enroll_sec"
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
},
}, http.StatusOK, &applyResp)
// create a team with a couple profiles
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_1"})
require.NoError(t, err)
tm1Profiles := [][]byte{
mobileconfigForTest("T1.1", "T1.1"),
scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem),
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
// create the team 1 enroll secret
var teamResp teamEnrollSecretsResponse
tm1EnrollSec := "team1_enroll_sec"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1.ID),
modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: tm1EnrollSec}},
}, http.StatusOK, &teamResp)
// create another team with different profiles
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_2"})
require.NoError(t, err)
tm2Profiles := [][]byte{
mobileconfigForTest("T2.1", "T2.1"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm2.ID))
// enroll a couple hosts in no team
h1, h1UserEnrollment, _ := createManualMDMEnrollWithOrbit(globalEnrollSec, true)
require.Nil(t, h1.TeamID)
h2, _, _ := createManualMDMEnrollWithOrbit(globalEnrollSec, false)
require.Nil(t, h2.TeamID)
// run the cron
t.Logf("[TestHostMDMAppleProfilesStatus] Starting FIRST cron run (after h1, h2 enrolled) at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] FIRST cron run completed at %s", time.Now().Format(time.RFC3339))
// G3 is user-scoped and the h2 host doesn't have a user-channel yet (and
// enrolled just now, so the minimum delay to give up and fail the profile
// delivery is not reached)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
h2: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// Verify there is a command on the user channel for h1
enrollmentIds, err := s.ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx)
require.NoError(t, err)
assert.Contains(t, enrollmentIds, h1UserEnrollment.ID)
assert.Contains(t, enrollmentIds, h1.UUID)
assert.Contains(t, enrollmentIds, h2.UUID)
// enroll a couple hosts in team 1
h3, h3UserEnrollment, _ := createManualMDMEnrollWithOrbit(tm1EnrollSec, true)
require.NotNil(t, h3.TeamID)
require.Equal(t, tm1.ID, *h3.TeamID)
h4, _, h4Device := createManualMDMEnrollWithOrbit(tm1EnrollSec, false)
require.NotNil(t, h4.TeamID)
require.Equal(t, tm1.ID, *h4.TeamID)
// run the cron
t.Logf("[TestHostMDMAppleProfilesStatus] Starting SECOND cron run (after h3, h4 enrolled in team1) at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] SECOND cron run completed at %s", time.Now().Format(time.RFC3339))
// T1.3 is user-scoped and the h4 host doesn't have a user-channel yet (and
// enrolled just now, so the minimum delay to give up and send the
// user-scoped profiles to the device channel is not reached)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// Verify there is a command on the user channel for h3
enrollmentIds, err = s.ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx)
require.NoError(t, err)
assert.Contains(t, enrollmentIds, h3UserEnrollment.ID)
assert.Contains(t, enrollmentIds, h3.UUID)
assert.Contains(t, enrollmentIds, h4.UUID)
// switch a no team host (h1) to a team (tm2)
t.Logf("[TestHostMDMAppleProfilesStatus] Transferring host h1 (id=%d) from no team to team tm2 (id=%d) at %s", h1.ID, tm2.ID, time.Now().Format(time.RFC3339))
var moveHostResp addHostsToTeamResponse
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h1.ID}}, http.StatusOK, &moveHostResp)
t.Logf("[TestHostMDMAppleProfilesStatus] Host h1 transfer completed at %s", time.Now().Format(time.RFC3339))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// switch a team host (h3) to another team (tm2)
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h3.ID}}, http.StatusOK, &moveHostResp)
// create the user-enrollment for host h4
err = h4Device.UserEnroll()
require.NoError(t, err)
// run the cron
t.Logf("[TestHostMDMAppleProfilesStatus] Starting THIRD cron run (after h3->tm2, h4 user enrolled) at %s", time.Now().Format(time.RFC3339))
s.awaitTriggerProfileSchedule(t)
t.Logf("[TestHostMDMAppleProfilesStatus] THIRD cron run completed at %s", time.Now().Format(time.RFC3339))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// switch a team host (h4) to no team
t.Logf("[TestHostMDMAppleProfilesStatus] Transferring host h4 (id=%d) from team tm1 to NO TEAM (nil) at %s", h4.ID, time.Now().Format(time.RFC3339))
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{h4.ID}}, http.StatusOK, &moveHostResp)
t.Logf("[TestHostMDMAppleProfilesStatus] Host h4 transfer to NO TEAM completed at %s", time.Now().Format(time.RFC3339))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, // still pending install due to cron not having run
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// add a profile to no team (h2 and h4 are now part of no team)
body, headers := generateNewProfileMultipartRequest(t,
"some_name", mobileconfigForTest("G4", "G4"), s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: { // still no user channel
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
h4: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// add a profile to team 2 (h1 and h3 are now part of team 2)
body, headers = generateNewProfileMultipartRequest(t,
"some_name", mobileconfigForTest("T2.2", "T2.2"), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm2.ID)}})
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// delete a no team profile
noTeamProfs, err := s.ds.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
var g1ProfID uint
for _, p := range noTeamProfs {
if p.Identifier == "G1" {
g1ProfID = p.ProfileID
break
}
}
require.NotZero(t, g1ProfID)
var delProfResp deleteMDMAppleConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", g1ProfID),
deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// delete a team profile
tm2Profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
require.NoError(t, err)
var tm21ProfID uint
for _, p := range tm2Profs {
if p.Identifier == "T2.1" {
tm21ProfID = p.ProfileID
break
}
}
require.NotZero(t, tm21ProfID)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", tm21ProfID),
deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// bulk-set profiles for no team, with add/delete/edit
g2Edited := mobileconfigForTest("G2b", "G2b")
g5Content := mobileconfigForTest("G5", "G5")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
// G3 is deleted
// G4 is deleted
g5Content,
},
}, http.StatusNoContent)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
{Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// bulk-set profiles for a team, with add/delete/edit
t22Edited := mobileconfigForTest("T2.2b", "T2.2b")
t23Content := mobileconfigForTest("T2.3", "T2.3")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
t22Edited,
t23Content,
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// bulk-set profiles for no team and team 2, without changes, and team 1 added (but no host affected)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
g5Content,
},
}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
t22Edited,
t23Content,
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser),
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// delete team 2 (h1 and h3 are part of that team)
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tm2.ID), nil, http.StatusOK)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfilesMarkVerifying()
// all profiles now verifying
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// h1 verified one of the profiles
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
"G2b": {Identifier: "G2b", DisplayName: "G2b", InstallDate: time.Now()},
}))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// switch a team host (h1) to another team (tm1)
assignHostToTeam(h1, &tm1.ID)
// Create a new profile that will be labeled
body, headers = generateNewProfileMultipartRequest(
t,
"label_prof",
mobileconfigForTest("label_prof", "label_prof"),
s.token,
map[string][]string{"team_id": {fmt.Sprintf("%d", tm1.ID)}},
)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
var uid string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "label_prof")
})
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "test label 1", Query: "select 1;"})
require.NoError(t, err)
// Update label with host membership
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
h1.ID,
label.ID,
)
return err
},
)
// Update profile <-> label mapping
mysql.ExecAdhocSQL(
t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(
context.Background(),
"INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
uid,
label.Name,
label.ID,
)
return err
},
)
triggerReconcileProfilesMarkVerifying()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
"label_prof": {Identifier: "label_prof", DisplayName: "label_prof", InstallDate: time.Now()},
}))
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
}
func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
// Ensure MDM is turned on
appConfig, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appConfig.MDM.AndroidEnabledAndConfigured = true
appConfig.MDM.EnabledAndConfigured = true
appConfig.MDM.WindowsEnabledAndConfigured = true
err = s.ds.SaveAppConfig(ctx, appConfig)
require.NoError(t, err)
// NOTE: label names starting with "-" are sent as "labels_excluding_any"
// (and the leading "-" is removed from the name). Names starting with
// "!" are sent as the deprecated "labels" field (and the "!" is removed).
// Names starting with a "~" prefix are sent as "labels_include_any"
// (and the leading "~" is removed.
addLabelsFields := func(labelNames []string) map[string][]string {
var deprLabels, inclAllLabels, inclAnyLabels, exclLabels []string
for _, lbl := range labelNames {
switch {
case strings.HasPrefix(lbl, "~"):
inclAnyLabels = append(inclAnyLabels, strings.TrimPrefix(lbl, "~"))
case strings.HasPrefix(lbl, "-"):
exclLabels = append(exclLabels, strings.TrimPrefix(lbl, "-"))
case strings.HasPrefix(lbl, "!"):
deprLabels = append(deprLabels, strings.TrimPrefix(lbl, "!"))
default:
inclAllLabels = append(inclAllLabels, lbl)
}
}
fields := make(map[string][]string)
if len(deprLabels) > 0 {
fields["labels"] = deprLabels
}
if len(inclAllLabels) > 0 {
fields["labels_include_all"] = inclAllLabels
}
if len(exclLabels) > 0 {
fields["labels_exclude_any"] = exclLabels
}
if len(inclAnyLabels) > 0 {
fields["labels_include_any"] = inclAnyLabels
}
return fields
}
assertAppleProfile := func(filename, name, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
body, headers := generateNewProfileMultipartRequest(
t, filename, mobileconfigForTest(name, ident), s.token, fields,
)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, "a", string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
assertAppleDeclaration := func(filename, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
bytes := []byte(fmt.Sprintf(`{
"Type": "com.apple.configuration.foo",
"Payload": {
"Echo": "f1337"
},
"Identifier": "%s"
}`, ident))
body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, fleet.MDMAppleDeclarationUUIDPrefix, string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
createAppleProfile := func(name, ident string, teamID uint, labelNames []string) string {
uid := assertAppleProfile(name+".mobileconfig", name, ident, teamID, labelNames, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "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(`- %s
- %s
`, 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
}
assertAndroidProfile := func(filename string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
fields := addLabelsFields(labelNames)
if teamID > 0 {
fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
}
bytes := []byte(`{
"removeUserDisabled": false
}`)
body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
if wantErrMsg != "" {
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, wantErrMsg)
return ""
}
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
require.NotEmpty(t, resp.ProfileUUID)
require.Equal(t, fleet.MDMAndroidProfileUUIDPrefix, string(resp.ProfileUUID[0]))
return resp.ProfileUUID
}
createAndroidProfile := func(name string, teamID uint, labelNames []string) string {
uid := assertAndroidProfile(name+".json", teamID, labelNames, http.StatusOK, "")
var wantJSON string
if teamID == 0 {
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name)
} else {
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name)
}
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedAndroidProfile{}.ActivityName(), wantJSON, 0)
return uid
}
// create a couple Apple profiles for no-team and team
noTeamAppleProfUUID := createAppleProfile("apple-global-profile", "test-global-ident", 0, nil)
teamAppleProfUUID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID, nil)
// create a couple Windows profiles for no-team and team
noTeamWinProfUUID := createWindowsProfile("win-global-profile", 0, nil)
teamWinProfUUID := createWindowsProfile("win-team-profile", testTeam.ID, nil)
// Create a couple Android profiles for no-team and team
noTeamAndroidProfUUID := createAndroidProfile("android-global-profile", 0, nil)
teamAndroidProfUUID := createAndroidProfile("android-team-profile", testTeam.ID, nil)
// Windows profile name conflicts with Apple's for no team
assertWindowsProfile("apple-global-profile.xml", "./Test", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for team 1
assertWindowsProfile("apple-global-profile.xml", "./Test", testTeam.ID, nil, http.StatusOK, "")
// Apple profile name conflicts with Windows' for no team
assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for team 1
assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, nil, http.StatusOK, "")
// Windows profile name conflicts with Apple's for team 1
assertWindowsProfile("apple-team-profile.xml", "./Test", testTeam.ID, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for no-team
assertWindowsProfile("apple-team-profile.xml", "./Test", 0, nil, http.StatusOK, "")
// Apple profile name conflicts with Windows' for team 1
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// but no conflict for no-team
assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "")
// Android profile name conflicts with Apple's for no team
assertAndroidProfile("apple-global-profile.json", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// add some macOS declarations
createAppleDeclaration("apple-declaration", "test-declaration-ident", 0, nil)
// identifier must be unique, it conflicts with existing declaration
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", 0, nil, http.StatusConflict, "test-declaration-ident already exists")
// name is pulled from filename, it conflicts with existing declaration
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// uniqueness is checked only within team, so it's fine to have the same name and identifier in different teams
assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", testTeam.ID, nil, http.StatusOK, "")
// name is pulled from filename, it conflicts with existing macOS config profile
assertAppleDeclaration("apple-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// name is pulled from filename, it conflicts with existing macOS config profile
assertAppleDeclaration("win-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// name is pulled from filename, it conflicts with existing Android config profile
assertAppleDeclaration("android-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// windows profile name conflicts with existing declaration
assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// macOS profile name conflicts with existing declaration
assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// Android profile name conflicts with existing declaration
assertAndroidProfile("apple-declaration.json", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg)
// not an xml nor mobileconfig file
assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAppleDeclaration("foo.txt", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
assertAndroidProfile("foo.txt", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
// Windows-reserved LocURI
assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest,
syncml.DiskEncryptionProfileRestrictionErrMsg)
assertWindowsProfile("updates.xml", syncml.FleetOSUpdateTargetLocURI, testTeam.ID, nil, http.StatusBadRequest,
"Couldn't add. Custom configuration profiles can't include Windows updates settings.")
// Fleet-reserved profiles
for name := range servermdm.FleetReservedProfileNames() {
assertAppleProfile(name+".mobileconfig", name, name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %s is not allowed`, name))
assertAppleDeclaration(name+".json", name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %q is not allowed`, name))
assertWindowsProfile(name+".xml", "./Test", 0, nil, http.StatusBadRequest, fmt.Sprintf(`Couldn't add. Profile name %q is not allowed.`, name))
}
// profiles with non-existent labels
assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "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")
assertAndroidProfile("android-with-labels.json", 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")
assertAndroidProfile("android-profile-with-labels.json", 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", "labels_include_any", or "labels" can be included.`)
assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "-bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
// profiles with valid labels
uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"!foo"}, http.StatusOK, "")
uuidAppleWithInclAnyLabel := assertAppleProfile("apple-profile-with-incl-any-labels.mobileconfig", "apple-profile-with-incl-any-labels", "ident-with-incl-any-labels", 0, []string{"~foo", "~bar"}, http.StatusOK, "")
uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"})
uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"-foo", "-bar"}, http.StatusOK, "")
uuidAppleDDMTeamWithLabel := createAppleDeclaration("apple-team-decl-with-labels", "ident-team-decl-with-labels", testTeam.ID, []string{"-foo"})
uuidWindowsTeamWithLabel := assertWindowsProfile("win-team-profile-with-labels.xml", "./Test", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "")
uuidWindowsTeamWithInclAnyLabel := assertWindowsProfile("win-team-profile-with-incl-any-labels.xml", "./Test", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "")
uuidAndroidWithLabel := assertAndroidProfile("android-profile-with-labels.json", 0, []string{"-foo", "-bar"}, http.StatusOK, "")
uuidAndroidTeamWithLabel := assertAndroidProfile("android-team-profile-with-labels.json", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "")
uuidAndroidTeamWithInclAnyLabel := assertAndroidProfile("android-team-profile-with-incl-any-labels.json", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "")
// Windows invalid content
body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't add. The file should include valid XML:")
// Apple invalid mobileconfig content
body, headers = generateNewProfileMultipartRequest(t,
"apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles can't be signed. Fleet wil sign the profile for you.")
// Apple/Android invalid json declaration
body, headers = generateNewProfileMultipartRequest(t,
"apple.json", []byte("{"), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Couldn't add. The file should include valid JSON:")
// Apple/Android cannot determine which
body, headers = generateNewProfileMultipartRequest(t,
"apple_or_android.json", []byte(`{"lower_key": true,"UpperKey": false}`), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Keys in declaration (DDM) profile must contain only letters and start with a uppercase letter. Keys in Android profile must contain only letters and start with a lowercase letter.")
// Android invalid keys
for key, expectedErr := range fleet.AndroidForbiddenJSONKeys {
body, headers = generateNewProfileMultipartRequest(t,
"android.json", []byte(fmt.Sprintf(`{"%s": true}`, key)), s.token, nil)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, expectedErr)
}
// get the existing profiles work
expectedProfiles := []fleet.MDMConfigProfilePayload{
{ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil, Scope: string(fleet.PayloadScopeSystem)},
{ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID, Scope: string(fleet.PayloadScopeSystem)},
{ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
{ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID},
{ProfileUUID: noTeamAndroidProfUUID, Platform: "android", Name: "android-global-profile", TeamID: nil},
{ProfileUUID: teamAndroidProfUUID, Platform: "android", Name: "android-team-profile", TeamID: &testTeam.ID},
{
ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAppleWithLabel, Platform: "darwin", Name: "apple-profile-with-labels", Identifier: "ident-with-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAppleWithInclAnyLabel, Platform: "darwin", Name: "apple-profile-with-incl-any-labels", Identifier: "ident-with-incl-any-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem),
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAppleDDMTeamWithLabel, Platform: "darwin", Name: "apple-team-decl-with-labels", Identifier: "ident-team-decl-with-labels", TeamID: &testTeam.ID,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidWindowsTeamWithLabel, Platform: "windows", Name: "win-team-profile-with-labels", TeamID: &testTeam.ID,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidWindowsTeamWithInclAnyLabel, Platform: "windows", Name: "win-team-profile-with-incl-any-labels", TeamID: &testTeam.ID,
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAndroidWithLabel, Platform: "android", Name: "android-profile-with-labels", TeamID: nil,
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAndroidTeamWithLabel, Platform: "android", Name: "android-team-profile-with-labels", TeamID: &testTeam.ID,
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
{
ProfileUUID: uuidAndroidTeamWithInclAnyLabel, Platform: "android", Name: "android-team-profile-with-incl-any-labels", TeamID: &testTeam.ID,
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: labelBar.ID, LabelName: labelBar.Name},
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
},
},
}
for _, prof := range expectedProfiles {
var getResp getMDMConfigProfileResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, &getResp)
require.NotZero(t, getResp.CreatedAt)
require.NotZero(t, getResp.UploadedAt)
if getResp.Platform == "darwin" {
require.Len(t, getResp.Checksum, 16)
} else {
require.Empty(t, getResp.Checksum)
}
getResp.CreatedAt, getResp.UploadedAt = time.Time{}, time.Time{}
getResp.Checksum = nil
// sort the labels by name
sort.Slice(getResp.LabelsIncludeAll, func(i, j int) bool {
return getResp.LabelsIncludeAll[i].LabelName < getResp.LabelsIncludeAll[j].LabelName
})
sort.Slice(getResp.LabelsExcludeAny, func(i, j int) bool {
return getResp.LabelsExcludeAny[i].LabelName < getResp.LabelsExcludeAny[j].LabelName
})
sort.Slice(getResp.LabelsIncludeAny, func(i, j int) bool {
return getResp.LabelsIncludeAny[i].LabelName < getResp.LabelsIncludeAny[j].LabelName
})
require.Equal(t, prof, *getResp.MDMConfigProfilePayload)
resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media")
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleProfileUUIDPrefix) { //nolint:gocritic // ignore ifElseChain
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAndroidProfileUUIDPrefix) {
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
} else {
require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream")
}
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
}
var getResp getMDMConfigProfileResponse
// get an unknown Apple profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media")
// get an unknown Apple declaration
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
// get an unknown Windows profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media")
// get an unknown Android profile
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &getResp)
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
var deleteResp deleteMDMConfigProfileResponse
// delete existing Apple profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp)
// turn off apple MDM
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.EnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp)
// delete non-existing Apple profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp)
// delete existing Apple declaration
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(),
`{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null}`,
0,
)
// delete non-existing Apple declaration
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
// delete existing Windows profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamWinProfUUID), nil, http.StatusOK, &deleteResp)
// Now disabling windows MDM
filler := struct{}{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_enabled_and_configured": false}}`), http.StatusOK, &filler)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamWinProfUUID), nil, http.StatusOK, &deleteResp)
// delete non-existing Windows profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &deleteResp)
// delete existing Android profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAndroidProfUUID), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedAndroidProfile{}.ActivityName(),
`{"profile_name": "android-global-profile", "team_id": null, "team_name": null}`,
0,
)
// turn off Android MDM
appCfg, err = s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAndroidProfUUID), nil, http.StatusOK, &deleteResp)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeDeletedAndroidProfile{}.ActivityName(),
fmt.Sprintf(`{"profile_name": "android-team-profile", "team_id": %d, "team_name": %q}`, testTeam.ID, testTeam.Name),
0,
)
// delete non-existing Android profiles
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
// turn back on apple MDM
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, "UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.enabled_and_configured', true) ")
return err
})
// trying to create/delete profiles managed by Fleet fails
for p := range mobileconfig.FleetPayloadIdentifiers() {
assertAppleProfile("foo.mobileconfig", p, p, 0, nil, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p))
// create it directly in the DB to test deletion
uid := "a" + uuid.NewString()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
mc := mcBytesForTest(p, p, uuid.New().String())
_, err := q.ExecContext(ctx,
"INSERT INTO mdm_apple_configuration_profiles (profile_uuid, identifier, name, mobileconfig, checksum, team_id, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP())",
uid, p, p, mc, "1234", 0)
return err
})
var deleteResp deleteMDMConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uid), nil, http.StatusBadRequest, &deleteResp)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
"DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?",
uid)
return err
})
}
// TODO: Add tests for create/delete forbidden declaration types?
// make fleet add a FileVault profile
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
// try to delete the profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profile.ProfileUUID), nil, http.StatusBadRequest, &deleteResp)
// make fleet add a Windows OS Updates profile
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }
}`), http.StatusOK, &acResp)
profUUID := checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1)})
// try to delete the profile
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp)
// TODO: Add tests for OS updates declaration when implemented.
}
func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() {
t := s.T()
ctx := context.Background()
// create some teams
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
require.NoError(t, err)
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
require.NoError(t, err)
tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
require.NoError(t, err)
// create 5 profiles for no team and team 1, names are A, B, C ... for global and
// tA, tB, tC ... for team 1. Alternate macOS and Windows profiles.
for i := 0; i < 5; i++ {
name := string('A' + byte(i))
if i%2 == 0 {
prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil)
require.NoError(t, err)
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof, nil)
require.NoError(t, err)
tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil)
require.NoError(t, err)
tprof.TeamID = &tm1.ID
_, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil)
require.NoError(t, err)
} else {
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(``)}, nil)
require.NoError(t, err)
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(``)}, nil)
require.NoError(t, err)
}
}
lblFoo, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "foo", Query: "select 1"})
require.NoError(t, err)
lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"})
require.NoError(t, err)
lblBaz, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "baz", Query: "select 1"})
require.NoError(t, err)
// create a couple profiles (Win and mac) for team 2, and none for team 3
tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil)
require.NoError(t, err)
tprof.TeamID = &tm2.ID
// make tm2ProfF a "exclude-any" label-based profile
tprof.LabelsExcludeAny = []fleet.ConfigurationProfileLabel{
{LabelID: lblFoo.ID, LabelName: lblFoo.Name},
{LabelID: lblBar.ID, LabelName: lblBar.Name},
}
tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil)
require.NoError(t, err)
// checksum is not returned by New..., so compute it manually
checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test
tm2ProfF.Checksum = checkSum[:]
// make tm2ProfG a "include-all" label-based profile
tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
Name: "tG",
TeamID: &tm2.ID,
SyncML: []byte(``),
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: lblFoo.ID, LabelName: lblFoo.Name},
{LabelID: lblBar.ID, LabelName: lblBar.Name},
},
}, nil)
require.NoError(t, err)
// make tm2ProfH a "include-any" label-based profile
tm2ProfH, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
Name: "tH",
TeamID: &tm2.ID,
SyncML: []byte(``),
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
},
}, nil)
require.NoError(t, err)
// break lblFoo by deleting it
require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}))
// test that all fields are correctly returned with team 2
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID))
require.Len(t, listResp.Profiles, 3)
require.NotZero(t, listResp.Profiles[0].CreatedAt)
require.NotZero(t, listResp.Profiles[0].UploadedAt)
require.NotZero(t, listResp.Profiles[1].CreatedAt)
require.NotZero(t, listResp.Profiles[1].UploadedAt)
listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{}
listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{}
listResp.Profiles[2].CreatedAt, listResp.Profiles[2].UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfF.ProfileUUID,
TeamID: tm2ProfF.TeamID,
Name: tm2ProfF.Name,
Platform: "darwin",
Identifier: tm2ProfF.Identifier,
Checksum: tm2ProfF.Checksum,
Scope: string(fleet.PayloadScopeSystem),
// labels are ordered by name
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, listResp.Profiles[0])
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfG.ProfileUUID,
TeamID: tm2ProfG.TeamID,
Name: tm2ProfG.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, listResp.Profiles[1])
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfH.ProfileUUID,
TeamID: tm2ProfH.TeamID,
Name: tm2ProfH.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
},
}, listResp.Profiles[2])
// get the specific include-all label-based profile returns the information
var getProfResp getMDMConfigProfileResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfG.ProfileUUID, nil, http.StatusOK, &getProfResp)
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfG.ProfileUUID,
TeamID: tm2ProfG.TeamID,
Name: tm2ProfG.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, getProfResp.MDMConfigProfilePayload)
// get the specific exclude-any label-based profile returns the information
getProfResp = getMDMConfigProfileResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfF.ProfileUUID, nil, http.StatusOK, &getProfResp)
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfF.ProfileUUID,
TeamID: tm2ProfF.TeamID,
Name: tm2ProfF.Name,
Platform: "darwin",
Identifier: tm2ProfF.Identifier,
Checksum: tm2ProfF.Checksum,
Scope: string(fleet.PayloadScopeSystem),
// labels are ordered by name
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
},
}, getProfResp.MDMConfigProfilePayload)
// get the specific include-any label-based profile returns the information
getProfResp = getMDMConfigProfileResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfH.ProfileUUID, nil, http.StatusOK, &getProfResp)
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
require.Equal(t, &fleet.MDMConfigProfilePayload{
ProfileUUID: tm2ProfH.ProfileUUID,
TeamID: tm2ProfH.TeamID,
Name: tm2ProfH.Name,
Platform: "windows",
// labels are ordered by name
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
{LabelID: lblBar.ID, LabelName: lblBar.Name},
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
},
}, getProfResp.MDMConfigProfilePayload)
// list for a non-existing team returns 404
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999")
cases := []struct {
queries []string // alternate query name and value
teamID *uint
wantNames []string
wantMeta *fleet.PaginationMetadata
}{
{
wantNames: []string{"A", "B", "C", "D", "E"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2"},
wantNames: []string{"A", "B"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2", "page", "1"},
wantNames: []string{"C", "D"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
},
{
queries: []string{"per_page", "2", "page", "2"},
wantNames: []string{"E"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3"},
teamID: &tm1.ID,
wantNames: []string{"tA", "tB", "tC"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
},
{
queries: []string{"per_page", "3", "page", "1"},
teamID: &tm1.ID,
wantNames: []string{"tD", "tE"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3", "page", "2"},
teamID: &tm1.ID,
wantNames: nil,
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
},
{
queries: []string{"per_page", "3"},
teamID: &tm2.ID,
wantNames: []string{"tF", "tG", "tH"},
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
{
queries: []string{"per_page", "2"},
teamID: &tm3.ID,
wantNames: nil,
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
},
}
for _, c := range cases {
t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) {
var listResp listMDMConfigProfilesResponse
queryArgs := c.queries
if c.teamID != nil {
queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID))
}
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, queryArgs...)
require.Equal(t, len(c.wantNames), len(listResp.Profiles))
require.Equal(t, c.wantMeta, listResp.Meta)
var gotNames []string
if len(listResp.Profiles) > 0 {
gotNames = make([]string, len(listResp.Profiles))
for i, p := range listResp.Profiles {
gotNames[i] = p.Name
if p.Name == "tG" {
require.Len(t, p.LabelsIncludeAll, 2)
} else {
require.Nil(t, p.LabelsIncludeAll)
}
if p.Name == "tF" {
require.Len(t, p.LabelsExcludeAny, 2)
} else {
require.Nil(t, p.LabelsExcludeAny)
}
if c.teamID == nil {
// we set it to 0 for global
require.NotNil(t, p.TeamID)
require.Zero(t, *p.TeamID)
} else {
require.NotNil(t, p.TeamID)
require.Equal(t, *c.teamID, *p.TeamID)
}
require.NotEmpty(t, p.Platform)
}
}
require.Equal(t, c.wantNames, gotNames)
})
}
}
func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
globalProfiles := []string{
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
}
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
teamProfiles := []string{
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
}
// create a non-Windows host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 1,
OsqueryHostID: ptr.String("non-windows-host"),
NodeKey: ptr.String("non-windows-host"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.non.windows", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// create a Windows host that's not enrolled into MDM
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
ID: 2,
OsqueryHostID: ptr.String("not-mdm-enrolled"),
NodeKey: ptr.String("not-mdm-enrolled"),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
Platform: "windows",
})
require.NoError(t, err)
verifyHostProfileStatus := func(cmds []fleet.ProtoCmdOperation, wantStatus string) {
for _, cmd := range cmds {
var gotProfile struct {
Status string `db:"status"`
Retries int `db:"retries"`
}
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `
SELECT COALESCE(status, 'pending') as status, retries
FROM host_mdm_windows_profiles
WHERE command_uuid = ?`
return sqlx.GetContext(context.Background(), q, &gotProfile, stmt, cmd.Cmd.CmdID.Value)
})
wantDeliveryStatus := fleet.WindowsResponseToDeliveryStatus(wantStatus)
if gotProfile.Retries <= servermdm.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.EqualValues(t, wantStatus, *p.Status, "profile", p.Name)
require.Equal(t, "windows", p.Platform)
// Fleet reserved profiles (e.g., OS updates) should be screened from the host details response
require.NotContains(t, servermdm.ListFleetReservedWindowsProfileNames(), p.Name)
}
require.ElementsMatch(t, wantProfs, gotProfs)
}
checkHostsFilteredByOSSettingsStatus := func(t *testing.T, wantHosts []string, wantStatus fleet.MDMDeliveryStatus, teamID *uint, labels ...*fleet.Label) {
var teamFilter string
if teamID != nil {
teamFilter = fmt.Sprintf("&team_id=%d", *teamID)
}
var gotHostsResp listHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
require.NotNil(t, gotHostsResp.Hosts)
var gotHosts []string
for _, h := range gotHostsResp.Hosts {
gotHosts = append(gotHosts, h.Hostname)
}
require.ElementsMatch(t, wantHosts, gotHosts)
var countHostsResp countHostsResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
require.Equal(t, len(wantHosts), countHostsResp.Count)
for _, l := range labels {
gotHostsResp = listHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/labels/%d/hosts?os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
require.NotNil(t, gotHostsResp.Hosts)
gotHosts = []string{}
for _, h := range gotHostsResp.Hosts {
gotHosts = append(gotHosts, h.Hostname)
}
require.ElementsMatch(t, wantHosts, gotHosts, "label", l.Name)
countHostsResp = countHostsResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?label_id=%d&os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
}
}
getProfileUUID := func(t *testing.T, profName string, teamID *uint) string {
var profUUID string
mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
var globalOrTeamID uint
if teamID != nil {
globalOrTeamID = *teamID
}
return sqlx.GetContext(ctx, tx, &profUUID, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, profName)
})
require.NotNil(t, profUUID)
return profUUID
}
checkHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, wantStatus fleet.MDMDeliveryStatus) {
var gotStatus fleet.MDMDeliveryStatus
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_uuid = ?`
err := sqlx.GetContext(context.Background(), q, &gotStatus, stmt, hostUUID, profUUID)
return err
})
require.Equal(t, wantStatus, gotStatus)
}
// Create a host and then enroll to MDM.
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// trigger a profile sync
verifyProfiles(mdmDevice, 3, false)
checkHostsProfilesMatch(host, globalProfiles)
checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
// can't resend windows configuration profiles as admin or from device endpoint while verifying
res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, globalProfiles[0]), nil, http.StatusConflict)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent")
deviceToken := "windows-device-token"
createDeviceTokenForHost(t, s.ds, host.ID, deviceToken)
res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", deviceToken, globalProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t 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, "", false))
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)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID}))
require.NoError(t, err)
// trigger a profile sync, device gets the team profile
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)
// 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)
// Resend the failed profile. Should succeed
s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
Pending: 1,
}, nil)
// Try resending, should fail since it's pending
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent")
// Trigger a profile sync, device gets the resent profile
verifyProfiles(mdmDevice, 1, false)
// update to verifying - should not allow resending
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'verifying' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
return err
})
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusConflict)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent")
// trigger a profile sync, device doesn't get the profile since resend was not allowed
verifyProfiles(mdmDevice, 0, false)
// Update to verified, resending allowed again
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE host_mdm_windows_profiles SET status = 'verified' WHERE profile_uuid = ?`
_, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
return err
})
s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
// Trigger a profile sync, device gets the resent profile
verifyProfiles(mdmDevice, 1, false)
// add a macOS profile to the team
mcUUID := "a" + uuid.NewString()
prof := mcBytesForTest("name-"+mcUUID, "identifier-"+mcUUID, mcUUID)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
_, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, test.MakeTestBytes())
return err
})
// trigger a profile sync, device doesn't get the macOS profile
verifyProfiles(mdmDevice, 0, false)
// can't resend a macOS profile to a Windows host
res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Profile is not compatible with host platform")
}
func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
t := s.T()
// create a team through the service so it initializes the agent ops
teamName := t.Name() + "team1"
team := &fleet.Team{
Name: teamName,
Description: "desc team1",
}
var createTeamResp teamResponse
s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
require.NotZero(t, createTeamResp.Team.ID)
team = createTeamResp.Team
rawTeamSpec := func(mdmValue string) json.RawMessage {
return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue))
}
// set the windows custom settings fields
var applyResp applyTeamSpecsResponse
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels": ["baz"]},
{"path": "bar", "labels_exclude_any": ["x", "y"]}
]
}
}
`), http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// check that they are returned by a GET /config
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.ElementsMatch(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"baz"}},
{Path: "bar", LabelsExcludeAny: []string{"x", "y"}},
}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// patch without specifying the windows custom settings fields and an unrelated
// field, should not remove them
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
// check that they are returned by a GET /config
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.ElementsMatch(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"baz"}},
{Path: "bar", LabelsExcludeAny: []string{"x", "y"}},
}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// patch with explicitly empty windows custom settings fields, would remove
// them but this is a dry-run
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{ "windows_settings": { "custom_settings": null } }
`), http.StatusOK, &applyResp, "dry_run", "true")
assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.ElementsMatch(t, []fleet.MDMProfileSpec{
{Path: "foo", LabelsIncludeAll: []string{"baz"}},
{Path: "bar", LabelsExcludeAny: []string{"x", "y"}},
}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// patch with explicitly empty windows custom settings fields, removes them
applyResp = applyTeamSpecsResponse{}
s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{ "windows_settings": { "custom_settings": null } }
`), http.StatusOK, &applyResp)
require.Len(t, applyResp.TeamIDsByName, 1)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
// apply with invalid mix of labels fails
res := s.Do("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
{
"windows_settings": {
"custom_settings": [
{"path": "foo", "labels": ["a"], "labels_include_all": ["b"]}
]
}
}
`), http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
t := s.T()
ctx := context.Background()
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
bigString := strings.Repeat("a", 1024*1024+1)
// Profile is too big
resp := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{{Contents: []byte(bigString)}}},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB")
// invalid profile (bad mobileconfig)
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{
Name: "Bad mobileconfig", Contents: []byte(`
PayloadContent
`),
},
}}, http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: new MDMAppleConfigProfile: plist: error parsing XML property list: XML syntax error")
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent)
// Nothing changed, so no activity items
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
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", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate PayloadDisplayName
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved macOS identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: p, Contents: mobileconfigForTest(p, p)},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
}
// payloads with reserved types
for p := range mobileconfig.FleetPayloadTypes() {
if p == mobileconfig.FleetCustomSettingsPayloadType {
// FileVault options in the custom settings payload are checked in file_vault_options_test.go
continue
}
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
switch p {
case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType:
assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg)
default:
assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
}
}
// payloads with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
// profiles with forbidden declaration types
for dt := range fleet.ForbiddenDeclTypes {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTestWithType("D1", dt)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt)
}
// and one more for the software update declaration
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.")
// invalid JSON
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: []byte(`{"foo":}`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "N4 is not a valid macOS, Windows, or Android configuration profile")
// profiles with reserved Windows location URIs
// bitlocker
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: syncml.FleetBitLockerTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg)
// os updates
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: syncml.FleetOSUpdateTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))},
{Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
// invalid windows tag
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: []byte(``)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
// invalid xml
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N3", Contents: []byte(`foo`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
// successfully apply windows and macOS a profiles for the team, but it's a dry run
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
// successfully apply for a team and verify activities
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
{Name: "N4", Contents: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, 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", "labels_include_any" or "labels" can be included.`)
// successful batch-set
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name, lbl2.Name}},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}},
{Name: "N4", Contents: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}},
}}, http.StatusNoContent)
// confirm expected results
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, 3)
require.Equal(t, "N1", listResp.Profiles[0].Name)
require.Equal(t, "N2", listResp.Profiles[1].Name)
require.Equal(t, "N4", listResp.Profiles[2].Name)
require.Equal(t, listResp.Profiles[0].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[0].LabelsExcludeAny)
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
})
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[2].LabelsIncludeAll)
// successful batch-set that updates some labels
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}},
{Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}},
}}, http.StatusNoContent)
listResp = listMDMConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, 2)
require.Equal(t, "N1", listResp.Profiles[0].Name)
require.Equal(t, "N2", listResp.Profiles[1].Name)
require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
{LabelID: lbl3.ID, LabelName: lbl3.Name},
})
require.Nil(t, listResp.Profiles[0].LabelsIncludeAll)
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
// names cannot be duplicated across platforms
declBytes := json.RawMessage(`{
"Type": "com.apple.configuration.decl.foo",
"Identifier": "com.fleet.config.foo",
"Payload": {
"ServiceType": "com.apple.bash",
"DataAssetReference": "com.fleet.asset.bash"
}}`)
mcBytes := mobileconfigForTest("N1", "I1")
winBytes := syncMLForTest("./Foo/Bar")
for _, p := range []struct {
payload []fleet.MDMProfileBatchPayload
expectErr string
}{
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
} {
// team profiles
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
// no team profiles
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
}
}
// This tests the new public API endpoint for batch modifying MDM profiles
func (s *integrationMDMTestSuite) TestBatchModifyMDMProfiles() {
t := s.T()
ctx := context.Background()
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
bigString := strings.Repeat("a", 1024*1024+1)
// Profile is too big
resp := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{{Profile: []byte(bigString)}}},
http.StatusUnprocessableEntity)
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB")
// apply an empty set to no-team
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil}, http.StatusNoContent)
// Nothing changed, so no activity items
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate PayloadDisplayName
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N2", Profile: mobileconfigForTest("N1", "I2")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved macOS identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: p, Profile: mobileconfigForTest(p, p)},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
}
// payloads with reserved types
for p := range mobileconfig.FleetPayloadTypes() {
if p == mobileconfig.FleetCustomSettingsPayloadType {
// FileVault options in the custom settings payload are checked in file_vault_options_test.go
continue
}
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
switch p {
case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType:
assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg)
default:
assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
}
}
// payloads with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
// profiles with forbidden declaration types
for dt := range fleet.ForbiddenDeclTypes {
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTestWithType("D1", dt)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt)
}
// and one more for the software update declaration
res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.")
// invalid JSON
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: []byte(`{"foo":}`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "N4 is not a valid macOS, Windows, or Android configuration profile.")
// profiles with reserved Windows location URIs
// bitlocker
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: syncml.FleetBitLockerTargetLocURI, Profile: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg)
// os updates
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: syncml.FleetOSUpdateTargetLocURI, Profile: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))},
{DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
// invalid windows tag
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N3", Profile: []byte(``)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
// invalid xml
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N3", Profile: []byte(`foo`)},
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
// successfully apply windows and macOS a profiles for the team, but it's a dry run
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
// successfully apply for a team and verify activities
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "NotRelevant", Profile: mobileconfigForTest("N1", "I1")}, // Check that we don't care about displayname for mobileconfig profiles
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")},
{DisplayName: "N4", Profile: declarationForTest("D1")},
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
// batch-apply profiles with labels
lbl1, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L1", Query: "select 1;"})
require.NoError(t, err)
lbl2, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L2", Query: "select 1;"})
require.NoError(t, err)
lbl3, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L3", Query: "select 1;"})
require.NoError(t, err)
// attempt with an invalid label name
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name, "no-such-label"}},
}}, http.StatusBadRequest)
msg := extractServerErrorText(res.Body)
require.Contains(t, msg, "some or all the labels provided don't exist")
// mix of labels fields
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name}, LabelsExcludeAny: []string{lbl2.Name}},
}}, http.StatusUnprocessableEntity)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
// successful batch-set
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAny: []string{lbl1.Name, lbl2.Name}},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}},
{DisplayName: "N4", Profile: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}},
}}, http.StatusNoContent)
// confirm expected results
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, 3)
require.Equal(t, "N1", listResp.Profiles[0].Name)
require.Equal(t, "N2", listResp.Profiles[1].Name)
require.Equal(t, "N4", listResp.Profiles[2].Name)
require.Equal(t, listResp.Profiles[0].LabelsIncludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[0].LabelsExcludeAny)
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
})
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[2].LabelsIncludeAll)
// successful batch-set that updates some labels
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}},
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}},
}}, http.StatusNoContent)
listResp = listMDMConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, 2)
require.Equal(t, "N1", listResp.Profiles[0].Name)
require.Equal(t, "N2", listResp.Profiles[1].Name)
require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
{LabelID: lbl1.ID, LabelName: lbl1.Name},
{LabelID: lbl3.ID, LabelName: lbl3.Name},
})
require.Nil(t, listResp.Profiles[0].LabelsIncludeAll)
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
{LabelID: lbl2.ID, LabelName: lbl2.Name},
})
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
// names cannot be duplicated across platforms
declBytes := json.RawMessage(`{
"Type": "com.apple.configuration.decl.foo",
"Identifier": "com.fleet.config.foo",
"Payload": {
"ServiceType": "com.apple.bash",
"DataAssetReference": "com.fleet.asset.bash"
}}`)
mcBytes := mobileconfigForTest("N1", "I1")
winBytes := syncMLForTest("./Foo/Bar")
for _, p := range []struct {
payload []fleet.BatchModifyMDMConfigProfilePayload
expectErr string
}{
{
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: declBytes}, {DisplayName: "N1", Profile: winBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
{
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: declBytes}},
expectErr: "More than one configuration profile have the same name 'N1'",
},
} {
// team profiles
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload},
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
// no team profiles
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, p.expectErr)
}
// Get the current list of configuration profiles
var currentProfiles listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, ¤tProfiles)
require.Greater(t, len(currentProfiles.Profiles), 0)
// Now we disable all three MDM's
appCfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
appCfg.MDM.EnabledAndConfigured = false
appCfg.MDM.WindowsEnabledAndConfigured = false
appCfg.MDM.AndroidEnabledAndConfigured = false
err = s.ds.SaveAppConfig(ctx, appCfg)
require.NoError(t, err)
// Now do a batch with profiles in it, to see it fails trying to add.
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
{DisplayName: "NEW", Profile: mobileconfigForTest("NEW", "INEW")},
}}, http.StatusUnprocessableEntity)
// Now do a batch without any profiles to ensure we can delete with all MDM's disabled.
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{}}, http.StatusNoContent)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
t := s.T()
ctx := context.Background()
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
// apply an empty set to no-team
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent)
// Nothing changed, so no activity
s.lastActivityOfTypeDoesNotMatch(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
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", fmt.Sprint(tm.ID), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate PayloadDisplayName
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": mobileconfigForTest("N1", "I2"),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
// profiles with reserved macOS identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
p: mobileconfigForTest(p, p),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
}
// payloads with reserved types
for p := range mobileconfig.FleetPayloadTypes() {
if p == mobileconfig.FleetCustomSettingsPayloadType {
// FileVault options in the custom settings payload are checked in file_vault_options_test.go
continue
}
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
switch p {
case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType:
assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg)
default:
assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
}
}
// payloads with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTestWithContent("N1", "I1", p, "random", ""),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
// profiles with reserved Windows location URIs
// bitlocker
res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
syncml.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI)),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg)
// os updates
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
syncml.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI)),
"N3": syncMLForTest("./Foo/Bar"),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
// invalid windows tag
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N3": []byte(``),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
// invalid xml
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N3": []byte(`foo`),
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
// successfully apply windows and macOS a profiles for the team, but it's a dry run
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": syncMLForTest("./Foo/Bar"),
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
// successfully apply for a team and verify activities
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
"N1": mobileconfigForTest("N1", "I1"),
"N2": syncMLForTest("./Foo/Bar"),
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, 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) 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([]syncml.TestCommand{{Verb: "Replace", LocURI: "l1", Data: "d1"}})
var testProfiles []fleet.MDMProfileBatchPayload
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "n1",
Contents: newWinProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set windows and mac profiles doesn't remove the reserved names
newMacProfile := mcBytesForTest("n2", "i2", uuid.NewString())
testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
Name: "n2",
Contents: newMacProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set only mac profiles doesn't remove the reserved names
testProfiles = []fleet.MDMProfileBatchPayload{{
Name: "n2",
Contents: newMacProfile,
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
// create a team
var tmResp teamResponse
s.DoJSON("POST", "/api/v1/fleet/teams", map[string]string{"Name": t.Name()}, http.StatusOK, &tmResp)
// edit team mdm config to turn on disk encryption and os updates
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tmResp.Team.ID), modifyTeamRequest{
TeamPayload: fleet.TeamPayload{
Name: ptr.String(t.Name()),
MDM: &fleet.TeamPayloadMDM{
EnableDiskEncryption: optjson.SetBool(true),
WindowsUpdates: &fleet.WindowsUpdates{
DeadlineDays: optjson.SetInt(4),
GracePeriodDays: optjson.SetInt(1),
},
MacOSUpdates: &fleet.AppleOSUpdateSettings{
Deadline: optjson.SetString("2023-12-31"),
MinimumVersion: optjson.SetString("13.3.8"),
UpdateNewHosts: optjson.SetBool(true),
},
},
},
}, http.StatusOK, &teamResponse{})
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/teams/%d", tmResp.Team.ID), nil, http.StatusOK, &tmResp)
require.True(t, tmResp.Team.Config.MDM.EnableDiskEncryption)
require.Equal(t, 4, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
require.Equal(t, 1, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
require.Equal(t, "2023-12-31", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
require.Equal(t, "13.3.8", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
require.Equal(t, true, tmResp.Team.Config.MDM.MacOSUpdates.UpdateNewHosts.Value)
require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, 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", fmt.Sprint(tmResp.Team.ID))
checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set windows and mac profiles doesn't remove the reserved names
testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
Name: "n2",
Contents: newMacProfile,
})
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tmResp.Team.ID))
checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
// batch set only mac profiles doesn't remove the reserved names
testTeamProfiles = []fleet.MDMProfileBatchPayload{{
Name: "n2",
Contents: newMacProfile,
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tmResp.Team.ID))
checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
}
func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
teamDelete, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TeamDelete"})
require.NoError(t, err)
testProfiles := make(map[string]fleet.MDMAppleConfigProfile)
generateTestProfile := func(name string, identifier string) {
i := identifier
if i == "" {
i = fmt.Sprintf("%s.SomeIdentifier", name)
}
cp := fleet.MDMAppleConfigProfile{
Name: name,
Identifier: i,
}
cp.Mobileconfig = mcBytesForTest(cp.Name, cp.Identifier, fmt.Sprintf("%s.UUID", name))
testProfiles[name] = cp
}
setTestProfileID := func(name string, id uint) {
tp := testProfiles[name]
tp.ProfileID = id
testProfiles[name] = tp
}
generateNewReq := func(name string, teamID *uint) (*bytes.Buffer, map[string]string) {
args := map[string][]string{}
if teamID != nil {
args["team_id"] = []string{fmt.Sprintf("%d", *teamID)}
}
return generateNewProfileMultipartRequest(t, "some_filename", testProfiles[name].Mobileconfig, s.token, args)
}
checkGetResponse := func(resp *http.Response, expected fleet.MDMAppleConfigProfile) {
// check expected headers
require.Contains(t, resp.Header["Content-Type"], "application/x-apple-aspen-config")
require.Contains(t, resp.Header["Content-Disposition"], fmt.Sprintf(`attachment;filename="%s_%s.%s"`, time.Now().Format("2006-01-02"), strings.ReplaceAll(expected.Name, " ", "_"), "mobileconfig"))
// check expected body
var bb bytes.Buffer
_, err = io.Copy(&bb, resp.Body)
require.NoError(t, err)
require.Equal(t, []byte(expected.Mobileconfig), bb.Bytes())
}
checkConfigProfile := func(expected fleet.MDMAppleConfigProfile, actual fleet.MDMAppleConfigProfile) {
require.Equal(t, expected.Name, actual.Name)
require.Equal(t, expected.Identifier, actual.Identifier)
}
host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
s.Do("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{
TeamID: &teamDelete.ID,
HostIDs: []uint{host.ID},
}, http.StatusOK)
// create new profile (no team)
generateTestProfile("TestNoTeam", "")
body, headers := generateNewReq("TestNoTeam", nil)
newResp := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
var newCP fleet.MDMAppleConfigProfile
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestNoTeam", newCP.ProfileID)
// create new profile (with team id)
generateTestProfile("TestWithTeamID", "")
body, headers = generateNewReq("TestWithTeamID", &testTeam.ID)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestWithTeamID", newCP.ProfileID)
// Create a profile that we're going to remove immediately
generateTestProfile("TestImmediateDelete", "")
body, headers = generateNewReq("TestImmediateDelete", &teamDelete.ID)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
newCP = fleet.MDMAppleConfigProfile{}
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestImmediateDelete", newCP.ProfileID)
// check that host_mdm_apple_profiles entry was created
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
require.NotNil(t, hostResp.Host.MDM.Profiles)
require.Len(t, *hostResp.Host.MDM.Profiles, 1)
require.Equal(t, (*hostResp.Host.MDM.Profiles)[0].Name, "TestImmediateDelete")
// now delete the profile before it's sent, we should see the host_mdm_apple_profiles entry go
// away
deletedCP := testProfiles["TestImmediateDelete"]
deletePath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
var deleteResp deleteMDMAppleConfigProfileResponse
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
var listResp listMDMAppleConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: teamDelete.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// confirm no host profiles
hostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp)
require.Nil(t, hostResp.Host.MDM.Profiles)
// list profiles (no team)
expectedCP := testProfiles["TestNoTeam"]
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 1)
respCP := listResp.ConfigProfiles[0]
require.Equal(t, expectedCP.Name, respCP.Name)
checkConfigProfile(expectedCP, *respCP)
require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
require.Empty(t, respCP.TeamID) // zero means no team
// list profiles (team 1)
expectedCP = testProfiles["TestWithTeamID"]
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 1)
respCP = listResp.ConfigProfiles[0]
require.Equal(t, expectedCP.Name, respCP.Name)
checkConfigProfile(expectedCP, *respCP)
require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
require.Equal(t, testTeam.ID, *respCP.TeamID) // team 1
// get profile (no team)
expectedCP = testProfiles["TestNoTeam"]
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
getResp := s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
checkGetResponse(getResp, expectedCP)
// get profile (team 1)
expectedCP = testProfiles["TestWithTeamID"]
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
getResp = s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
checkGetResponse(getResp, expectedCP)
// delete profile (no team)
deletedCP = testProfiles["TestNoTeam"]
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// delete profile (team 1)
deletedCP = testProfiles["TestWithTeamID"]
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
deleteResp = deleteMDMAppleConfigProfileResponse{}
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// fail to create new profile (no team), invalid fleet secret
testProfiles["badSecrets"] = fleet.MDMAppleConfigProfile{
Name: "badSecrets",
Identifier: "badSecrets.One",
Mobileconfig: mobileconfig.Mobileconfig(`
PayloadContent
PayloadDisplayName
badSecrets
PayloadIdentifier
badSecrets.One
PayloadType
Configuration
PayloadUUID
$FLEET_SECRET_INVALID.35E2029E-A0C2-4754-B709-4CAAB1B8D3CB
PayloadVersion
1
`),
}
body, headers = generateNewReq("badSecrets", nil)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusUnprocessableEntity, headers)
errMsg := extractServerErrorText(newResp.Body)
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
// trying to add/delete profiles with identifiers managed by Fleet fails
for p := range mobileconfig.FleetPayloadIdentifiers() {
generateTestProfile("TestNoTeam", p)
body, headers := generateNewReq("TestNoTeam", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
generateTestProfile("TestWithTeamID", p)
body, headers = generateNewReq("TestWithTeamID", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
require.NoError(t, err)
testProfiles["WithContent"] = *cp
body, headers = generateNewReq("WithContent", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
}
// trying to add profiles with identifiers managed by Fleet fails
for p := range mobileconfig.FleetPayloadIdentifiers() {
generateTestProfile("TestNoTeam", p)
body, headers := generateNewReq("TestNoTeam", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
generateTestProfile("TestWithTeamID", p)
body, headers = generateNewReq("TestWithTeamID", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
require.NoError(t, err)
testProfiles["WithContent"] = *cp
body, headers = generateNewReq("WithContent", nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
}
// trying to add profiles with names reserved by Fleet fails
for name := range servermdm.FleetReservedProfileNames() {
cp := &fleet.MDMAppleConfigProfile{
Name: name,
Identifier: "valid.identifier",
Mobileconfig: mcBytesForTest(name, "valid.identifier", "some-uuid"),
}
body, headers := generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
"team_id": {fmt.Sprintf("%d", testTeam.ID)},
})
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent(
"valid outer name",
"valid.outer.identifier",
"valid.inner.identifer",
"some-uuid",
name,
), nil)
require.NoError(t, err)
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
cp.TeamID = &testTeam.ID
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
"team_id": {fmt.Sprintf("%d", testTeam.ID)},
})
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
}
// make fleet add a FileVault profile
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "enable_disk_encryption": true }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
// try to delete the profile
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", profile.ProfileID)
deleteResp = deleteMDMAppleConfigProfileResponse{}
s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp)
}
func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() {
t := s.T()
s.setSkipWorkerJobs(t)
ctx := context.Background()
triggerReconcileProfiles := func() {
s.awaitTriggerProfileSchedule(t)
// this will only mark them as "pending", as the response to confirm
// profile deployment is asynchronous, so we simulate it here by
// updating any "pending" (not NULL) profiles to "verifying"
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
return err
}
return nil
})
}
// run the crons immediately, will create the Fleet-controlled profiles that
// will then be expected to be applied (e.g. com.fleetdm.fleetd.config and
// com.fleetdm.caroot)
// first create the no-team enroll secret (required to create the fleet profiles)
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}},
}, http.StatusOK, &applyResp)
s.awaitTriggerProfileSchedule(t)
// create an Apple and a Windows host
appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// create a few labels
labels := make([]*fleet.Label, 5)
for i := 0; i < len(labels); i++ {
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"})
require.NoError(t, err)
labels[i] = label
}
// simulate reporting label results for those hosts
appleHost.LabelUpdatedAt = time.Now()
windowsHost.LabelUpdatedAt = time.Now()
err := s.ds.UpdateHost(ctx, appleHost)
require.NoError(t, err)
err = s.ds.UpdateHost(ctx, windowsHost)
require.NoError(t, err)
// set an Apple profile and declaration and a Windows profile
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsExcludeAny: []string{labels[0].Name, labels[1].Name}},
{Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsExcludeAny: []string{labels[2].Name, labels[3].Name}},
{Name: "D3", Contents: declarationForTest("D3"), LabelsExcludeAny: []string{labels[4].Name}},
}}, http.StatusNoContent)
// hosts are not members of any label yet, so running the cron applies the labels
s.awaitTriggerProfileSchedule(t)
s.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, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.NoError(t, err)
// it doesn't get installed to the Apple host, as it is broken
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
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, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.NoError(t, err)
err = s.ds.DeleteLabel(ctx, labels[4].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})
require.NoError(t, err)
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
appleHost2: {
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
},
})
}
func (s *integrationMDMTestSuite) TestMDMProfilesIncludeAnyLabels() {
t := s.T()
ctx := context.Background()
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, we'll use the first five for "exclude any" profiles and the remaining for "include any"
labels := make([]*fleet.Label, 10)
for i := 0; i < len(labels); i++ {
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"})
require.NoError(t, err)
labels[i] = label
}
// simulate reporting label results for those hosts
appleHost.LabelUpdatedAt = time.Now()
windowsHost.LabelUpdatedAt = time.Now()
err := s.ds.UpdateHost(ctx, appleHost)
require.NoError(t, err)
err = s.ds.UpdateHost(ctx, windowsHost)
require.NoError(t, err)
// set up some Apple profiles and declarations and Windows profiles
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsIncludeAny: []string{labels[0].Name, labels[1].Name}},
{Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsIncludeAny: []string{labels[2].Name, labels[3].Name}},
{Name: "D3", Contents: declarationForTest("D3"), LabelsIncludeAny: []string{labels[4].Name}},
}}, http.StatusNoContent)
// hosts are not members of any label yet, so running the cron applies no labels
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {},
})
// make hosts members of labels [1], [2], [3] and [4], meaning that each of the "include any"
// labels will now match at least one host
err = s.ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{
{labels[0].ID, appleHost.ID},
{labels[1].ID, appleHost.ID},
{labels[2].ID, appleHost.ID},
{labels[3].ID, appleHost.ID},
{labels[4].ID, appleHost.ID},
{labels[1].ID, windowsHost.ID},
{labels[2].ID, windowsHost.ID},
{labels[3].ID, windowsHost.ID},
{labels[4].ID, windowsHost.ID},
})
require.NoError(t, err)
triggerReconcileProfiles()
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
// remove membership of labels [2] for Windows, and [1] and [4] for Apple, meaning
// that D3 will be removed on Apple, A1 will remain on Apple because the host is still a member
// of [0], and W2 will remain on Windows because the host is still a member of [3]
err = s.ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{
{labels[1].ID, appleHost.ID},
{labels[4].ID, appleHost.ID},
{labels[2].ID, windowsHost.ID},
})
require.NoError(t, err)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
appleHost: {
{Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "D3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
windowsHost: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
},
})
}
func (s *integrationMDMTestSuite) TestOTAProfile() {
t := s.T()
ctx := context.Background()
// Getting profile for non-existent secret it's ok
s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real")
// Create an enroll secret; has some special characters that should be escaped in the profile
globalEnrollSec := "global_enroll+_/sec"
escSec := url.QueryEscape(globalEnrollSec)
s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
},
}, http.StatusOK)
cfg, err := s.ds.AppConfig(ctx)
require.NoError(t, err)
t.Run("gets profile with idp uuid included if boyd cookie is set", func(t *testing.T) {
// Get profile with that enroll secret
j, err := json.Marshal(getOTAProfileRequest{})
require.NoError(t, err)
idpUUID := uuid.New()
resp := s.DoRawWithHeaders("GET", "/api/latest/fleet/enrollment_profiles/ota", j, http.StatusOK, map[string]string{
"Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, idpUUID.String()),
}, "enroll_secret", globalEnrollSec)
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`)
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s&idp_uuid=%s", cfg.ServerSettings.ServerURL, escSec, idpUUID.String()))
require.Contains(t, string(b), cfg.OrgInfo.OrgName)
})
t.Run("does not include idp_uuid in the url if cookie is not set", func(t *testing.T) {
resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", &getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec)
require.NotZero(t, resp.ContentLength)
require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`)
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.Equal(t, resp.ContentLength, int64(len(b)))
require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota")
require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec))
require.NotContains(t, string(b), "idp_uuid=")
require.Contains(t, string(b), cfg.OrgInfo.OrgName)
})
}
// TestAppleDDMSecretVariablesUpload tests uploading DDM profiles with secrets via the /configuration_profiles endpoint
func (s *integrationMDMTestSuite) TestAppleDDMSecretVariablesUpload() {
tmpl := `
{
"Type": "com.apple.configuration.decl%d",
"Identifier": "com.fleet.config%d",
"Payload": {
"ServiceType": "com.apple.bash%d",
"DataAssetReference": "com.fleet.asset.bash"
}
}`
newProfileBytes := func(i int) []byte {
return []byte(fmt.Sprintf(tmpl, i, i, i))
}
getProfileContents := func(profileUUID string) string {
profile, err := s.ds.GetMDMAppleDeclaration(context.Background(), profileUUID)
require.NoError(s.T(), err)
assert.NotNil(s.T(), profile.SecretsUpdatedAt)
return string(profile.RawJSON)
}
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "json", "darwin")
}
func (s *integrationMDMTestSuite) testSecretVariablesUpload(newProfileBytes func(i int) []byte,
getProfileContents func(profileUUID string) string, fileExtension string, platform string,
) {
t := s.T()
const numProfiles = 2
var profiles [][]byte
for i := 0; i < numProfiles; i++ {
profiles = append(profiles, newProfileBytes(i))
}
// Use secrets
myBash := "com.apple.bash0"
profiles[0] = []byte(strings.ReplaceAll(string(profiles[0]), myBash, "$"+fleet.ServerSecretPrefix+"BASH"))
secretProfile := profiles[1]
profiles[1] = []byte("${" + fleet.ServerSecretPrefix + "PROFILE}")
body, headers := generateNewProfileMultipartRequest(
t, "secret-config0."+fileExtension, profiles[0], s.token, nil,
)
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusUnprocessableEntity, headers)
assertBodyContains(t, res, `Secret variable \"$FLEET_SECRET_BASH\" missing`)
// Add secret(s) to server
req := createSecretVariablesRequest{
SecretVariables: []fleet.SecretVariable{
{
Name: "FLEET_SECRET_BASH",
Value: myBash,
},
{
Name: "FLEET_SECRET_PROFILE",
Value: string(secretProfile),
},
},
}
secretResp := createSecretVariablesResponse{}
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
var resp newMDMConfigProfileResponse
err := json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.ProfileUUID)
body, headers = generateNewProfileMultipartRequest(
t, "secret-config1."+fileExtension, profiles[1], s.token, nil,
)
s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp)
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(res.Body).Decode(&resp)
require.NoError(t, err)
assert.NotEmpty(t, resp.ProfileUUID)
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Len(t, listResp.Profiles, numProfiles)
profileUUIDs := make([]string, numProfiles)
for _, p := range listResp.Profiles {
switch p.Name {
case "secret-config0":
assert.Equal(t, platform, p.Platform)
profileUUIDs[0] = p.ProfileUUID
case "secret-config1":
assert.Equal(t, platform, p.Platform)
profileUUIDs[1] = p.ProfileUUID
default:
t.Errorf("unexpected profile %s", p.Name)
}
}
// Check that contents are masking secret values
for i := 0; i < numProfiles; i++ {
assert.Equal(t, string(profiles[i]), getProfileContents(profileUUIDs[i]))
}
// Delete profiles -- make sure there is no issue deleting profiles with secrets
for i := 0; i < numProfiles; i++ {
s.Do("DELETE", "/api/latest/fleet/configuration_profiles/"+profileUUIDs[i], nil, http.StatusOK)
}
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Empty(t, listResp.Profiles)
}
// TestAppleConfigSecretVariablesUpload tests uploading Apple config profiles with secrets via the /configuration_profiles endpoint
func (s *integrationMDMTestSuite) TestAppleConfigSecretVariablesUpload() {
tmpl := `
PayloadDescription
For secret variables
PayloadDisplayName
secret-config%d
PayloadIdentifier
PI%d
PayloadType
Configuration
PayloadUUID
%d
PayloadVersion
1
PayloadContent
Bash
$FLEET_SECRET_BASH
PayloadDisplayName
secret payload
PayloadIdentifier
com.test.secret
PayloadType
com.test.secretd
PayloadUUID
476F5334-D501-4768-9A31-1A18A4E1E808
PayloadVersion
1
`
newProfileBytes := func(i int) []byte {
return []byte(fmt.Sprintf(tmpl, i, i, i))
}
getProfileContents := func(profileUUID string) string {
profile, err := s.ds.GetMDMAppleConfigProfile(context.Background(), profileUUID)
require.NoError(s.T(), err)
assert.NotNil(s.T(), profile.SecretsUpdatedAt)
return string(profile.Mobileconfig)
}
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "mobileconfig", "darwin")
}
// TestWindowsConfigSecretVariablesUpload tests uploading Windows profiles with secrets via the /configuration_profiles endpoint
func (s *integrationMDMTestSuite) TestWindowsConfigSecretVariablesUpload() {
tmpl := `
-
int
./Device/Vendor/MSFT/Policy/Config/System/DisableOneDriveFileSync
$FLEET_SECRET_BASH
`
newProfileBytes := func(i int) []byte {
return []byte(fmt.Sprintf(tmpl, i, i, i))
}
getProfileContents := func(profileUUID string) string {
profile, err := s.ds.GetMDMWindowsConfigProfile(context.Background(), profileUUID)
require.NoError(s.T(), err)
return string(profile.SyncML)
}
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "xml", "windows")
}
func (s *integrationMDMTestSuite) TestAppleProfileDeletion() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
}
wantGlobalProfiles := globalProfiles
wantGlobalProfiles = append(
wantGlobalProfiles,
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
)
// add global profiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// Create a host and then enroll to MDM.
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// Add IdP email to host
mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error {
_, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp@example.com", host.ID,
fleet.DeviceMappingMDMIdpAccounts)
return err
})
// 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)
// Add a profile with a Fleet variable. We are also testing that removal of a profile with a Fleet variable works.
// A unique command is created for each host when this Fleet variable is used.
globalProfilesPlusOne := [][]byte{
globalProfiles[0],
mobileconfigForTest("N2", "$FLEET_VAR_"+string(fleet.FleetVarHostEndUserEmailIDP)),
}
// via the deprecated endpoint, this fails because variables are not supported
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfilesPlusOne},
http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "profile variables are not supported by this deprecated endpoint")
// via the new endpoint, this works
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: globalProfilesPlusOne[0]},
{Name: "N2", Contents: globalProfilesPlusOne[1]},
}}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// Make sure profile was uploaded
profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 4)
// Delete a profile before it is sent to device
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
sendErrorOnRemoveProfile := func(device *mdmtest.TestAppleMDMClient) {
// The host grabs the removal command from Fleet
cmd, err := device.Idle()
require.NoError(t, err)
assert.Equal(t, "RemoveProfile", cmd.Command.RequestType)
// Since profile is not on the device, it returns an error.
errChain := []mdm.ErrorChain{
{
ErrorCode: 89,
ErrorDomain: "FooErrorDomain",
LocalizedDescription: "The profile not found",
},
}
cmd, err = device.Err(cmd.CommandUUID, errChain)
require.NoError(t, err)
assert.Nil(t, cmd)
}
sendErrorOnRemoveProfile(mdmDevice)
// Make sure deleted profile no longer shows up
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 3)
// Add a profile again
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: globalProfilesPlusOne[0]},
{Name: "N2", Contents: globalProfilesPlusOne[1]},
}}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// The host grabs the profile from Fleet
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
assert.Equal(t, "InstallProfile", cmd.Command.RequestType)
// Verify that the Fleet variable was replaced with the IdP email
type Command struct {
Command struct {
Payload []byte
}
}
var p Command
err = plist.Unmarshal(cmd.Raw, &p)
require.NoError(t, err)
assert.NotContains(t, string(p.Command.Payload), "$FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP)
assert.Contains(t, string(p.Command.Payload), "idp@example.com")
// While the host is installing the profile, we delete it.
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// Host acknowledges installing the profile and grabs the remove command
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Equal(t, "RemoveProfile", cmd.Command.RequestType)
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
assert.Nil(t, cmd)
// Add another device
host2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// Add IdP email to host
mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error {
_, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp2@example.com", host2.ID,
fleet.DeviceMappingMDMIdpAccounts)
return err
})
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
installs, removes = checkNextPayloads(t, mdmDevice2, false)
assert.Len(t, installs, 3)
assert.Empty(t, removes)
// Add a profile again
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: globalProfilesPlusOne[0]},
{Name: "N2", Contents: globalProfilesPlusOne[1]},
}}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// Delete a profile before it is sent to both devices
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// The host grabs the removal command from Fleet
sendErrorOnRemoveProfile(mdmDevice)
sendErrorOnRemoveProfile(mdmDevice2)
// Make sure deleted profile no longer shows up on either host
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 3)
profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host2.UUID)
require.NoError(t, err)
assert.Len(t, profiles, 3)
}
func (s *integrationMDMTestSuite) TestBatchResendMDMProfiles() {
t := s.T()
ctx := t.Context()
s.setSkipWorkerJobs(t)
// create a few hosts
host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host3, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// register a couple profiles for Apple and one for Windows
profN1 := mobileconfigForTest("N1", "I1")
profN2 := mobileconfigForTest("N2", "I2")
profN3 := syncMLForTest("./Foo/N3")
declN4 := declarationForTest("N4")
batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "N1", Contents: profN1},
{Name: "N2", Contents: profN2},
{Name: "N3", Contents: profN3},
{Name: "N4", Contents: declN4},
}}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent)
// list the profiles to get the UUIDs
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
}
// get status for non-existing profile
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "ano-such-profile"), nil, http.StatusNotFound)
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "wno-such-profile"), nil, http.StatusNotFound)
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "dno-such-profile"), nil, http.StatusNotFound)
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "zno-such-profile"), nil, http.StatusNotFound)
// get status for existing profiles, all 0 counts
for _, uuid := range []string{profNameToPayload["N1"].ProfileUUID, profNameToPayload["N2"].ProfileUUID, profNameToPayload["N3"].ProfileUUID} {
var statusResp getMDMConfigProfileStatusResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", uuid), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{}, statusResp.MDMConfigProfileStatus)
}
// except for the declaration, which is immediately set as pending on the hosts
var statusResp getMDMConfigProfileStatusResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
// try to batch-resend a non-existing profile
batchReq := batchResendMDMProfileToHostsRequest{ProfileUUID: "zzzz"} // not a known prefix
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound)
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "azzzz"} // unknown Apple profile
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound)
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "wzzzz"} // unknown Windows profile
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound)
// batch-resend with an invalid filter
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryPending)
res := s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest)
msg := extractServerErrorText(res.Body)
require.Contains(t, msg, "Invalid profile_status filter value, only 'failed' is currently supported.")
// batch-resend with an Apple DDM
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N4"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
res = s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest)
msg = extractServerErrorText(res.Body)
require.Contains(t, msg, "Can't resend declaration (DDM) profiles.")
// batch-resend an Apple and a Windows profile, does nothing as it is not delivered yet
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
forceSetWindowsHostProfileStatus(t, s.ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["N3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N1"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N2"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N3"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 1}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
// acknowledge the Apple profiles, failing I2 on both hosts, and fail the Windows one
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
forceSetWindowsHostProfileStatus(t, s.ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["N3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
// batch-resend N2 profile
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N2"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
s.lastActivityOfTypeMatches(
fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(),
fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N2", 2),
0,
)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryFailed},
},
})
// set I2/N2 as verifying
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
// batch-resend N3 profile
batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID}
batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed)
s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
{Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.lastActivityOfTypeMatches(
fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(),
fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N3", 1),
0,
)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N1"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Verifying: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N2"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Verifying: 2}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N3"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 1}, statusResp.MDMConfigProfileStatus)
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp)
require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus)
// trigger profile schedule to get the fleet-controlled profiles
s.awaitTriggerProfileSchedule(t)
// list the profiles to get a fleet-controlled profile UUID
gotProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, host1.UUID)
require.NoError(t, err)
var fleetReservedProfile string
for _, prof := range gotProfs {
// find the fleetd config one
if prof.Identifier == mobileconfig.FleetdConfigPayloadIdentifier {
fleetReservedProfile = prof.ProfileUUID
}
}
require.NotEmpty(t, fleetReservedProfile)
// fleet-reserved profiles are not returned by the API, only custom profiles
s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", fleetReservedProfile), getMDMConfigProfileStatusRequest{}, http.StatusNotFound)
}
func (s *integrationMDMTestSuite) TestDeleteMDMProfileCancelsInstalls() {
t := s.T()
s.setSkipWorkerJobs(t)
// create some Apple, Windows and declaration profiles
profiles := []fleet.MDMProfileBatchPayload{
{
Name: "A1",
Contents: mobileconfigForTest("A1", "A1"),
},
{
Name: "A2",
Contents: mobileconfigForTest("A2", "A2"),
},
{
Name: "D1",
Contents: declarationForTest("D1"),
},
{
Name: "D2",
Contents: declarationForTest("D2"),
},
{
Name: "W1",
Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "W1", Data: "W1"}}),
},
{
Name: "W2",
Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "W2", Data: "W2"}}),
},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent)
// list the profiles to get the UUIDs
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
t.Logf("profile %s: %s", prof.Name, prof.ProfileUUID)
}
// deleting without any affected host is fine
var deleteResp deleteMDMConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A1"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["D1"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["W1"].ProfileUUID), nil, http.StatusOK, &deleteResp)
// create some Apple and Windows hosts
host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
host3, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
host4, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
for i, h := range []*fleet.Host{host1, host2, host3, host4} {
t.Logf("host %d: %s", i+1, h.UUID)
}
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host4: {
{Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// for the declaration, set host1 as NULL and host2 as verified
forceSetAppleHostDeclarationStatus(t, s.ds, host1.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeInstall, "")
forceSetAppleHostDeclarationStatus(t, s.ds, host2.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified)
// delete the declaration, will have removed it for host1 and set to remove pending for host2
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["D2"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// for the Windows profile, set host4 as failed
forceSetWindowsHostProfileStatus(t, s.ds, host4.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["W2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
// delete the Windows profile, will have removed it for both (because there
// is no "Remove profile" for now with Windows)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["W2"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {},
host4: {},
})
// for the Apple profile, set host1 as NULL (pending not queued yet), and leave host2 as actually pending (queued)
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A2"]), fleet.MDMOperationTypeInstall, "")
assertIsCommandActiveForHostAndProfile := func(hostUUID, profileUUID string, wantActive bool) {
var active bool
ctx := t.Context()
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &active, `SELECT neq.active
FROM
nano_enrollment_queue neq
JOIN host_mdm_apple_profiles hmap
ON hmap.command_uuid = neq.command_uuid AND hmap.host_uuid = neq.id
WHERE
hmap.host_uuid = ? AND
hmap.profile_uuid = ?`, hostUUID, profileUUID)
})
if wantActive {
require.True(t, active)
} else {
require.False(t, active)
}
}
assertIsCommandActiveForHostAndProfile(host1.UUID, profNameToPayload["A2"].ProfileUUID, true)
assertIsCommandActiveForHostAndProfile(host2.UUID, profNameToPayload["A2"].ProfileUUID, true)
// delete the profile, will remove the row for host1 and set host2 to pending remove (and will deactivate the associated nano command)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A2"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: "D2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
assertIsCommandActiveForHostAndProfile(host2.UUID, profNameToPayload["A2"].ProfileUUID, false)
// set the remove operations to verifying and reconcile profiles
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A2"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying)
forceSetAppleHostDeclarationStatus(t, s.ds, host2.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{
host3: {},
host4: {},
})
// add new profile A3 and re-add A2, behaves as if a new profile because it has a new uuid
oldA2Contents := profiles[1].Contents
profiles = []fleet.MDMProfileBatchPayload{
{
Name: "A2",
Contents: oldA2Contents,
},
{
Name: "A3",
Contents: mobileconfigForTest("A3", "A3"),
},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent)
// list the profiles to get the UUIDs
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload = make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
t.Logf("new profile %s: %s", prof.Name, prof.ProfileUUID)
}
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
// set A3 as failed on host1, and removed on host2
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed)
forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryFailed)
// delete the profile, will mark host1 as pending remove and will not touch host2 (not installed)
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A3"].ProfileUUID), nil, http.StatusOK, &deleteResp)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryFailed},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying)
s.awaitTriggerProfileSchedule(t)
s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
host1: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
host2: {
{Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryFailed},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
{Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
},
})
}
// those helper functions to force-set a host profile status are copied from the Datastore
// tests, couldn't put them in the test package due to circular dependency with mysql, would
// be nice to find a way to avoid this copy eventually.
func forceSetAppleHostProfileStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMAppleConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
ctx := t.Context()
// empty status string means set to NULL
var actualStatus *fleet.MDMDeliveryStatus
if status != "" {
actualStatus = &status
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_profiles
(profile_identifier, host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid)
VALUES
(?, ?, ?, ?, ?, ?, UNHEX(MD5(?)), ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type)
`,
profile.Identifier, hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.Mobileconfig, profile.ProfileUUID)
return err
})
}
func forceSetWindowsHostProfileStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMWindowsConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
ctx := t.Context()
// empty status string means set to NULL
var actualStatus *fleet.MDMDeliveryStatus
if status != "" {
actualStatus = &status
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_windows_profiles
(host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid)
VALUES
(?, ?, ?, ?, ?, UNHEX(MD5(?)), ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type)
`,
hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.SyncML, profile.ProfileUUID)
return err
})
}
func forceSetAppleHostDeclarationStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMAppleDeclaration, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) {
ctx := t.Context()
// empty status string means set to NULL
var actualStatus *fleet.MDMDeliveryStatus
if status != "" {
actualStatus = &status
}
mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_declarations
(declaration_identifier, host_uuid, status, operation_type, token, declaration_name, declaration_uuid)
VALUES
(?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
operation_type = VALUES(operation_type)
`,
profile.Identifier, hostUUID, actualStatus, operation, test.MakeTestBytes(), profile.Name, profile.DeclarationUUID)
return err
})
}
func (s *integrationMDMTestSuite) TestVerifyUserScopedProfiles() {
t := s.T()
ctx := t.Context()
s.setSkipWorkerJobs(t)
// create a macOS host, will enroll only with device
host, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
// create some profiles, system- and user-scoped
payloadScopeSystem := fleet.PayloadScopeSystem
payloadScopeUser := fleet.PayloadScopeUser
profiles := []fleet.MDMProfileBatchPayload{
{Name: "A1", Contents: scopedMobileconfigForTest("A1", "A1", &payloadScopeSystem)},
{Name: "A2", Contents: scopedMobileconfigForTest("A2", "A2.user", &payloadScopeUser)},
{Name: "A3", Contents: scopedMobileconfigForTest("A3", "A3.user", &payloadScopeUser)},
}
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent)
// ensure we are at least 1s after the profiles uploaded-at timestamp
time.Sleep(time.Second)
// list the profiles to get the UUIDs
var listResp listMDMConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload)
for _, prof := range listResp.Profiles {
if len(prof.Checksum) == 0 {
// not important, but must not be empty or it causes issues when forcing a status
prof.Checksum = []byte("checksum")
}
profNameToPayload[prof.Name] = prof
t.Logf("profile %s: %s", prof.Name, prof.ProfileUUID)
}
type hostProfile struct {
ProfileUUID string `db:"profile_uuid"`
ProfileIdentifier string `db:"profile_identifier"`
ProfileName string `db:"profile_name"`
Status *string `db:"status"`
OperationType *string `db:"operation_type"`
Retries int `db:"retries"`
Scope string `db:"scope"`
}
assertHostProfiles := func(want []hostProfile) {
var got []hostProfile
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// for the purpose of this test, we ignore the Fleet-internal profiles
// (we only care about the custom profiles)
return sqlx.SelectContext(t.Context(), q, &got, `
SELECT profile_uuid, profile_identifier, profile_name, status, operation_type, retries, scope
FROM host_mdm_apple_profiles
WHERE host_uuid = ? AND profile_identifier NOT IN (?, ?)`,
host.UUID, mobileconfig.FleetdConfigPayloadIdentifier, mobileconfig.FleetCARootConfigPayloadIdentifier)
})
require.ElementsMatch(t, want, got)
}
forceProfileUploadeddAtTimestamp := func(ident string, ts time.Time) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `UPDATE mdm_apple_configuration_profiles
SET uploaded_at = ? WHERE identifier = ?`, ts, ident)
return err
})
}
// cron job hasn't run yet, so no profile exist for the host
assertHostProfiles([]hostProfile{})
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// user-scoped profiles show up as status nil (no user-enrollment yet)
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// verify the profiles, only the system one is reported as installed
host.DetailUpdatedAt = time.Now().UTC()
err := s.ds.UpdateHost(ctx, host)
require.NoError(t, err)
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
// user-scoped profiles were left untouched
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// create the user-enrollment
err = device.UserEnroll()
require.NoError(t, err)
// trigger a profile sync
s.awaitTriggerProfileSchedule(t)
// user-scoped profiles have been added as pending (not nil)
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// verify the profiles, A3 is missing but still within the grace period
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
profNameToPayload["A2"].Identifier: {
DisplayName: profNameToPayload["A2"].Name,
Identifier: profNameToPayload["A2"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
// A2 is now verified, A3 is still pending
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: ptr.String(string(fleet.MDMDeliveryPending)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
})
// rewind the uploaded_at timestamp of A3 so it is not in the grace period
forceProfileUploadeddAtTimestamp(profNameToPayload["A3"].Identifier, time.Now().Add(-24*time.Hour))
// report as still missing
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
profNameToPayload["A2"].Identifier: {
DisplayName: profNameToPayload["A2"].Name,
Identifier: profNameToPayload["A2"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
// A3 is now missing and retries
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: nil,
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 1,
Scope: string(fleet.PayloadScopeUser),
},
})
s.awaitTriggerProfileSchedule(t)
// force-set it to Verifying so that by being missing again it goes to failed
// (it doesn't go to failed if it is pending)
forceSetAppleHostProfileStatus(t, s.ds, host.UUID,
test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying)
err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{
profNameToPayload["A1"].Identifier: {
DisplayName: profNameToPayload["A1"].Name,
Identifier: profNameToPayload["A1"].Identifier,
InstallDate: time.Now().UTC(),
},
profNameToPayload["A2"].Identifier: {
DisplayName: profNameToPayload["A2"].Name,
Identifier: profNameToPayload["A2"].Identifier,
InstallDate: time.Now().UTC(),
},
})
require.NoError(t, err)
assertHostProfiles([]hostProfile{
{
ProfileUUID: profNameToPayload["A1"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A1"].Identifier,
ProfileName: profNameToPayload["A1"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeSystem),
},
{
ProfileUUID: profNameToPayload["A2"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A2"].Identifier,
ProfileName: profNameToPayload["A2"].Name,
Status: ptr.String(string(fleet.MDMDeliveryVerified)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 0,
Scope: string(fleet.PayloadScopeUser),
},
{
ProfileUUID: profNameToPayload["A3"].ProfileUUID,
ProfileIdentifier: profNameToPayload["A3"].Identifier,
ProfileName: profNameToPayload["A3"].Name,
Status: ptr.String(string(fleet.MDMDeliveryFailed)),
OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)),
Retries: 1,
Scope: string(fleet.PayloadScopeUser),
},
})
}
func (s *integrationMDMTestSuite) TestMDMAppleProfileScopeChanges() {
t := s.T()
ctx := context.Background()
// add a couple global profiles
payloadScopeSystem := fleet.PayloadScopeSystem
payloadScopeUser := fleet.PayloadScopeUser
globalProfiles := [][]byte{
mobileconfigForTest("G1", "G1"),
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser),
scopedMobileconfigForTest("G4", "G4.user-but-actually-system", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// Create a profile with a scope that is System in the DB but User in the XML. This mimics
// our upgrade behavior from versions prior to 4.71 to 4.71+ when we added support for User
// scoped profiles
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
stmt := `UPDATE mdm_apple_configuration_profiles SET scope=? WHERE identifier=?;`
_, err := q.ExecContext(context.Background(), stmt, fleet.PayloadScopeSystem, "G4.user-but-actually-system")
return err
})
// create a team with a couple profiles
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_1"})
require.NoError(t, err)
tm1Profiles := [][]byte{
mobileconfigForTest("T1.1", "T1.1"),
scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem),
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
// create a second team with different profiles
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_2"})
require.NoError(t, err)
tm2Profiles := [][]byte{
mobileconfigForTest("T2.1", "T2.1"),
scopedMobileconfigForTest("T2.2", "T2.2.user", &payloadScopeSystem),
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm2.ID))
// Do a no-op update of each team's profiles, verify no errors are returned
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm2.ID))
// Test a modification of an existing global profile with an implicit scope change
newGlobalProfiles := [][]byte{
globalProfiles[0],
globalProfiles[1],
globalProfiles[2],
scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser),
}
response := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newGlobalProfiles}, http.StatusBadRequest)
errMsg := extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't edit configuration profile (G4.user-but-actually-system) because it was previously delivered to some hosts on the device channel")
// Test a conflict of a profile on a team with an existing global profile and an implicit scope change
// Should error because "G4.user-but-actually-system" conflicts with global
// "G4.user-but-actually-system" profile scope
newTm1Profiles := [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser),
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't add configuration profile (G4.user-but-actually-system) because \"PayloadScope\" conflicts")
// Test a conflict of a profile on a team with an existing global profile
// Should error because "G2" conflicts with global "G2" profile
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("G2", "G2", &payloadScopeUser),
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't add configuration profile (G2) because \"PayloadScope\" conflicts")
// Test a conflict of a profile on a team versus one with the same identifier but different
// scope on a different team.
// Should error because "T2.3.user" system-scoped profile conflicts with team2 "T2.3.user" user-scoped profile
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeSystem), // T2.3.user changed to system scope
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't add configuration profile (T2.3.user) because \"PayloadScope\" conflicts")
// Profile edit of existing profile on team1 with a new scope
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeSystem), // T1.3.user changed to system scope
}
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
"team_id", fmt.Sprint(tm1.ID))
errMsg = extractServerErrorText(response.Body)
require.Contains(t, errMsg, "Couldn't edit configuration profile (T1.3.user) because the profile's \"PayloadScope\" has changed")
// Should be able to add these profiles to team1 with the proper scopes
newTm1Profiles = [][]byte{
tm1Profiles[0], // T1.1
tm1Profiles[1], // T1.2
tm1Profiles[2], // T1.3.user
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), // T2.3.user changed to system scope
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusNoContent,
"team_id", fmt.Sprint(tm1.ID))
}
func (s *integrationMDMTestSuite) TestWindowsProfilesWithFleetVariables() {
t := s.T()
ctx := t.Context()
// Create a team for team-scoped tests
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test_windows_fleet_vars"})
require.NoError(t, err)
testCases := []struct {
name string
profiles []fleet.MDMProfileBatchPayload
teamID *uint
wantStatus int
wantErrContains string
}{
{
name: "HOST_UUID variable accepted for team",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestHostUUID",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "HOST_UUID variable with braces accepted",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestHostUUIDBraces",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "${FLEET_VAR_HOST_UUID}"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "unsupported variable rejected",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestUnsupported",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Serial", Data: "$FLEET_VAR_HOST_FAKE"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusUnprocessableEntity,
wantErrContains: "Fleet variable $FLEET_VAR_HOST_FAKE is not supported in Windows profiles",
},
{
name: "mixed supported and unsupported variables rejected",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "TestMixed",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Email", Data: "$FLEET_VAR_BOGUS"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusUnprocessableEntity,
wantErrContains: "Fleet variable $FLEET_VAR_BOGUS is not supported in Windows profiles",
},
{
name: "HOST_UUID variable accepted globally",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "GlobalHostUUID",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
}),
},
},
teamID: nil, // global profile
wantStatus: http.StatusNoContent,
},
{
name: "batch with regular and variable profiles accepted",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "RegularProfile",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"},
}),
},
{
Name: "ProfileWithVar",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "multiple HOST_UUID variables in single profile accepted",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "MultipleHostUUID",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/BackupID", Data: "${FLEET_VAR_HOST_UUID}"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusNoContent,
},
{
name: "unknown Fleet variable rejected",
profiles: []fleet.MDMProfileBatchPayload{
{
Name: "UnknownVar",
Contents: syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/SomeValue", Data: "${FLEET_VAR_UNKNOWN_VAR}"},
}),
},
},
teamID: &tm.ID,
wantStatus: http.StatusUnprocessableEntity,
wantErrContains: "Fleet variable $FLEET_VAR_UNKNOWN_VAR is not supported in Windows profiles",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var resp *http.Response
// Execute request with or without team_id
if tc.teamID != nil {
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: tc.profiles},
tc.wantStatus,
"team_id", fmt.Sprint(*tc.teamID))
} else {
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: tc.profiles},
tc.wantStatus)
}
// Check error message if expected
if tc.wantErrContains != "" {
errMsg := extractServerErrorText(resp.Body)
require.Contains(t, errMsg, tc.wantErrContains)
}
})
}
}
func (s *integrationMDMTestSuite) TestWindowsProfilesFleetVariableSubstitution() {
t := s.T()
ctx := context.Background()
// Create a team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team"})
require.NoError(t, err)
// Create and enroll three Windows hosts (two global, one in team)
hostGlobal1, device1 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
hostGlobal2, device2 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
hostTeam, deviceTeam := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// Add the team host to the team
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hostTeam.ID}))
require.NoError(t, err)
// Create profiles with HOST_UUID variable for global and team
globalProfile := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "Device ID: $FLEET_VAR_HOST_UUID"},
})
teamProfile := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/TeamDevice/ID", Data: "Team Device: ${FLEET_VAR_HOST_UUID}"},
})
// Upload global profile
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "GlobalProfileWithVar", Contents: globalProfile},
}},
http.StatusNoContent)
// Upload team profile
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "TeamProfileWithVar", Contents: teamProfile},
}},
http.StatusNoContent,
"team_id", fmt.Sprint(tm.ID))
// Helper to verify profile contains substituted UUID
verifyProfileSubstitution := func(device *mdmtest.TestWindowsMDMClient, expectedUUID string, expectedData string) {
s.awaitTriggerProfileSchedule(t)
cmds, err := device.StartManagementSession()
require.NoError(t, err)
// Find the Atomic command containing the profile
var foundProfile bool
msgID, err := device.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
// Send status response for each command
device.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusOK),
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
if cmd.Verb == "Atomic" {
// Check if the command contains our expected data with UUID substituted
for _, replaceCmd := range cmd.Cmd.ReplaceCommands {
for _, item := range replaceCmd.Items {
if item.Data != nil && item.Data.Content != "" {
if strings.Contains(item.Data.Content, expectedData) {
// Verify the UUID was substituted correctly
require.Contains(t, item.Data.Content, expectedUUID)
require.NotContains(t, item.Data.Content, "$FLEET_VAR_HOST_UUID")
require.NotContains(t, item.Data.Content, "${FLEET_VAR_HOST_UUID}")
foundProfile = true
}
}
}
}
}
}
require.True(t, foundProfile, "Expected profile with UUID substitution not found")
// Send the response to complete the session
cmds, err = device.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
// Verify global hosts receive profile with their UUID substituted
verifyProfileSubstitution(device1, hostGlobal1.UUID, "Device ID: "+hostGlobal1.UUID)
verifyProfileSubstitution(device2, hostGlobal2.UUID, "Device ID: "+hostGlobal2.UUID)
// Verify team host receives team profile with UUID substituted
verifyProfileSubstitution(deviceTeam, hostTeam.UUID, "Team Device: "+hostTeam.UUID)
// Check that profile statuses are updated correctly in the database
checkHostProfileStatus := func(hostUUID string, profileName string, expectedStatus fleet.MDMDeliveryStatus) {
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, hostUUID)
require.NoError(t, err)
// Find the specific profile by name
var foundProfile *fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName {
foundProfile = &p
break
}
}
require.NotNil(t, foundProfile, "Profile %s not found for host %s", profileName, hostUUID)
require.NotNil(t, foundProfile.Status, "Profile %s status is nil for host %s", profileName, hostUUID)
assert.Equal(t, expectedStatus, *foundProfile.Status, "Profile %s has unexpected status for host %s", profileName, hostUUID)
}
checkHostProfileStatus(hostGlobal1.UUID, "GlobalProfileWithVar", fleet.MDMDeliveryVerifying)
checkHostProfileStatus(hostGlobal2.UUID, "GlobalProfileWithVar", fleet.MDMDeliveryVerifying)
checkHostProfileStatus(hostTeam.UUID, "TeamProfileWithVar", fleet.MDMDeliveryVerifying)
// Now let's check profile verification
// Also create and test a host without Fleet variables to ensure normal verification still works
hostNoVars, deviceNoVars := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
// Create a profile without variables
profileNoVars := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/Static/Value", Data: "Static Value: NoSubstitution"},
})
// Upload profile without variables
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "ProfileNoVars", Contents: profileNoVars},
}},
http.StatusNoContent)
// Let the host get the profile
s.awaitTriggerProfileSchedule(t)
cmds, err := deviceNoVars.StartManagementSession()
require.NoError(t, err)
msgID, err := deviceNoVars.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
deviceNoVars.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusOK),
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
cmds, err = deviceNoVars.SendResponse()
require.NoError(t, err)
require.Len(t, cmds, 1) // ack
checkHostProfileStatus(hostNoVars.UUID, "ProfileNoVars", fleet.MDMDeliveryVerifying)
// To ensure any verification failures result in retry (pending) status instead of staying as verifying,
// we need to be outside the grace period. The grace period check is:
// hostDetailUpdatedAt.Before(profileEarliestInstallDate.Add(1 hour))
//
// We need to make sure the host checked in recently (detail_updated_at = now)
// but the profiles are old (created more than 1 hour ago)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
// Set profile timestamps to 2 hours ago
// IMPORTANT: uploaded_at is used as EarliestInstallDate in verification logic
_, err := q.ExecContext(ctx,
`UPDATE mdm_windows_configuration_profiles
SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR),
uploaded_at = DATE_SUB(NOW(), INTERVAL 2 HOUR)
WHERE name IN (?, ?, ?)`,
"GlobalProfileWithVar", "TeamProfileWithVar", "ProfileNoVars")
if err != nil {
return err
}
// Also update the host profile associations to have old timestamps (only created_at)
_, err = q.ExecContext(ctx,
`UPDATE host_mdm_windows_profiles
SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR)
WHERE host_uuid IN (?, ?, ?, ?)`,
hostGlobal1.UUID, hostGlobal2.UUID, hostTeam.UUID, hostNoVars.UUID)
if err != nil {
return err
}
// Set host detail_updated_at to now (recent check-in)
_, err = q.ExecContext(ctx, `UPDATE hosts SET detail_updated_at = NOW() WHERE id IN (?, ?, ?, ?)`,
hostGlobal1.ID, hostGlobal2.ID, hostTeam.ID, hostNoVars.ID)
return err
})
// Helper to simulate osquery reporting back profile data
simulateOsqueryProfileReport := func(nodeKey string, profileName string, locURI string, reportedData string) {
// Build a SyncML response that osquery would send back after reading the profile from Windows
cmdRef := microsoft_mdm.HashLocURI(profileName, locURI)
var msg fleet.SyncML
msg.Xmlns = syncml.SyncCmdNamespace
msg.SyncHdr = fleet.SyncHdr{
VerDTD: syncml.SyncMLSupportedVersion,
VerProto: syncml.SyncMLVerProto,
SessionID: "2",
MsgID: "2",
}
// Add status response (profile was successfully applied)
msg.AppendCommand(fleet.MDMRaw, fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
CmdID: fleet.CmdID{Value: uuid.NewString()},
CmdRef: &cmdRef,
Data: ptr.String("200"),
})
// Add results with the data that osquery read from Windows
msg.AppendCommand(fleet.MDMRaw, fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdResults},
CmdID: fleet.CmdID{Value: uuid.NewString()},
CmdRef: &cmdRef,
Items: []fleet.CmdItem{
{
Target: ptr.String(locURI),
Data: &fleet.RawXmlData{
Content: reportedData,
},
},
},
})
rawResponse, err := xml.Marshal(msg)
require.NoError(t, err)
// Submit the results via osquery distributed write endpoint
distributedReq := SubmitDistributedQueryResultsRequest{
NodeKey: nodeKey,
Results: map[string][]map[string]string{
"fleet_detail_query_mdm_config_profiles_windows": {
{"raw_mdm_command_output": string(rawResponse)},
},
},
Statuses: map[string]fleet.OsqueryStatus{
"fleet_detail_query_mdm_config_profiles_windows": 0,
},
}
distributedResp := submitDistributedQueryResultsResponse{}
s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp)
}
// First verify that normal profile (without variables) verifies correctly
simulateOsqueryProfileReport(
*hostNoVars.NodeKey,
"ProfileNoVars",
"./Device/Vendor/MSFT/DMClient/Provider/ProviderID/Static/Value",
"Static Value: NoSubstitution", // osquery reports exactly what was sent
)
// Normal profile should be verified successfully
checkHostProfileStatus(hostNoVars.UUID, "ProfileNoVars", fleet.MDMDeliveryVerified)
// Simulate osquery reporting back for team host
simulateOsqueryProfileReport(
*hostTeam.NodeKey,
"TeamProfileWithVar",
"./Device/Vendor/MSFT/DMClient/Provider/ProviderID/TeamDevice/ID",
"Team Device: "+hostTeam.UUID, // osquery reports the substituted value
)
// Team host has TeamProfileWithVar which now correctly verifies with Fleet variables
// The fix has been implemented and the profile should be verified successfully
checkHostProfileStatus(hostTeam.UUID, "TeamProfileWithVar", fleet.MDMDeliveryVerified)
// Hit the host details API and check the status in the mdm.profiles section
// Verify team host
var hostRespTeam getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", hostTeam.ID), getHostRequest{}, http.StatusOK, &hostRespTeam)
require.NotNil(t, hostRespTeam.Host.MDM.Profiles)
require.Len(t, *hostRespTeam.Host.MDM.Profiles, 1)
require.Equal(t, "TeamProfileWithVar", (*hostRespTeam.Host.MDM.Profiles)[0].Name)
require.EqualValues(t, fleet.MDMDeliveryVerified, *(*hostRespTeam.Host.MDM.Profiles)[0].Status,
"Profile should be verified in host details API for team host")
// Verify no-vars host
var hostRespNoVars getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", hostNoVars.ID), getHostRequest{}, http.StatusOK, &hostRespNoVars)
require.NotNil(t, hostRespNoVars.Host.MDM.Profiles)
require.Len(t, *hostRespNoVars.Host.MDM.Profiles, 1)
require.Equal(t, "ProfileNoVars", (*hostRespNoVars.Host.MDM.Profiles)[0].Name)
require.EqualValues(t, fleet.MDMDeliveryVerified, *(*hostRespNoVars.Host.MDM.Profiles)[0].Status,
"Profile should be verified in host details API for no-vars host")
}
//go:embed testdata/profiles/windows-device-scep.xml
var windowsDeviceSCEPProfileBytes []byte
func (s *integrationMDMTestSuite) TestWindowsDeviceSCEPProfile() {
testWindowsSCEPProfile(s, windowsDeviceSCEPProfileBytes)
}
//go:embed testdata/profiles/windows-user-scep.xml
var windowsUserSCEPProfileBytes []byte
func (s *integrationMDMTestSuite) TestWindowsUserSCEPProfile() {
testWindowsSCEPProfile(s, windowsUserSCEPProfileBytes)
}
func testWindowsSCEPProfile(s *integrationMDMTestSuite, windowsScepProfile []byte) {
t := s.T()
ctx := context.Background()
scepServer := scep_server.StartTestSCEPServer(t)
scepServerURL := scepServer.URL + "/scep"
// Create windows host and enroll in MDM
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
verifyCommands := func(wantProfiles int, status string) {
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
// profile installs + 2 protocol commands acks
require.Len(t, cmds, wantProfiles+2)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
atomicCmds := 0
for _, c := range cmds {
if c.Verb == "Atomic" {
atomicCmds++
}
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: ptr.String(c.Cmd.CmdID.Value),
Cmd: ptr.String(c.Verb),
Data: ptr.String(status),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
require.Equal(t, wantProfiles, atomicCmds)
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
}
// Upload SCEP profile with missing CA
resp := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "WindowsSCEPProfile", Contents: windowsScepProfile},
}},
http.StatusBadRequest)
errMsg := extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "Fleet variable $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_INTEGRATION does not exist.")
// Create Custom SCEP CA
ca := &fleet.CertificateAuthority{
Type: string(fleet.CATypeCustomSCEPProxy),
Name: ptr.String("INTEGRATION"),
Challenge: ptr.String("integration-test"),
URL: ptr.String(scepServerURL),
}
_, err := s.ds.NewCertificateAuthority(ctx, ca)
require.NoError(t, err)
// Fail on missing OU
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "WindowsSCEPProfile", Contents: bytes.ReplaceAll(windowsScepProfile, []byte(fleet.FleetVarSCEPRenewalID.WithPrefix()), []byte("BOGUS"))},
}},
http.StatusBadRequest)
errMsg = extractServerErrorText(resp.Body)
require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables")
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "WindowsSCEPProfile", Contents: windowsScepProfile},
}},
http.StatusNoContent)
// Verify host receives the profile
s.awaitTriggerProfileSchedule(t)
// Check that profile status is Pending
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var foundProfile bool
for _, p := range profiles {
if p.Name == "WindowsSCEPProfile" {
foundProfile = true
require.NotNil(t, p.Status)
assert.EqualValues(t, fleet.MDMDeliveryPending, *p.Status)
}
}
require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
verifyCommands(1, syncml.CmdStatusOK)
// Verify profile status is Verified due to successful response
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
foundProfile = false
for _, p := range profiles {
if p.Name == "WindowsSCEPProfile" {
foundProfile = true
require.NotNil(t, p.Status)
assert.EqualValues(t, fleet.MDMDeliveryVerified, *p.Status)
}
}
require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
// Report Osquery results indicating SCEP profile was applied successfully
s.reportWindowsOSQueryProfiles(ctx, t, host, map[string][]profileData{
"WindowsSCEPProfile": {{"200", "L1", "Bogus"}}, // Report back with SCEP LocURI, but data that does not relate SCEP to support the case that we don't verify the success.
})
// Verify profile status is still Verified, and OSQuery does not change it's status.
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
foundProfile = false
profileUUID := ""
for _, p := range profiles {
if p.Name == "WindowsSCEPProfile" {
foundProfile = true
profileUUID = p.ProfileUUID
require.NotNil(t, p.Status)
require.EqualValues(t, fleet.MDMDeliveryVerified, *p.Status)
}
}
require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
// Attempt simple SCEP call with GetCACaps operation to verify SCEP server is reachable
identifier := host.UUID + "," + profileUUID + "," + "INTEGRATION"
scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+"/pkiclient.exe", nil, http.StatusOK, nil, "operation", "GetCACaps")
body, err := io.ReadAll(scepRes.Body)
require.NoError(t, err)
assert.Equal(t, scepserver.DefaultCACaps, string(body))
}
// This test verifies that there is no longer a race condition in apple profile resending
func (s *integrationMDMTestSuite) TestAppleProfileResendRaceCondition() {
t := s.T()
ctx := context.Background()
// Create a host and enroll it in MDM
host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
setupPusher(s, t, mdmDevice)
scimUserID, err := s.ds.CreateScimUser(ctx, &fleet.ScimUser{UserName: "user@example.com"})
require.NoError(t, err)
// Assign scim user to host
hostIdStr := fmt.Sprint(host.ID)
s.Do("PUT", "/api/latest/fleet/hosts/"+hostIdStr+"/device_mapping", putHostDeviceMappingRequest{
Email: "user@example.com",
Source: "idp",
}, http.StatusOK)
// Create a profile that uses IDP variables
profileWithIDPVar := mobileconfigForTestWithContent("TestProfile", "com.test.profile", "com.test.profile.content", "com.test.profile", "Test IDP Variable Profile")
// Replace the profile content to include an IDP variable
profileContent := string(profileWithIDPVar)
profileContent = strings.Replace(profileContent, "ShowRecoveryKey", "TestVariable", 1)
profileContent = strings.Replace(profileContent, "", "$FLEET_VAR_HOST_END_USER_IDP_USERNAME", 1)
profileWithIDPVar = []byte(profileContent)
// Upload the profile using the new endpoint that supports variables
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: "TestProfile", Contents: profileWithIDPVar},
}}, http.StatusNoContent) // Setup SCIM user data for the host
// No profiles until reconciler
hostProfiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
require.Empty(t, hostProfiles, "Host should not have any profiles before sync")
// Trigger initial profile sync - profile should be set to pending/installing
s.awaitTriggerProfileSchedule(t)
// Check that install command was sent, but do not acknowledge it yet
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
seenProfile := false
profileCmdID := ""
for cmd != nil {
if cmd.Command.RequestType != "InstallProfile" {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
seenProfile = true
profileCmdID = cmd.CommandUUID
break
}
// Acknowledge other commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.True(t, seenProfile, "Expected install command for TestProfile not found")
// Verify profile is in pending status
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
var testProfile *fleet.HostMDMAppleProfile
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status)
// Now simulate the race condition:
// we trigger a resend before the acknowledgement comes back
// 1. Trigger an IDP variable change by updating SCIM user
err = s.ds.ReplaceScimUser(ctx, &fleet.ScimUser{ID: scimUserID, UserName: "newuser@example.com"})
require.NoError(t, err)
// 2. At this point, the profile should be marked for resend (status = NULL)
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
fmt.Printf("%v\n", p.Status)
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status) // Should be NULL (pending for the user)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status *fleet.MDMDeliveryStatus
err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier)
require.Nil(t, status)
return err
})
// Acknowledge the original install command now, simulating the device response
cmd, err = mdmDevice.Acknowledge(profileCmdID)
require.NoError(t, err)
// Now check if we see any new TestProfile cmds (we need to ack them here, since we might have skipped some above.)
seenProfile = false
for cmd != nil {
if cmd.Command.RequestType != "InstallProfile" {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
seenProfile = true
// Acknowledge the resend command
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
} else {
// Acknowledge other commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
require.Nil(t, cmd, "No further commands should be pending after acknowledging install")
require.False(t, seenProfile, "No resend install command for TestProfile should be sent due to race condition")
// Verify the profile is still in pending status (null in the DB)
// aka. no race condition.
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status *fleet.MDMDeliveryStatus
err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier)
require.Nil(t, status)
return err
})
// run reconciler to resend any pending profiles
s.awaitTriggerProfileSchedule(t)
cmd, err = mdmDevice.Idle()
require.NoError(t, err)
// Now we should see the resend command
seenProfile = false
for cmd != nil {
if cmd.Command.RequestType != "InstallProfile" {
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &fullCmd)
require.NoError(t, err)
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
seenProfile = true
// Acknowledge the resend command
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
} else {
// Acknowledge other commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
}
require.True(t, seenProfile, "Resend install command for TestProfile should be sent after reconciler runs")
// And we should now also see the profile being marked as verifying
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range hostProfiles {
if p.Identifier == "com.test.profile" {
testProfile = &p
break
}
}
require.NotNil(t, testProfile)
require.EqualValues(t, fleet.MDMDeliveryVerifying, *testProfile.Status)
}
func (s *integrationMDMTestSuite) TestWindowsProfileRetry() {
t := s.T()
ctx := t.Context()
// Create a host and enroll it in MDM
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
t.Run("Command gets retried with Replace after 418", func(t *testing.T) {
profilePayload := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"},
})
profileName := "RetryProfile"
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: profileName, Contents: profilePayload},
}},
http.StatusNoContent)
expectRetry := func(profileName string, expectedRetries int) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var retryCount int
err := sqlx.GetContext(t.Context(), q, &retryCount,
`SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
host.UUID, profileName)
require.NoError(t, err)
require.Equal(t, expectedRetries, retryCount, "Unexpected retry count for profile %s", profileName)
return nil
})
}
// Trigger profile schedule
s.awaitTriggerProfileSchedule(t)
// Get initial host profile
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var initialProfile fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName {
initialProfile = p
break
}
}
require.NotNil(t, initialProfile)
require.Equal(t, fleet.MDMDeliveryPending, *initialProfile.Status)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, addCmd := range cmd.Cmd.AddCommands {
for range addCmd.Items {
itemCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &addCmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
// 418 triggers Replace resend logic
Data: ptr.String(syncml.CmdStatusAlreadyExists),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(itemCmd)
}
}
}
cmds, err = mdmDevice.SendResponse() // we have atomic replace (resend after 418 attempt in this cmd list here)
require.NoError(t, err)
require.Len(t, cmds, 2) // stsatus + atomic replace
// After initial 418 resend: pending, empty detail, retries = 0.
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var updatedProfile fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName {
updatedProfile = p
break
}
}
require.NotNil(t, updatedProfile)
require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status)
require.Empty(t, updatedProfile.Detail)
expectRetry(profileName, 0)
// Second session: fail Atomic to trigger normal retry (status NULL, retries++ -> 1).
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
// Verify raw DB status is NULL and retries = 1.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var status sql.NullString
var retries int
err := sqlx.GetContext(t.Context(), q, &status,
`SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
host.UUID, profileName)
require.NoError(t, err)
require.False(t, status.Valid, "status should be NULL")
err = sqlx.GetContext(t.Context(), q, &retries,
`SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
host.UUID, profileName)
require.NoError(t, err)
require.Equal(t, 1, retries)
return nil
})
// Third session: Add 418 again to requeue Replace; retries decremented back to 0.
s.awaitTriggerProfileSchedule(t)
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, addCmd := range cmd.Cmd.AddCommands {
for range addCmd.Items {
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &addCmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
Data: ptr.String(syncml.CmdStatusAlreadyExists), // 418
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
}
}
newCmds, err := mdmDevice.SendResponse()
require.NoError(t, err)
require.Len(t, newCmds, 2) // status + atomic replace
// Pending and retries back to 0.
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range profiles {
if p.Name == profileName {
updatedProfile = p
break
}
}
require.NotNil(t, updatedProfile)
require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status)
require.Empty(t, updatedProfile.Detail)
expectRetry(profileName, 1)
// Fourth session: Replace succeeds (Atomic OK + item 200) → verifying.
s.awaitTriggerProfileSchedule(t)
cmds, err = mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err = mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusOK),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, repCmd := range cmd.Cmd.ReplaceCommands {
for _, item := range repCmd.Items {
itemCmdRef := microsoft_mdm.HashLocURI(profileName, *item.Target)
mdmDevice.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &itemCmdRef,
Cmd: ptr.String(fleet.CmdStatus),
Data: ptr.String(syncml.CmdStatusOK), // 200
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
}
}
}
_, err = mdmDevice.SendResponse()
require.NoError(t, err)
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
for _, p := range profiles {
if p.Name == profileName {
updatedProfile = p
break
}
}
require.NotNil(t, updatedProfile)
require.Equal(t, fleet.MDMDeliveryVerifying, *updatedProfile.Status)
require.Empty(t, updatedProfile.Detail)
expectRetry(profileName, 1)
})
t.Run("No resend on non-retryable error", func(t *testing.T) {
profilePayload2 := syncml.ForTestWithData([]syncml.TestCommand{
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowCamera", Data: "1"},
})
profileName2 := "NonRetryProfile"
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
{Name: profileName2, Contents: profilePayload2},
}},
http.StatusNoContent)
// Trigger profile schedule
s.awaitTriggerProfileSchedule(t)
// Get initial host profile
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
require.NoError(t, err)
var initialProfile2 fleet.HostMDMWindowsProfile
for _, p := range profiles {
if p.Name == profileName2 {
initialProfile2 = p
break
}
}
require.NotNil(t, initialProfile2)
require.Equal(t, fleet.MDMDeliveryPending, *initialProfile2.Status)
cmds, err := mdmDevice.StartManagementSession()
require.NoError(t, err)
msgID, err := mdmDevice.GetCurrentMsgID()
require.NoError(t, err)
for _, cmd := range cmds {
if cmd.Verb == "Status" {
continue
}
syncCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &cmd.Cmd.CmdID.Value,
Cmd: ptr.String(cmd.Verb),
Data: ptr.String(syncml.CmdStatusAtomicFailed), // Generic failure, should not retry
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(syncCmd)
for _, addCmd := range cmd.Cmd.AddCommands {
for range addCmd.Items {
itemCmd := fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &addCmd.CmdID.Value,
Cmd: ptr.String(fleet.CmdStatus),
// 500 generic failure
Data: ptr.String(syncml.CmdStatusBadRequest),
CmdID: fleet.CmdID{Value: uuid.NewString()},
}
mdmDevice.AppendResponse(itemCmd)
}
}
}
cmds, err = mdmDevice.SendResponse()
require.NoError(t, err)
require.Len(t, cmds, 1) // only ack returned
})
}