mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #39606 # Checklist for submitter If some of the following don't apply, delete the relevant line. ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed
8789 lines
398 KiB
Go
8789 lines
398 KiB
Go
package service
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/md5" // nolint:gosec // used only for tests
|
||
"crypto/x509"
|
||
"database/sql"
|
||
_ "embed"
|
||
"encoding/json"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm"
|
||
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
|
||
"github.com/fleetdm/fleet/v4/pkg/optjson"
|
||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||
servermdm "github.com/fleetdm/fleet/v4/server/mdm"
|
||
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
||
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"
|
||
"google.golang.org/api/androidmanagement/v1"
|
||
)
|
||
|
||
func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) {
|
||
t := s.T()
|
||
rootCA := x509.NewCertPool()
|
||
|
||
assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{
|
||
fleet.MDMAssetCACert,
|
||
}, nil)
|
||
require.NoError(t, err)
|
||
|
||
require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value))
|
||
|
||
// verify that all the profiles were signed usign the SCEP certificate,
|
||
// and grab their contents
|
||
signedContents := [][]byte{}
|
||
for _, prof := range got {
|
||
p7, err := pkcs7.Parse(prof)
|
||
require.NoError(t, err)
|
||
require.NoError(t, p7.VerifyWithChain(rootCA))
|
||
signedContents = append(signedContents, p7.Content)
|
||
}
|
||
|
||
// verify that contents match
|
||
require.ElementsMatch(t, want, signedContents)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
|
||
require.NoError(t, err)
|
||
|
||
globalProfiles := [][]byte{
|
||
mobileconfigForTest("N1", "I1"),
|
||
mobileconfigForTest("N2", "I2"),
|
||
}
|
||
wantGlobalProfiles := globalProfiles
|
||
wantGlobalProfiles = append(
|
||
wantGlobalProfiles,
|
||
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
|
||
)
|
||
|
||
// add global profiles
|
||
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
|
||
|
||
// invalid secrets
|
||
invalidSecretsProfile := []byte(`
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>PayloadContent</key>
|
||
<array/>
|
||
<key>PayloadDisplayName</key>
|
||
<string>My profile</string>
|
||
<key>PayloadIdentifier</key>
|
||
<string>$FLEET_SECRET_INVALID</string>
|
||
<key>PayloadType</key>
|
||
<string>Configuration</string>
|
||
<key>PayloadUUID</key>
|
||
<string>601E0B42-0989-4FAD-A61B-18656BA3670E</string>
|
||
<key>PayloadVersion</key>
|
||
<integer>1</integer>
|
||
</dict>
|
||
</plist>
|
||
`)
|
||
|
||
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
|
||
errMsg := extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
|
||
|
||
invalidSecretsProfile = []byte(`
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>PayloadContent</key>
|
||
<array/>
|
||
<key>PayloadDisplayName</key>
|
||
<string>$FLEET_SECRET_INVALID</string>
|
||
<key>PayloadIdentifier</key>
|
||
<string>N3</string>
|
||
<key>PayloadType</key>
|
||
<string>Configuration</string>
|
||
<key>PayloadUUID</key>
|
||
<string>601E0B42-0989-4FAD-A61B-18656BA3670E</string>
|
||
<key>PayloadVersion</key>
|
||
<integer>1</integer>
|
||
</dict>
|
||
</plist>
|
||
`)
|
||
|
||
res = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "PayloadDisplayName cannot contain FLEET_SECRET variables")
|
||
|
||
// create a new team
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
|
||
require.NoError(t, err)
|
||
|
||
// add an enroll secret so the fleetd profiles differ
|
||
var teamResp teamEnrollSecretsResponse
|
||
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm.ID),
|
||
modifyTeamEnrollSecretsRequest{
|
||
Secrets: []fleet.EnrollSecret{{Secret: "team1_enroll_sec"}},
|
||
}, http.StatusOK, &teamResp)
|
||
|
||
teamProfiles := [][]byte{
|
||
mobileconfigForTest("N3", "I3"),
|
||
}
|
||
wantTeamProfiles := teamProfiles
|
||
wantTeamProfiles = append(
|
||
wantTeamProfiles,
|
||
setupExpectedFleetdProfile(t, s.server.URL, "team1_enroll_sec", &tm.ID),
|
||
)
|
||
// add profiles to the team
|
||
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent,
|
||
"team_id", fmt.Sprint(tm.ID))
|
||
|
||
// create a non-macOS host
|
||
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
|
||
ID: 1,
|
||
OsqueryHostID: ptr.String("non-macos-host"),
|
||
NodeKey: ptr.String("non-macos-host"),
|
||
UUID: uuid.New().String(),
|
||
Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
|
||
Platform: "windows",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// create a host that's not enrolled into MDM
|
||
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
|
||
ID: 2,
|
||
OsqueryHostID: ptr.String("not-mdm-enrolled"),
|
||
NodeKey: ptr.String("not-mdm-enrolled"),
|
||
UUID: uuid.New().String(),
|
||
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// 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 <Status>
|
||
verifyCommands(len(testProfiles), syncml.CmdStatusOK)
|
||
checkProfilesStatus(t) // all profiles verifying
|
||
checkRetryCounts(t) // no retries yet
|
||
|
||
// report osquery results with N2 missing and confirm N2 marked
|
||
// as verifying and other profiles are marked as verified
|
||
|
||
reportHostProfs("N1")
|
||
expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
|
||
expectedProfileStatuses["N2"] = fleet.MDMDeliveryPending
|
||
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 <Status>
|
||
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 <Status>
|
||
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(`<Add><Item><Target><LocURI>%s</LocURI></Target></Item></Add><Replace><Item><Target><LocURI>%s</LocURI></Target></Item></Replace>`, locURI, locURI)),
|
||
s.token,
|
||
fields,
|
||
)
|
||
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
|
||
|
||
if wantErrMsg != "" {
|
||
errMsg := extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, wantErrMsg)
|
||
return ""
|
||
}
|
||
|
||
var resp newMDMConfigProfileResponse
|
||
err := json.NewDecoder(res.Body).Decode(&resp)
|
||
require.NoError(t, err)
|
||
require.NotEmpty(t, resp.ProfileUUID)
|
||
require.Equal(t, "w", string(resp.ProfileUUID[0]))
|
||
return resp.ProfileUUID
|
||
}
|
||
createWindowsProfile := func(name string, teamID uint, labels []string) string {
|
||
uid := assertWindowsProfile(name+".xml", "./Test", teamID, labels, http.StatusOK, "")
|
||
|
||
var wantJSON string
|
||
if teamID == 0 {
|
||
wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name)
|
||
} else {
|
||
wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name)
|
||
}
|
||
s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.ActivityName(), wantJSON, 0)
|
||
|
||
return uid
|
||
}
|
||
|
||
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, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
assertAndroidProfile("android-with-labels.json", 0, []string{"does-not-exist"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
|
||
// create a couple of labels
|
||
labelFoo := &fleet.Label{Name: "foo", Query: "select * from foo;"}
|
||
labelFoo, err = s.ds.NewLabel(context.Background(), labelFoo)
|
||
require.NoError(t, err)
|
||
labelBar := &fleet.Label{Name: "bar", Query: "select * from bar;"}
|
||
labelBar, err = s.ds.NewLabel(context.Background(), labelBar)
|
||
require.NoError(t, err)
|
||
|
||
// profiles mixing existent and non-existent labels
|
||
assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
assertAndroidProfile("android-profile-with-labels.json", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, `Couldn't update. Label "does-not-exist" doesn't exist. Please remove the label from the configuration profile.`)
|
||
|
||
// profiles with invalid mix of labels
|
||
assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "-bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`)
|
||
|
||
// profiles with valid labels
|
||
uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"!foo"}, http.StatusOK, "")
|
||
uuidAppleWithInclAnyLabel := assertAppleProfile("apple-profile-with-incl-any-labels.mobileconfig", "apple-profile-with-incl-any-labels", "ident-with-incl-any-labels", 0, []string{"~foo", "~bar"}, http.StatusOK, "")
|
||
uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"})
|
||
uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"-foo", "-bar"}, http.StatusOK, "")
|
||
uuidAppleDDMTeamWithLabel := createAppleDeclaration("apple-team-decl-with-labels", "ident-team-decl-with-labels", testTeam.ID, []string{"-foo"})
|
||
uuidWindowsTeamWithLabel := assertWindowsProfile("win-team-profile-with-labels.xml", "./Test", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "")
|
||
uuidWindowsTeamWithInclAnyLabel := assertWindowsProfile("win-team-profile-with-incl-any-labels.xml", "./Test", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "")
|
||
|
||
uuidAndroidWithLabel := assertAndroidProfile("android-profile-with-labels.json", 0, []string{"-foo", "-bar"}, http.StatusOK, "")
|
||
uuidAndroidTeamWithLabel := assertAndroidProfile("android-team-profile-with-labels.json", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "")
|
||
uuidAndroidTeamWithInclAnyLabel := assertAndroidProfile("android-team-profile-with-incl-any-labels.json", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "")
|
||
|
||
// Windows invalid content
|
||
body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil)
|
||
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg := extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Couldn't add. The file should include valid XML:")
|
||
|
||
// Apple invalid mobileconfig content
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Configuration profiles can't be signed. Fleet will sign the profile for you.")
|
||
|
||
// Apple/Android invalid json declaration
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"apple.json", []byte("{"), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Couldn't add. The file should include valid JSON:")
|
||
|
||
// Apple/Android cannot determine which
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"apple_or_android.json", []byte(`{"lower_key": true,"UpperKey": false}`), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Keys in declaration (DDM) profile must contain only letters and start with a uppercase letter. Keys in Android profile must contain only letters and start with a lowercase letter.")
|
||
|
||
// Android invalid keys
|
||
for key, expectedErr := range fleet.AndroidForbiddenJSONKeys {
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"android.json", []byte(fmt.Sprintf(`{"%s": true}`, key)), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, expectedErr)
|
||
}
|
||
|
||
// invalid JSON structure
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"android.json", []byte(`{"passwordPolicies": {"testKey": 123}}`), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, `Couldn't add. Invalid JSON payload. "passwordPolicies" format is wrong.`)
|
||
// nested key
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"android.json", []byte(`{"passwordPolicies": [{"passwordMinimumLength": true}]}`), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, `Couldn't add. Invalid JSON payload. "passwordPolicies.passwordMinimumLength" format is wrong.`)
|
||
|
||
// disallow unknown keys
|
||
body, headers = generateNewProfileMultipartRequest(t,
|
||
"android.json", []byte(`{"unknownKey": true}`), s.token, nil)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, `Couldn't add. Invalid JSON payload. Unknown key "unknownKey"`)
|
||
|
||
// get the existing profiles work
|
||
expectedProfiles := []fleet.MDMConfigProfilePayload{
|
||
{ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil, Scope: string(fleet.PayloadScopeSystem)},
|
||
{ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID, Scope: string(fleet.PayloadScopeSystem)},
|
||
{ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
|
||
{ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID},
|
||
{ProfileUUID: noTeamAndroidProfUUID, Platform: "android", Name: "android-global-profile", TeamID: nil},
|
||
{ProfileUUID: teamAndroidProfUUID, Platform: "android", Name: "android-team-profile", TeamID: &testTeam.ID},
|
||
{
|
||
ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil,
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidAppleWithLabel, Platform: "darwin", Name: "apple-profile-with-labels", Identifier: "ident-with-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem),
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidAppleWithInclAnyLabel, Platform: "darwin", Name: "apple-profile-with-incl-any-labels", Identifier: "ident-with-incl-any-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem),
|
||
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil,
|
||
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidAppleDDMTeamWithLabel, Platform: "darwin", Name: "apple-team-decl-with-labels", Identifier: "ident-team-decl-with-labels", TeamID: &testTeam.ID,
|
||
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidWindowsTeamWithLabel, Platform: "windows", Name: "win-team-profile-with-labels", TeamID: &testTeam.ID,
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidWindowsTeamWithInclAnyLabel, Platform: "windows", Name: "win-team-profile-with-incl-any-labels", TeamID: &testTeam.ID,
|
||
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidAndroidWithLabel, Platform: "android", Name: "android-profile-with-labels", TeamID: nil,
|
||
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidAndroidTeamWithLabel, Platform: "android", Name: "android-team-profile-with-labels", TeamID: &testTeam.ID,
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
{
|
||
ProfileUUID: uuidAndroidTeamWithInclAnyLabel, Platform: "android", Name: "android-team-profile-with-incl-any-labels", TeamID: &testTeam.ID,
|
||
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: labelBar.ID, LabelName: labelBar.Name},
|
||
{LabelID: labelFoo.ID, LabelName: labelFoo.Name},
|
||
},
|
||
},
|
||
}
|
||
for _, prof := range expectedProfiles {
|
||
var getResp getMDMConfigProfileResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, &getResp)
|
||
require.NotZero(t, getResp.CreatedAt)
|
||
require.NotZero(t, getResp.UploadedAt)
|
||
if getResp.Platform == "darwin" {
|
||
require.Len(t, getResp.Checksum, 16)
|
||
} else {
|
||
require.Empty(t, getResp.Checksum)
|
||
}
|
||
getResp.CreatedAt, getResp.UploadedAt = time.Time{}, time.Time{}
|
||
getResp.Checksum = nil
|
||
// sort the labels by name
|
||
sort.Slice(getResp.LabelsIncludeAll, func(i, j int) bool {
|
||
return getResp.LabelsIncludeAll[i].LabelName < getResp.LabelsIncludeAll[j].LabelName
|
||
})
|
||
sort.Slice(getResp.LabelsExcludeAny, func(i, j int) bool {
|
||
return getResp.LabelsExcludeAny[i].LabelName < getResp.LabelsExcludeAny[j].LabelName
|
||
})
|
||
sort.Slice(getResp.LabelsIncludeAny, func(i, j int) bool {
|
||
return getResp.LabelsIncludeAny[i].LabelName < getResp.LabelsIncludeAny[j].LabelName
|
||
})
|
||
require.Equal(t, prof, *getResp.MDMConfigProfilePayload)
|
||
|
||
resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media")
|
||
require.NotZero(t, resp.ContentLength)
|
||
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
|
||
if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleProfileUUIDPrefix) { //nolint:gocritic // ignore ifElseChain
|
||
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
|
||
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
|
||
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
|
||
} else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAndroidProfileUUIDPrefix) {
|
||
require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
|
||
} else {
|
||
require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream")
|
||
}
|
||
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
|
||
|
||
b, err := io.ReadAll(resp.Body)
|
||
require.NoError(t, err)
|
||
require.Equal(t, resp.ContentLength, int64(len(b)))
|
||
}
|
||
|
||
var getResp getMDMConfigProfileResponse
|
||
// get an unknown Apple profile
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp)
|
||
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media")
|
||
// get an unknown Apple declaration
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp)
|
||
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
|
||
// get an unknown Windows profile
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp)
|
||
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media")
|
||
// get an unknown Android profile
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &getResp)
|
||
s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
|
||
|
||
var deleteResp deleteMDMConfigProfileResponse
|
||
// delete existing Apple profiles
|
||
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp)
|
||
|
||
// turn off apple MDM
|
||
appCfg, err := s.ds.AppConfig(ctx)
|
||
require.NoError(t, err)
|
||
appCfg.MDM.EnabledAndConfigured = false
|
||
err = s.ds.SaveAppConfig(ctx, appCfg)
|
||
require.NoError(t, err)
|
||
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp)
|
||
// delete non-existing Apple profile
|
||
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp)
|
||
|
||
// delete existing Apple declaration
|
||
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp)
|
||
s.lastActivityOfTypeMatches(
|
||
fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(),
|
||
`{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null}`,
|
||
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(`<Replace></Replace>`)}, nil)
|
||
require.NoError(t, err)
|
||
_, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(`<Replace></Replace>`)}, nil)
|
||
require.NoError(t, err)
|
||
}
|
||
}
|
||
|
||
lblFoo, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "foo", Query: "select 1"})
|
||
require.NoError(t, err)
|
||
lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"})
|
||
require.NoError(t, err)
|
||
lblBaz, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "baz", Query: "select 1"})
|
||
require.NoError(t, err)
|
||
|
||
// create a couple profiles (Win and mac) for team 2, and none for team 3
|
||
tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil)
|
||
require.NoError(t, err)
|
||
tprof.TeamID = &tm2.ID
|
||
// make tm2ProfF a "exclude-any" label-based profile
|
||
tprof.LabelsExcludeAny = []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblFoo.ID, LabelName: lblFoo.Name},
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
}
|
||
tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil)
|
||
require.NoError(t, err)
|
||
// checksum is not returned by New..., so compute it manually
|
||
checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test
|
||
tm2ProfF.Checksum = checkSum[:]
|
||
|
||
// make tm2ProfG a "include-all" label-based profile
|
||
tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
|
||
Name: "tG",
|
||
TeamID: &tm2.ID,
|
||
SyncML: []byte(`<Add></Add>`),
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblFoo.ID, LabelName: lblFoo.Name},
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
},
|
||
}, nil)
|
||
require.NoError(t, err)
|
||
|
||
// make tm2ProfH a "include-any" label-based profile
|
||
tm2ProfH, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
|
||
Name: "tH",
|
||
TeamID: &tm2.ID,
|
||
SyncML: []byte(`<Add></Add>`),
|
||
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
|
||
},
|
||
}, nil)
|
||
require.NoError(t, err)
|
||
|
||
// break lblFoo by deleting it
|
||
require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}))
|
||
|
||
// test that all fields are correctly returned with team 2
|
||
var listResp listMDMConfigProfilesResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID))
|
||
require.Len(t, listResp.Profiles, 3)
|
||
require.NotZero(t, listResp.Profiles[0].CreatedAt)
|
||
require.NotZero(t, listResp.Profiles[0].UploadedAt)
|
||
require.NotZero(t, listResp.Profiles[1].CreatedAt)
|
||
require.NotZero(t, listResp.Profiles[1].UploadedAt)
|
||
listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{}
|
||
listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{}
|
||
listResp.Profiles[2].CreatedAt, listResp.Profiles[2].UploadedAt = time.Time{}, time.Time{}
|
||
require.Equal(t, &fleet.MDMConfigProfilePayload{
|
||
ProfileUUID: tm2ProfF.ProfileUUID,
|
||
TeamID: tm2ProfF.TeamID,
|
||
Name: tm2ProfF.Name,
|
||
Platform: "darwin",
|
||
Identifier: tm2ProfF.Identifier,
|
||
Checksum: tm2ProfF.Checksum,
|
||
Scope: string(fleet.PayloadScopeSystem),
|
||
// labels are ordered by name
|
||
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
|
||
},
|
||
}, listResp.Profiles[0])
|
||
require.Equal(t, &fleet.MDMConfigProfilePayload{
|
||
ProfileUUID: tm2ProfG.ProfileUUID,
|
||
TeamID: tm2ProfG.TeamID,
|
||
Name: tm2ProfG.Name,
|
||
Platform: "windows",
|
||
// labels are ordered by name
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
|
||
},
|
||
}, listResp.Profiles[1])
|
||
require.Equal(t, &fleet.MDMConfigProfilePayload{
|
||
ProfileUUID: tm2ProfH.ProfileUUID,
|
||
TeamID: tm2ProfH.TeamID,
|
||
Name: tm2ProfH.Name,
|
||
Platform: "windows",
|
||
// labels are ordered by name
|
||
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
|
||
},
|
||
}, listResp.Profiles[2])
|
||
|
||
// get the specific include-all label-based profile returns the information
|
||
var getProfResp getMDMConfigProfileResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfG.ProfileUUID, nil, http.StatusOK, &getProfResp)
|
||
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
|
||
require.Equal(t, &fleet.MDMConfigProfilePayload{
|
||
ProfileUUID: tm2ProfG.ProfileUUID,
|
||
TeamID: tm2ProfG.TeamID,
|
||
Name: tm2ProfG.Name,
|
||
Platform: "windows",
|
||
// labels are ordered by name
|
||
LabelsIncludeAll: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
|
||
},
|
||
}, getProfResp.MDMConfigProfilePayload)
|
||
|
||
// get the specific exclude-any label-based profile returns the information
|
||
getProfResp = getMDMConfigProfileResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfF.ProfileUUID, nil, http.StatusOK, &getProfResp)
|
||
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
|
||
require.Equal(t, &fleet.MDMConfigProfilePayload{
|
||
ProfileUUID: tm2ProfF.ProfileUUID,
|
||
TeamID: tm2ProfF.TeamID,
|
||
Name: tm2ProfF.Name,
|
||
Platform: "darwin",
|
||
Identifier: tm2ProfF.Identifier,
|
||
Checksum: tm2ProfF.Checksum,
|
||
Scope: string(fleet.PayloadScopeSystem),
|
||
// labels are ordered by name
|
||
LabelsExcludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: 0, LabelName: lblFoo.Name, Broken: true},
|
||
},
|
||
}, getProfResp.MDMConfigProfilePayload)
|
||
|
||
// get the specific include-any label-based profile returns the information
|
||
getProfResp = getMDMConfigProfileResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfH.ProfileUUID, nil, http.StatusOK, &getProfResp)
|
||
getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
|
||
require.Equal(t, &fleet.MDMConfigProfilePayload{
|
||
ProfileUUID: tm2ProfH.ProfileUUID,
|
||
TeamID: tm2ProfH.TeamID,
|
||
Name: tm2ProfH.Name,
|
||
Platform: "windows",
|
||
// labels are ordered by name
|
||
LabelsIncludeAny: []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lblBar.ID, LabelName: lblBar.Name},
|
||
{LabelID: lblBaz.ID, LabelName: lblBaz.Name},
|
||
},
|
||
}, getProfResp.MDMConfigProfilePayload)
|
||
// list for a non-existing team returns 404
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999")
|
||
|
||
cases := []struct {
|
||
queries []string // alternate query name and value
|
||
teamID *uint
|
||
wantNames []string
|
||
wantMeta *fleet.PaginationMetadata
|
||
}{
|
||
{
|
||
wantNames: []string{"A", "B", "C", "D", "E"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "2"},
|
||
wantNames: []string{"A", "B"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "2", "page", "1"},
|
||
wantNames: []string{"C", "D"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "2", "page", "2"},
|
||
wantNames: []string{"E"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "3"},
|
||
teamID: &tm1.ID,
|
||
wantNames: []string{"tA", "tB", "tC"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "3", "page", "1"},
|
||
teamID: &tm1.ID,
|
||
wantNames: []string{"tD", "tE"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "3", "page", "2"},
|
||
teamID: &tm1.ID,
|
||
wantNames: nil,
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "3"},
|
||
teamID: &tm2.ID,
|
||
wantNames: []string{"tF", "tG", "tH"},
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||
},
|
||
{
|
||
queries: []string{"per_page", "2"},
|
||
teamID: &tm3.ID,
|
||
wantNames: nil,
|
||
wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
|
||
},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) {
|
||
var listResp listMDMConfigProfilesResponse
|
||
queryArgs := c.queries
|
||
if c.teamID != nil {
|
||
queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID))
|
||
}
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, queryArgs...)
|
||
|
||
require.Equal(t, len(c.wantNames), len(listResp.Profiles))
|
||
require.Equal(t, c.wantMeta, listResp.Meta)
|
||
|
||
var gotNames []string
|
||
if len(listResp.Profiles) > 0 {
|
||
gotNames = make([]string, len(listResp.Profiles))
|
||
for i, p := range listResp.Profiles {
|
||
gotNames[i] = p.Name
|
||
if p.Name == "tG" {
|
||
require.Len(t, p.LabelsIncludeAll, 2)
|
||
} else {
|
||
require.Nil(t, p.LabelsIncludeAll)
|
||
}
|
||
if p.Name == "tF" {
|
||
require.Len(t, p.LabelsExcludeAny, 2)
|
||
} else {
|
||
require.Nil(t, p.LabelsExcludeAny)
|
||
}
|
||
if c.teamID == nil {
|
||
// we set it to 0 for global
|
||
require.NotNil(t, p.TeamID)
|
||
require.Zero(t, *p.TeamID)
|
||
} else {
|
||
require.NotNil(t, p.TeamID)
|
||
require.Equal(t, *c.teamID, *p.TeamID)
|
||
}
|
||
require.NotEmpty(t, p.Platform)
|
||
}
|
||
}
|
||
require.Equal(t, c.wantNames, gotNames)
|
||
})
|
||
}
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
|
||
require.NoError(t, err)
|
||
|
||
globalProfiles := []string{
|
||
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
|
||
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
|
||
mysql.InsertWindowsProfileForTest(t, s.ds, 0),
|
||
}
|
||
|
||
// create a new team
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
|
||
require.NoError(t, err)
|
||
teamProfiles := []string{
|
||
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
|
||
mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
|
||
}
|
||
|
||
// create a non-Windows host
|
||
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
|
||
ID: 1,
|
||
OsqueryHostID: ptr.String("non-windows-host"),
|
||
NodeKey: ptr.String("non-windows-host"),
|
||
UUID: uuid.New().String(),
|
||
Hostname: fmt.Sprintf("%sfoo.local.non.windows", t.Name()),
|
||
Platform: "darwin",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
// create a Windows host that's not enrolled into MDM
|
||
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
|
||
ID: 2,
|
||
OsqueryHostID: ptr.String("not-mdm-enrolled"),
|
||
NodeKey: ptr.String("not-mdm-enrolled"),
|
||
UUID: uuid.New().String(),
|
||
Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
|
||
Platform: "windows",
|
||
})
|
||
require.NoError(t, err)
|
||
|
||
verifyHostProfileStatus := func(cmds []fleet.ProtoCmdOperation, wantStatus string) {
|
||
for _, cmd := range cmds {
|
||
var gotProfile struct {
|
||
Status string `db:"status"`
|
||
Retries int `db:"retries"`
|
||
}
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
stmt := `
|
||
SELECT COALESCE(status, 'pending') as status, retries
|
||
FROM host_mdm_windows_profiles
|
||
WHERE command_uuid = ?`
|
||
return sqlx.GetContext(context.Background(), q, &gotProfile, stmt, cmd.Cmd.CmdID.Value)
|
||
})
|
||
|
||
wantDeliveryStatus := fleet.WindowsResponseToDeliveryStatus(wantStatus)
|
||
if gotProfile.Retries <= servermdm.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(`<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>PayloadContent</key>
|
||
<array/>`),
|
||
},
|
||
}}, http.StatusUnprocessableEntity)
|
||
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: new MDMAppleConfigProfile: plist: error parsing XML property list: XML syntax error")
|
||
|
||
// apply an empty set to no-team
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent)
|
||
// Nothing changed, so no activity items
|
||
s.lastActivityOfTypeDoesNotMatch(
|
||
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
|
||
`{"team_id": null, "team_name": null}`,
|
||
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(`<Exec></Exec>`)},
|
||
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
|
||
// invalid xml
|
||
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "N3", Contents: []byte(`foo`)},
|
||
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
|
||
// successfully apply windows and macOS a profiles for the team, but it's a dry run
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
|
||
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
|
||
{Name: "N4", Contents: declarationForTest("D1")},
|
||
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
|
||
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
|
||
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
|
||
|
||
// successfully apply for a team and verify activities
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
|
||
{Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
|
||
{Name: "N4", Contents: declarationForTest("D1")},
|
||
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
|
||
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
|
||
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
|
||
s.lastActivityOfTypeMatches(
|
||
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
|
||
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, 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, `Couldn't update. Label "no-such-label" doesn't exist. Please remove the label from the configuration profile.`)
|
||
|
||
// mix of labels fields
|
||
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name}, LabelsExcludeAny: []string{lbl2.Name}},
|
||
}}, http.StatusUnprocessableEntity)
|
||
msg = extractServerErrorText(res.Body)
|
||
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
|
||
|
||
// successful batch-set
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name, lbl2.Name}},
|
||
{Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}},
|
||
{Name: "N4", Contents: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}},
|
||
}}, http.StatusNoContent)
|
||
|
||
// confirm expected results
|
||
var listResp listMDMConfigProfilesResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
|
||
require.Len(t, listResp.Profiles, 3)
|
||
require.Equal(t, "N1", listResp.Profiles[0].Name)
|
||
require.Equal(t, "N2", listResp.Profiles[1].Name)
|
||
require.Equal(t, "N4", listResp.Profiles[2].Name)
|
||
require.Equal(t, listResp.Profiles[0].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl1.ID, LabelName: lbl1.Name},
|
||
{LabelID: lbl2.ID, LabelName: lbl2.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[0].LabelsExcludeAny)
|
||
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl1.ID, LabelName: lbl1.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
|
||
require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl2.ID, LabelName: lbl2.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[2].LabelsIncludeAll)
|
||
|
||
// successful batch-set that updates some labels
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "N1", Contents: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}},
|
||
{Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}},
|
||
}}, http.StatusNoContent)
|
||
|
||
listResp = listMDMConfigProfilesResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
|
||
require.Len(t, listResp.Profiles, 2)
|
||
require.Equal(t, "N1", listResp.Profiles[0].Name)
|
||
require.Equal(t, "N2", listResp.Profiles[1].Name)
|
||
require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl1.ID, LabelName: lbl1.Name},
|
||
{LabelID: lbl3.ID, LabelName: lbl3.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[0].LabelsIncludeAll)
|
||
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl2.ID, LabelName: lbl2.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
|
||
|
||
// names cannot be duplicated across platforms
|
||
declBytes := json.RawMessage(`{
|
||
"Type": "com.apple.configuration.decl.foo",
|
||
"Identifier": "com.fleet.config.foo",
|
||
"Payload": {
|
||
"ServiceType": "com.apple.bash",
|
||
"DataAssetReference": "com.fleet.asset.bash"
|
||
}}`)
|
||
mcBytes := mobileconfigForTest("N1", "I1")
|
||
winBytes := syncMLForTest("./Foo/Bar")
|
||
|
||
for _, p := range []struct {
|
||
payload []fleet.MDMProfileBatchPayload
|
||
expectErr string
|
||
}{
|
||
{
|
||
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: winBytes}},
|
||
expectErr: "More than one configuration profile have the same name 'N1'",
|
||
},
|
||
{
|
||
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}},
|
||
expectErr: "More than one configuration profile have the same name 'N1'",
|
||
},
|
||
{
|
||
payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}},
|
||
expectErr: "More than one configuration profile have the same name 'N1'",
|
||
},
|
||
} {
|
||
// team profiles
|
||
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload},
|
||
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, p.expectErr)
|
||
// no team profiles
|
||
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, p.expectErr)
|
||
}
|
||
}
|
||
|
||
// This tests the new public API endpoint for batch modifying MDM profiles
|
||
func (s *integrationMDMTestSuite) TestBatchModifyMDMProfiles() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
// create a new team
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
|
||
require.NoError(t, err)
|
||
|
||
bigString := strings.Repeat("a", 1024*1024+1)
|
||
|
||
// Profile is too big
|
||
resp := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{{Profile: []byte(bigString)}}},
|
||
http.StatusUnprocessableEntity)
|
||
require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB")
|
||
|
||
// apply an empty set to no-team
|
||
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil}, http.StatusNoContent)
|
||
// Nothing changed, so no activity items
|
||
s.lastActivityOfTypeDoesNotMatch(
|
||
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
|
||
`{"team_id": null, "team_name": null}`,
|
||
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(`<Exec></Exec>`)},
|
||
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
|
||
// invalid xml
|
||
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
|
||
{DisplayName: "N3", Profile: []byte(`foo`)},
|
||
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
|
||
// successfully apply windows and macOS a profiles for the team, but it's a dry run
|
||
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
|
||
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")},
|
||
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")},
|
||
{DisplayName: "N4", Profile: declarationForTest("D1")},
|
||
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
|
||
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
|
||
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
|
||
|
||
// successfully apply for a team and verify activities
|
||
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
|
||
{DisplayName: "NotRelevant", Profile: mobileconfigForTest("N1", "I1")}, // Check that we don't care about displayname for mobileconfig profiles
|
||
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")},
|
||
{DisplayName: "N4", Profile: declarationForTest("D1")},
|
||
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
|
||
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
|
||
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
|
||
s.lastActivityOfTypeMatches(
|
||
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
|
||
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, 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, `Couldn't update. Label "no-such-label" doesn't exist. Please remove the label from the configuration profile.`)
|
||
|
||
// mix of labels fields
|
||
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
|
||
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name}, LabelsExcludeAny: []string{lbl2.Name}},
|
||
}}, http.StatusUnprocessableEntity)
|
||
msg = extractServerErrorText(res.Body)
|
||
require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`)
|
||
|
||
// successful batch-set
|
||
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
|
||
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAny: []string{lbl1.Name, lbl2.Name}},
|
||
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}},
|
||
{DisplayName: "N4", Profile: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}},
|
||
}}, http.StatusNoContent)
|
||
|
||
// confirm expected results
|
||
var listResp listMDMConfigProfilesResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
|
||
require.Len(t, listResp.Profiles, 3)
|
||
require.Equal(t, "N1", listResp.Profiles[0].Name)
|
||
require.Equal(t, "N2", listResp.Profiles[1].Name)
|
||
require.Equal(t, "N4", listResp.Profiles[2].Name)
|
||
require.Equal(t, listResp.Profiles[0].LabelsIncludeAny, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl1.ID, LabelName: lbl1.Name},
|
||
{LabelID: lbl2.ID, LabelName: lbl2.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[0].LabelsExcludeAny)
|
||
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl1.ID, LabelName: lbl1.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
|
||
require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl2.ID, LabelName: lbl2.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[2].LabelsIncludeAll)
|
||
|
||
// successful batch-set that updates some labels
|
||
s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{
|
||
{DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}},
|
||
{DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}},
|
||
}}, http.StatusNoContent)
|
||
|
||
listResp = listMDMConfigProfilesResponse{}
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp)
|
||
require.Len(t, listResp.Profiles, 2)
|
||
require.Equal(t, "N1", listResp.Profiles[0].Name)
|
||
require.Equal(t, "N2", listResp.Profiles[1].Name)
|
||
require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl1.ID, LabelName: lbl1.Name},
|
||
{LabelID: lbl3.ID, LabelName: lbl3.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[0].LabelsIncludeAll)
|
||
require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{
|
||
{LabelID: lbl2.ID, LabelName: lbl2.Name},
|
||
})
|
||
require.Nil(t, listResp.Profiles[1].LabelsExcludeAny)
|
||
|
||
// names cannot be duplicated across platforms
|
||
declBytes := json.RawMessage(`{
|
||
"Type": "com.apple.configuration.decl.foo",
|
||
"Identifier": "com.fleet.config.foo",
|
||
"Payload": {
|
||
"ServiceType": "com.apple.bash",
|
||
"DataAssetReference": "com.fleet.asset.bash"
|
||
}}`)
|
||
mcBytes := mobileconfigForTest("N1", "I1")
|
||
winBytes := syncMLForTest("./Foo/Bar")
|
||
|
||
for _, p := range []struct {
|
||
payload []fleet.BatchModifyMDMConfigProfilePayload
|
||
expectErr string
|
||
}{
|
||
{
|
||
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: winBytes}},
|
||
expectErr: "More than one configuration profile have the same name 'N1'",
|
||
},
|
||
{
|
||
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: declBytes}, {DisplayName: "N1", Profile: winBytes}},
|
||
expectErr: "More than one configuration profile have the same name 'N1'",
|
||
},
|
||
{
|
||
payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: declBytes}},
|
||
expectErr: "More than one configuration profile have the same name 'N1'",
|
||
},
|
||
} {
|
||
// team profiles
|
||
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload},
|
||
http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, p.expectErr)
|
||
// no team profiles
|
||
res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload}, http.StatusUnprocessableEntity)
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, p.expectErr)
|
||
}
|
||
|
||
// Get the current list of configuration profiles
|
||
var currentProfiles listMDMConfigProfilesResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, ¤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(`<Exec></Exec>`),
|
||
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
|
||
// invalid xml
|
||
res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
|
||
"N3": []byte(`foo`),
|
||
}}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID))
|
||
errMsg = extractServerErrorText(res.Body)
|
||
require.Contains(t, errMsg, "Windows configuration profiles can only have <Replace> or <Add> top level elements.")
|
||
|
||
// successfully apply windows and macOS a profiles for the team, but it's a dry run
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
|
||
"N1": mobileconfigForTest("N1", "I1"),
|
||
"N2": syncMLForTest("./Foo/Bar"),
|
||
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true")
|
||
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
|
||
s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
|
||
|
||
// successfully apply for a team and verify activities
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
|
||
"N1": mobileconfigForTest("N1", "I1"),
|
||
"N2": syncMLForTest("./Foo/Bar"),
|
||
}}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID))
|
||
s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
|
||
s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
|
||
s.lastActivityOfTypeMatches(
|
||
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
|
||
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, 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(`<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>PayloadContent</key>
|
||
<array/>
|
||
<key>PayloadDisplayName</key>
|
||
<string>badSecrets</string>
|
||
<key>PayloadIdentifier</key>
|
||
<string>badSecrets.One</string>
|
||
<key>PayloadType</key>
|
||
<string>Configuration</string>
|
||
<key>PayloadUUID</key>
|
||
<string>$FLEET_SECRET_INVALID.35E2029E-A0C2-4754-B709-4CAAB1B8D3CB</string>
|
||
<key>PayloadVersion</key>
|
||
<integer>1</integer>
|
||
</dict>
|
||
</plist>
|
||
`),
|
||
}
|
||
|
||
body, headers = generateNewReq("badSecrets", nil)
|
||
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusUnprocessableEntity, headers)
|
||
errMsg := extractServerErrorText(newResp.Body)
|
||
require.Contains(t, errMsg, "$FLEET_SECRET_INVALID")
|
||
|
||
// trying to add/delete profiles with identifiers managed by Fleet fails
|
||
for p := range mobileconfig.FleetPayloadIdentifiers() {
|
||
generateTestProfile("TestNoTeam", p)
|
||
body, headers := generateNewReq("TestNoTeam", nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
|
||
generateTestProfile("TestWithTeamID", p)
|
||
body, headers = generateNewReq("TestWithTeamID", nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
|
||
require.NoError(t, err)
|
||
testProfiles["WithContent"] = *cp
|
||
body, headers = generateNewReq("WithContent", nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
}
|
||
|
||
// trying to add profiles with identifiers managed by Fleet fails
|
||
for p := range mobileconfig.FleetPayloadIdentifiers() {
|
||
generateTestProfile("TestNoTeam", p)
|
||
body, headers := generateNewReq("TestNoTeam", nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
|
||
generateTestProfile("TestWithTeamID", p)
|
||
body, headers = generateNewReq("TestWithTeamID", nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
|
||
require.NoError(t, err)
|
||
testProfiles["WithContent"] = *cp
|
||
body, headers = generateNewReq("WithContent", nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
}
|
||
|
||
// trying to add profiles with names reserved by Fleet fails
|
||
for name := range servermdm.FleetReservedProfileNames() {
|
||
cp := &fleet.MDMAppleConfigProfile{
|
||
Name: name,
|
||
Identifier: "valid.identifier",
|
||
Mobileconfig: mcBytesForTest(name, "valid.identifier", "some-uuid"),
|
||
}
|
||
body, headers := generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
|
||
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
|
||
"team_id": {fmt.Sprintf("%d", testTeam.ID)},
|
||
})
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
|
||
cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent(
|
||
"valid outer name",
|
||
"valid.outer.identifier",
|
||
"valid.inner.identifer",
|
||
"some-uuid",
|
||
name,
|
||
), nil)
|
||
require.NoError(t, err)
|
||
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
|
||
cp.TeamID = &testTeam.ID
|
||
body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
|
||
"team_id": {fmt.Sprintf("%d", testTeam.ID)},
|
||
})
|
||
|
||
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
|
||
}
|
||
|
||
// make fleet add a FileVault profile
|
||
acResp := appConfigResponse{}
|
||
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
|
||
"mdm": { "enable_disk_encryption": true }
|
||
}`), http.StatusOK, &acResp)
|
||
assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
|
||
profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
|
||
|
||
// try to delete the profile
|
||
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", profile.ProfileID)
|
||
deleteResp = deleteMDMAppleConfigProfileResponse{}
|
||
s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() {
|
||
t := s.T()
|
||
s.setSkipWorkerJobs(t)
|
||
ctx := context.Background()
|
||
|
||
triggerReconcileProfiles := func() {
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// this will only mark them as "pending", as the response to confirm
|
||
// profile deployment is asynchronous, so we simulate it here by
|
||
// updating any "pending" (not NULL) profiles to "verifying"
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
|
||
return err
|
||
}
|
||
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
|
||
return err
|
||
}
|
||
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// run the crons immediately, will create the Fleet-controlled profiles that
|
||
// will then be expected to be applied (e.g. com.fleetdm.fleetd.config and
|
||
// com.fleetdm.caroot)
|
||
// first create the no-team enroll secret (required to create the fleet profiles)
|
||
var applyResp applyEnrollSecretSpecResponse
|
||
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
|
||
applyEnrollSecretSpecRequest{
|
||
Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}},
|
||
}, http.StatusOK, &applyResp)
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// create an Apple and a Windows host
|
||
appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
|
||
// create a few labels
|
||
labels := make([]*fleet.Label, 5)
|
||
for i := 0; i < len(labels); i++ {
|
||
label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"})
|
||
require.NoError(t, err)
|
||
labels[i] = label
|
||
}
|
||
// simulate reporting label results for those hosts
|
||
appleHost.LabelUpdatedAt = time.Now()
|
||
windowsHost.LabelUpdatedAt = time.Now()
|
||
err := s.ds.UpdateHost(ctx, appleHost)
|
||
require.NoError(t, err)
|
||
err = s.ds.UpdateHost(ctx, windowsHost)
|
||
require.NoError(t, err)
|
||
|
||
// set an Apple profile and declaration and a Windows profile
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsExcludeAny: []string{labels[0].Name, labels[1].Name}},
|
||
{Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsExcludeAny: []string{labels[2].Name, labels[3].Name}},
|
||
{Name: "D3", Contents: declarationForTest("D3"), LabelsExcludeAny: []string{labels[4].Name}},
|
||
}}, http.StatusNoContent)
|
||
|
||
// hosts are not members of any label yet, so running the cron applies the labels
|
||
s.awaitTriggerProfileSchedule(t)
|
||
s.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()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
triggerReconcileProfiles := func() {
|
||
s.awaitTriggerProfileSchedule(t)
|
||
// this will only mark them as "pending", as the response to confirm
|
||
// profile deployment is asynchronous, so we simulate it here by
|
||
// updating any "pending" (not NULL) profiles to "verifying"
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
|
||
return err
|
||
}
|
||
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
|
||
return err
|
||
}
|
||
if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil {
|
||
return err
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// run the crons immediately, will create the Fleet-controlled profiles that
|
||
// will then be expected to be applied (e.g. com.fleetdm.fleetd.config and
|
||
// com.fleetdm.caroot)
|
||
// first create the no-team enroll secret (required to create the fleet profiles)
|
||
var applyResp applyEnrollSecretSpecResponse
|
||
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
|
||
applyEnrollSecretSpecRequest{
|
||
Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}},
|
||
}, http.StatusOK, &applyResp)
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// create an Apple and a Windows host
|
||
appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
|
||
// 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 := `
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||
<plist version="1.0">
|
||
<dict>
|
||
<key>PayloadDescription</key>
|
||
<string>For secret variables</string>
|
||
<key>PayloadDisplayName</key>
|
||
<string>secret-config%d</string>
|
||
<key>PayloadIdentifier</key>
|
||
<string>PI%d</string>
|
||
<key>PayloadType</key>
|
||
<string>Configuration</string>
|
||
<key>PayloadUUID</key>
|
||
<string>%d</string>
|
||
<key>PayloadVersion</key>
|
||
<integer>1</integer>
|
||
<key>PayloadContent</key>
|
||
<array>
|
||
<dict>
|
||
<key>Bash</key>
|
||
<string>$FLEET_SECRET_BASH</string>
|
||
<key>PayloadDisplayName</key>
|
||
<string>secret payload</string>
|
||
<key>PayloadIdentifier</key>
|
||
<string>com.test.secret</string>
|
||
<key>PayloadType</key>
|
||
<string>com.test.secretd</string>
|
||
<key>PayloadUUID</key>
|
||
<string>476F5334-D501-4768-9A31-1A18A4E1E808</string>
|
||
<key>PayloadVersion</key>
|
||
<integer>1</integer>
|
||
</dict>
|
||
</array>
|
||
</dict>
|
||
</plist>`
|
||
|
||
newProfileBytes := func(i int) []byte {
|
||
return []byte(fmt.Sprintf(tmpl, i, i, i))
|
||
}
|
||
|
||
getProfileContents := func(profileUUID string) string {
|
||
profile, err := s.ds.GetMDMAppleConfigProfile(context.Background(), profileUUID)
|
||
require.NoError(s.T(), err)
|
||
assert.NotNil(s.T(), profile.SecretsUpdatedAt)
|
||
return string(profile.Mobileconfig)
|
||
}
|
||
|
||
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "mobileconfig", "darwin")
|
||
}
|
||
|
||
// TestWindowsConfigSecretVariablesUpload tests uploading Windows profiles with secrets via the /configuration_profiles endpoint
|
||
func (s *integrationMDMTestSuite) TestWindowsConfigSecretVariablesUpload() {
|
||
tmpl := `
|
||
<Replace>
|
||
<Item>
|
||
<Meta>
|
||
<Format xmlns="syncml:metinf">int</Format>
|
||
</Meta>
|
||
<Target>
|
||
<LocURI>./Device/Vendor/MSFT/Policy/Config/System/DisableOneDriveFileSync</LocURI>
|
||
</Target>
|
||
<Data>$FLEET_SECRET_BASH</Data>
|
||
</Item>
|
||
</Replace>
|
||
`
|
||
|
||
newProfileBytes := func(i int) []byte {
|
||
return []byte(fmt.Sprintf(tmpl, i, i, i))
|
||
}
|
||
|
||
getProfileContents := func(profileUUID string) string {
|
||
profile, err := s.ds.GetMDMWindowsConfigProfile(context.Background(), profileUUID)
|
||
require.NoError(s.T(), err)
|
||
return string(profile.SyncML)
|
||
}
|
||
|
||
s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "xml", "windows")
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestAppleProfileDeletion() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
|
||
require.NoError(t, err)
|
||
|
||
globalProfiles := [][]byte{
|
||
mobileconfigForTest("N1", "I1"),
|
||
}
|
||
wantGlobalProfiles := globalProfiles
|
||
wantGlobalProfiles = append(
|
||
wantGlobalProfiles,
|
||
setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
|
||
)
|
||
|
||
// add global profiles
|
||
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
|
||
|
||
// 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. This profile has the same \"PayloadIdentifier\" but a different \"PayloadScope\" as another profile in a separate team.")
|
||
|
||
// Test a conflict of a profile on a team with an existing global profile
|
||
// Should error because "G2" conflicts with global "G2" profile
|
||
newTm1Profiles = [][]byte{
|
||
tm1Profiles[0], // T1.1
|
||
tm1Profiles[1], // T1.2
|
||
tm1Profiles[2], // T1.3.user
|
||
scopedMobileconfigForTest("G2", "G2", &payloadScopeUser),
|
||
}
|
||
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
|
||
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
|
||
"team_id", fmt.Sprint(tm1.ID))
|
||
|
||
errMsg = extractServerErrorText(response.Body)
|
||
require.Contains(t, errMsg, "Couldn't add configuration profile. This profile has the same \"PayloadIdentifier\" but a different \"PayloadScope\" as another profile in a separate team.")
|
||
|
||
// Test a conflict of a profile on a team versus one with the same identifier but different
|
||
// scope on a different team.
|
||
// Should error because "T2.3.user" system-scoped profile conflicts with team2 "T2.3.user" user-scoped profile
|
||
newTm1Profiles = [][]byte{
|
||
tm1Profiles[0], // T1.1
|
||
tm1Profiles[1], // T1.2
|
||
tm1Profiles[2], // T1.3.user
|
||
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeSystem), // T2.3.user changed to system scope
|
||
}
|
||
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
|
||
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
|
||
"team_id", fmt.Sprint(tm1.ID))
|
||
|
||
errMsg = extractServerErrorText(response.Body)
|
||
require.Contains(t, errMsg, "Couldn't add configuration profile. This profile has the same \"PayloadIdentifier\" but a different \"PayloadScope\" as another profile in a separate team.")
|
||
|
||
// Profile edit of existing profile on team1 with a new scope
|
||
newTm1Profiles = [][]byte{
|
||
tm1Profiles[0], // T1.1
|
||
tm1Profiles[1], // T1.2
|
||
scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeSystem), // T1.3.user changed to system scope
|
||
}
|
||
response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
|
||
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest,
|
||
"team_id", fmt.Sprint(tm1.ID))
|
||
|
||
errMsg = extractServerErrorText(response.Body)
|
||
require.Contains(t, errMsg, "Couldn't edit configuration profile (T1.3.user) because the profile's \"PayloadScope\" has changed")
|
||
|
||
// Should be able to add these profiles to team1 with the proper scopes
|
||
newTm1Profiles = [][]byte{
|
||
tm1Profiles[0], // T1.1
|
||
tm1Profiles[1], // T1.2
|
||
tm1Profiles[2], // T1.3.user
|
||
scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem),
|
||
scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), // T2.3.user changed to system scope
|
||
}
|
||
|
||
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
|
||
batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusNoContent,
|
||
"team_id", fmt.Sprint(tm1.ID))
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestWindowsProfilesWithFleetVariables() {
|
||
t := s.T()
|
||
ctx := t.Context()
|
||
|
||
// Create a team for team-scoped tests
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test_windows_fleet_vars"})
|
||
require.NoError(t, err)
|
||
|
||
testCases := []struct {
|
||
name string
|
||
profiles []fleet.MDMProfileBatchPayload
|
||
teamID *uint
|
||
wantStatus int
|
||
wantErrContains string
|
||
}{
|
||
{
|
||
name: "HOST_UUID variable accepted for team",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "TestHostUUID",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusNoContent,
|
||
},
|
||
{
|
||
name: "HOST_UUID variable with braces accepted",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "TestHostUUIDBraces",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "${FLEET_VAR_HOST_UUID}"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusNoContent,
|
||
},
|
||
{
|
||
name: "unsupported variable rejected",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "TestUnsupported",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Serial", Data: "$FLEET_VAR_HOST_FAKE"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusUnprocessableEntity,
|
||
wantErrContains: "Fleet variable $FLEET_VAR_HOST_FAKE is not supported in Windows profiles",
|
||
},
|
||
{
|
||
name: "mixed supported and unsupported variables rejected",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "TestMixed",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Email", Data: "$FLEET_VAR_BOGUS"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusUnprocessableEntity,
|
||
wantErrContains: "Fleet variable $FLEET_VAR_BOGUS is not supported in Windows profiles",
|
||
},
|
||
{
|
||
name: "HOST_UUID variable accepted globally",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "GlobalHostUUID",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: nil, // global profile
|
||
wantStatus: http.StatusNoContent,
|
||
},
|
||
{
|
||
name: "batch with regular and variable profiles accepted",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "RegularProfile",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"},
|
||
}),
|
||
},
|
||
{
|
||
Name: "ProfileWithVar",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusNoContent,
|
||
},
|
||
{
|
||
name: "multiple HOST_UUID variables in single profile accepted",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "MultipleHostUUID",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"},
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/BackupID", Data: "${FLEET_VAR_HOST_UUID}"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusNoContent,
|
||
},
|
||
{
|
||
name: "unknown Fleet variable rejected",
|
||
profiles: []fleet.MDMProfileBatchPayload{
|
||
{
|
||
Name: "UnknownVar",
|
||
Contents: syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/SomeValue", Data: "${FLEET_VAR_UNKNOWN_VAR}"},
|
||
}),
|
||
},
|
||
},
|
||
teamID: &tm.ID,
|
||
wantStatus: http.StatusUnprocessableEntity,
|
||
wantErrContains: "Fleet variable $FLEET_VAR_UNKNOWN_VAR is not supported in Windows profiles",
|
||
},
|
||
}
|
||
|
||
for _, tc := range testCases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
var resp *http.Response
|
||
|
||
// Execute request with or without team_id
|
||
if tc.teamID != nil {
|
||
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||
batchSetMDMProfilesRequest{Profiles: tc.profiles},
|
||
tc.wantStatus,
|
||
"team_id", fmt.Sprint(*tc.teamID))
|
||
} else {
|
||
resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||
batchSetMDMProfilesRequest{Profiles: tc.profiles},
|
||
tc.wantStatus)
|
||
}
|
||
|
||
// Check error message if expected
|
||
if tc.wantErrContains != "" {
|
||
errMsg := extractServerErrorText(resp.Body)
|
||
require.Contains(t, errMsg, tc.wantErrContains)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestWindowsProfilesFleetVariableSubstitution() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
|
||
// Create a team
|
||
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team"})
|
||
require.NoError(t, err)
|
||
|
||
// Create and enroll three Windows hosts (two global, one in team)
|
||
hostGlobal1, device1 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
hostGlobal2, device2 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
hostTeam, deviceTeam := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
|
||
// Add the team host to the team
|
||
err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hostTeam.ID}))
|
||
require.NoError(t, err)
|
||
|
||
// Create profiles with HOST_UUID variable for global and team
|
||
globalProfile := syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "Device ID: $FLEET_VAR_HOST_UUID"},
|
||
})
|
||
teamProfile := syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/TeamDevice/ID", Data: "Team Device: ${FLEET_VAR_HOST_UUID}"},
|
||
})
|
||
|
||
// Upload global profile
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "GlobalProfileWithVar", Contents: globalProfile},
|
||
}},
|
||
http.StatusNoContent)
|
||
|
||
// Upload team profile
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "TeamProfileWithVar", Contents: teamProfile},
|
||
}},
|
||
http.StatusNoContent,
|
||
"team_id", fmt.Sprint(tm.ID))
|
||
|
||
// Helper to verify profile contains substituted UUID
|
||
verifyProfileSubstitution := func(device *mdmtest.TestWindowsMDMClient, expectedUUID string, expectedData string) {
|
||
s.awaitTriggerProfileSchedule(t)
|
||
cmds, err := device.StartManagementSession()
|
||
require.NoError(t, err)
|
||
|
||
// Find the Atomic command containing the profile
|
||
var foundProfile bool
|
||
msgID, err := device.GetCurrentMsgID()
|
||
require.NoError(t, err)
|
||
|
||
for _, cmd := range cmds {
|
||
// Send status response for each command
|
||
device.AppendResponse(fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &cmd.Cmd.CmdID.Value,
|
||
Cmd: ptr.String(cmd.Verb),
|
||
Data: ptr.String(syncml.CmdStatusOK),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
})
|
||
|
||
if cmd.Verb == "Atomic" {
|
||
// Check if the command contains our expected data with UUID substituted
|
||
for _, replaceCmd := range cmd.Cmd.ReplaceCommands {
|
||
for _, item := range replaceCmd.Items {
|
||
if item.Data != nil && item.Data.Content != "" {
|
||
if strings.Contains(item.Data.Content, expectedData) {
|
||
// Verify the UUID was substituted correctly
|
||
require.Contains(t, item.Data.Content, expectedUUID)
|
||
require.NotContains(t, item.Data.Content, "$FLEET_VAR_HOST_UUID")
|
||
require.NotContains(t, item.Data.Content, "${FLEET_VAR_HOST_UUID}")
|
||
foundProfile = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
require.True(t, foundProfile, "Expected profile with UUID substitution not found")
|
||
|
||
// Send the response to complete the session
|
||
cmds, err = device.SendResponse()
|
||
require.NoError(t, err)
|
||
// the ack of the message should be the only returned command
|
||
require.Len(t, cmds, 1)
|
||
}
|
||
|
||
// Verify global hosts receive profile with their UUID substituted
|
||
verifyProfileSubstitution(device1, hostGlobal1.UUID, "Device ID: "+hostGlobal1.UUID)
|
||
verifyProfileSubstitution(device2, hostGlobal2.UUID, "Device ID: "+hostGlobal2.UUID)
|
||
|
||
// Verify team host receives team profile with UUID substituted
|
||
verifyProfileSubstitution(deviceTeam, hostTeam.UUID, "Team Device: "+hostTeam.UUID)
|
||
|
||
// Check that profile statuses are updated correctly in the database
|
||
checkHostProfileStatus := func(hostUUID string, profileName string, expectedStatus fleet.MDMDeliveryStatus) {
|
||
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, hostUUID)
|
||
require.NoError(t, err)
|
||
|
||
// Find the specific profile by name
|
||
var foundProfile *fleet.HostMDMWindowsProfile
|
||
for _, p := range profiles {
|
||
if p.Name == profileName {
|
||
foundProfile = &p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, foundProfile, "Profile %s not found for host %s", profileName, hostUUID)
|
||
require.NotNil(t, foundProfile.Status, "Profile %s status is nil for host %s", profileName, hostUUID)
|
||
assert.Equal(t, expectedStatus, *foundProfile.Status, "Profile %s has unexpected status for host %s", profileName, hostUUID)
|
||
}
|
||
|
||
checkHostProfileStatus(hostGlobal1.UUID, "GlobalProfileWithVar", fleet.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_<CA_NAME>, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_<CA_NAME>, and $FLEET_VAR_SCEP_RENEWAL_ID variables")
|
||
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "WindowsSCEPProfile", Contents: windowsScepProfile},
|
||
}},
|
||
http.StatusNoContent)
|
||
|
||
// Verify host receives the profile
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// Check that profile status is Pending
|
||
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
var foundProfile bool
|
||
for _, p := range profiles {
|
||
if p.Name == "WindowsSCEPProfile" {
|
||
foundProfile = true
|
||
require.NotNil(t, p.Status)
|
||
assert.EqualValues(t, fleet.MDMDeliveryPending, *p.Status)
|
||
}
|
||
}
|
||
require.True(t, foundProfile, "WindowsSCEPProfile not found for host")
|
||
|
||
verifyCommands(1, syncml.CmdStatusOK)
|
||
|
||
// Verify profile status is Verified due to successful response
|
||
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
foundProfile = false
|
||
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, "<key>ShowRecoveryKey</key>", "<key>TestVariable</key>", 1)
|
||
profileContent = strings.Replace(profileContent, "<false/>", "<string>$FLEET_VAR_HOST_END_USER_IDP_USERNAME</string>", 1)
|
||
profileWithIDPVar = []byte(profileContent)
|
||
|
||
// Upload the profile using the new endpoint that supports variables
|
||
s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: "TestProfile", Contents: profileWithIDPVar},
|
||
}}, http.StatusNoContent) // Setup SCIM user data for the host
|
||
|
||
// No profiles until reconciler
|
||
hostProfiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
require.Empty(t, hostProfiles, "Host should not have any profiles before sync")
|
||
|
||
// Trigger initial profile sync - profile should be set to pending/installing
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// Check that install command was sent, but do not acknowledge it yet
|
||
cmd, err := mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
|
||
seenProfile := false
|
||
profileCmdID := ""
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType != "InstallProfile" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
var fullCmd micromdm.CommandPayload
|
||
err = plist.Unmarshal(cmd.Raw, &fullCmd)
|
||
require.NoError(t, err)
|
||
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
|
||
seenProfile = true
|
||
profileCmdID = cmd.CommandUUID
|
||
break
|
||
}
|
||
|
||
// Acknowledge other commands
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
require.True(t, seenProfile, "Expected install command for TestProfile not found")
|
||
|
||
// Verify profile is in pending status
|
||
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
|
||
var testProfile *fleet.HostMDMAppleProfile
|
||
for _, p := range hostProfiles {
|
||
if p.Identifier == "com.test.profile" {
|
||
testProfile = &p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, testProfile)
|
||
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status)
|
||
|
||
// Now simulate the race condition:
|
||
// we trigger a resend before the acknowledgement comes back
|
||
|
||
// 1. Trigger an IDP variable change by updating SCIM user
|
||
err = s.ds.ReplaceScimUser(ctx, &fleet.ScimUser{ID: scimUserID, UserName: "newuser@example.com"})
|
||
require.NoError(t, err)
|
||
|
||
// 2. At this point, the profile should be marked for resend (status = NULL)
|
||
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
|
||
for _, p := range hostProfiles {
|
||
if p.Identifier == "com.test.profile" {
|
||
fmt.Printf("%v\n", p.Status)
|
||
testProfile = &p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, testProfile)
|
||
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status) // Should be NULL (pending for the user)
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
var status *fleet.MDMDeliveryStatus
|
||
err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier)
|
||
require.Nil(t, status)
|
||
return err
|
||
})
|
||
|
||
// Acknowledge the original install command now, simulating the device response
|
||
cmd, err = mdmDevice.Acknowledge(profileCmdID)
|
||
require.NoError(t, err)
|
||
|
||
// Now check if we see any new TestProfile cmds (we need to ack them here, since we might have skipped some above.)
|
||
seenProfile = false
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType != "InstallProfile" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
var fullCmd micromdm.CommandPayload
|
||
err = plist.Unmarshal(cmd.Raw, &fullCmd)
|
||
require.NoError(t, err)
|
||
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
|
||
seenProfile = true
|
||
// Acknowledge the resend command
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
} else {
|
||
// Acknowledge other commands
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
}
|
||
require.Nil(t, cmd, "No further commands should be pending after acknowledging install")
|
||
require.False(t, seenProfile, "No resend install command for TestProfile should be sent due to race condition")
|
||
|
||
// Verify the profile is still in pending status (null in the DB)
|
||
// aka. no race condition.
|
||
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
|
||
for _, p := range hostProfiles {
|
||
if p.Identifier == "com.test.profile" {
|
||
testProfile = &p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, testProfile)
|
||
require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status)
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
var status *fleet.MDMDeliveryStatus
|
||
err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier)
|
||
require.Nil(t, status)
|
||
return err
|
||
})
|
||
|
||
// run reconciler to resend any pending profiles
|
||
s.awaitTriggerProfileSchedule(t)
|
||
cmd, err = mdmDevice.Idle()
|
||
require.NoError(t, err)
|
||
// Now we should see the resend command
|
||
seenProfile = false
|
||
for cmd != nil {
|
||
if cmd.Command.RequestType != "InstallProfile" {
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
continue
|
||
}
|
||
var fullCmd micromdm.CommandPayload
|
||
err = plist.Unmarshal(cmd.Raw, &fullCmd)
|
||
require.NoError(t, err)
|
||
if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") {
|
||
seenProfile = true
|
||
// Acknowledge the resend command
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
} else {
|
||
// Acknowledge other commands
|
||
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
|
||
require.NoError(t, err)
|
||
}
|
||
}
|
||
require.True(t, seenProfile, "Resend install command for TestProfile should be sent after reconciler runs")
|
||
|
||
// And we should now also see the profile being marked as verifying
|
||
hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
for _, p := range hostProfiles {
|
||
if p.Identifier == "com.test.profile" {
|
||
testProfile = &p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, testProfile)
|
||
require.EqualValues(t, fleet.MDMDeliveryVerifying, *testProfile.Status)
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestWindowsProfileRetry() {
|
||
t := s.T()
|
||
ctx := t.Context()
|
||
|
||
// Create a host and enroll it in MDM
|
||
host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
|
||
|
||
t.Run("Command gets retried with Replace after 418", func(t *testing.T) {
|
||
profilePayload := syncml.ForTestWithData([]syncml.TestCommand{
|
||
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"},
|
||
})
|
||
profilePayloadNonAtomic := syncml.ForTestWithDataNonAtomic([]syncml.TestCommand{
|
||
{Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AtomicLocation", Data: "1"},
|
||
})
|
||
|
||
profileName := "RetryProfile"
|
||
s.Do("POST", "/api/v1/fleet/mdm/profiles/batch",
|
||
batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
|
||
{Name: profileName, Contents: profilePayload},
|
||
{Name: profileName + "NonAtomic", Contents: profilePayloadNonAtomic},
|
||
}},
|
||
http.StatusNoContent)
|
||
|
||
expectRetry := func(profileName string, expectedRetries int) {
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
var retryCount int
|
||
err := sqlx.GetContext(t.Context(), q, &retryCount,
|
||
`SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
|
||
host.UUID, profileName)
|
||
require.NoError(t, err)
|
||
require.Equal(t, expectedRetries, retryCount, "Unexpected retry count for profile %s", profileName)
|
||
return nil
|
||
})
|
||
}
|
||
|
||
// Trigger profile schedule
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
// Get initial host profile
|
||
profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
var initialProfile fleet.HostMDMWindowsProfile
|
||
for _, p := range profiles {
|
||
if p.Name == profileName {
|
||
initialProfile = p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, initialProfile)
|
||
|
||
require.Equal(t, fleet.MDMDeliveryPending, *initialProfile.Status)
|
||
|
||
cmds, err := mdmDevice.StartManagementSession()
|
||
require.NoError(t, err)
|
||
msgID, err := mdmDevice.GetCurrentMsgID()
|
||
require.NoError(t, err)
|
||
for _, cmd := range cmds {
|
||
if cmd.Verb == "Status" {
|
||
continue
|
||
}
|
||
|
||
var syncCmd fleet.SyncMLCmd
|
||
if cmd.Verb == "Atomic" {
|
||
syncCmd = fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &cmd.Cmd.CmdID.Value,
|
||
Cmd: ptr.String(cmd.Verb),
|
||
Data: ptr.String(syncml.CmdStatusAtomicFailed),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
}
|
||
} else {
|
||
syncCmd = fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &cmd.Cmd.CmdID.Value,
|
||
Cmd: ptr.String(cmd.Verb),
|
||
Data: ptr.String(syncml.CmdStatusAlreadyExists),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
}
|
||
}
|
||
mdmDevice.AppendResponse(syncCmd)
|
||
|
||
for _, addCmd := range cmd.Cmd.AddCommands {
|
||
for range addCmd.Items {
|
||
itemCmd := fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &addCmd.CmdID.Value,
|
||
Cmd: ptr.String(fleet.CmdStatus),
|
||
// 418 triggers Replace resend logic
|
||
Data: ptr.String(syncml.CmdStatusAlreadyExists),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
}
|
||
mdmDevice.AppendResponse(itemCmd)
|
||
}
|
||
}
|
||
}
|
||
cmds, err = mdmDevice.SendResponse() // we have atomic replace and normal replace (resend after 418 attempt in this cmd list here)
|
||
require.NoError(t, err)
|
||
require.Len(t, cmds, 3) // status + atomic replace + replace
|
||
|
||
// After initial 418 resend: pending, empty detail, retries = 0.
|
||
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
var updatedProfile fleet.HostMDMWindowsProfile
|
||
for _, p := range profiles {
|
||
if p.Name == profileName {
|
||
updatedProfile = p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, updatedProfile)
|
||
require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status)
|
||
require.Empty(t, updatedProfile.Detail)
|
||
expectRetry(profileName, 0)
|
||
|
||
// Second session: fail Atomic to trigger normal retry (status NULL, retries++ -> 1).
|
||
cmds, err = mdmDevice.StartManagementSession()
|
||
require.NoError(t, err)
|
||
msgID, err = mdmDevice.GetCurrentMsgID()
|
||
require.NoError(t, err)
|
||
for _, cmd := range cmds {
|
||
if cmd.Verb == "Status" {
|
||
continue
|
||
}
|
||
syncCmd := fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &cmd.Cmd.CmdID.Value,
|
||
Cmd: ptr.String(cmd.Verb),
|
||
Data: ptr.String(syncml.CmdStatusAtomicFailed),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
}
|
||
mdmDevice.AppendResponse(syncCmd)
|
||
}
|
||
_, err = mdmDevice.SendResponse()
|
||
require.NoError(t, err)
|
||
|
||
// Verify raw DB status is NULL and retries = 1.
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
var status sql.NullString
|
||
var retries int
|
||
err := sqlx.GetContext(t.Context(), q, &status,
|
||
`SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
|
||
host.UUID, profileName)
|
||
require.NoError(t, err)
|
||
require.False(t, status.Valid, "status should be NULL")
|
||
err = sqlx.GetContext(t.Context(), q, &retries,
|
||
`SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`,
|
||
host.UUID, profileName)
|
||
require.NoError(t, err)
|
||
require.Equal(t, 1, retries)
|
||
return nil
|
||
})
|
||
|
||
// Third session: Add 418 again to requeue Replace; retries decremented back to 0.
|
||
s.awaitTriggerProfileSchedule(t)
|
||
|
||
cmds, err = mdmDevice.StartManagementSession()
|
||
require.NoError(t, err)
|
||
msgID, err = mdmDevice.GetCurrentMsgID()
|
||
require.NoError(t, err)
|
||
for _, cmd := range cmds {
|
||
if cmd.Verb == "Status" {
|
||
continue
|
||
}
|
||
syncCmd := fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &cmd.Cmd.CmdID.Value,
|
||
Cmd: ptr.String(cmd.Verb),
|
||
Data: ptr.String(syncml.CmdStatusAtomicFailed),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
}
|
||
mdmDevice.AppendResponse(syncCmd)
|
||
for _, addCmd := range cmd.Cmd.AddCommands {
|
||
for range addCmd.Items {
|
||
mdmDevice.AppendResponse(fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &addCmd.CmdID.Value,
|
||
Cmd: ptr.String(fleet.CmdStatus),
|
||
Data: ptr.String(syncml.CmdStatusAlreadyExists), // 418
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
})
|
||
}
|
||
}
|
||
}
|
||
newCmds, err := mdmDevice.SendResponse()
|
||
require.NoError(t, err)
|
||
require.Len(t, newCmds, 2) // status + atomic replace
|
||
|
||
// Pending and retries back to 0.
|
||
profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
||
require.NoError(t, err)
|
||
for _, p := range profiles {
|
||
if p.Name == profileName {
|
||
updatedProfile = p
|
||
break
|
||
}
|
||
}
|
||
require.NotNil(t, updatedProfile)
|
||
require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status)
|
||
require.Empty(t, updatedProfile.Detail)
|
||
expectRetry(profileName, 1)
|
||
|
||
// Fourth session: Replace succeeds (Atomic OK + item 200) → verifying.
|
||
s.awaitTriggerProfileSchedule(t)
|
||
cmds, err = mdmDevice.StartManagementSession()
|
||
require.NoError(t, err)
|
||
msgID, err = mdmDevice.GetCurrentMsgID()
|
||
require.NoError(t, err)
|
||
for _, cmd := range cmds {
|
||
syncCmd := fleet.SyncMLCmd{
|
||
XMLName: xml.Name{Local: fleet.CmdStatus},
|
||
MsgRef: &msgID,
|
||
CmdRef: &cmd.Cmd.CmdID.Value,
|
||
Cmd: ptr.String(cmd.Verb),
|
||
Data: ptr.String(syncml.CmdStatusOK),
|
||
CmdID: fleet.CmdID{Value: uuid.NewString()},
|
||
}
|
||
mdmDevice.AppendResponse(syncCmd)
|
||
for _, repCmd := range cmd.Cmd.ReplaceCommands {
|
||
for _, 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
|
||
})
|
||
}
|
||
|
||
func (s *integrationMDMTestSuite) TestHostMDMAndroidProfilesStatus() {
|
||
t := s.T()
|
||
ctx := context.Background()
|
||
s.setSkipWorkerJobs(t)
|
||
|
||
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam", Secrets: []*fleet.EnrollSecret{{Secret: uuid.NewString()}}})
|
||
require.NoError(t, err)
|
||
|
||
enterpriseID := s.enableAndroidMDM(t)
|
||
|
||
s.runWorkerUntilDoneWithChecks(true)
|
||
|
||
host1, deviceInfo1, pubSubToken := s.createAndEnrollAndroidDevice(t, "host-1", &testTeam.ID, false)
|
||
s.createAndEnrollAndroidDevice(t, "host-2", &testTeam.ID, false)
|
||
|
||
var hosts listHostsResponse
|
||
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &hosts)
|
||
assert.Len(t, hosts.Hosts, 2)
|
||
|
||
bytes := []byte(`{
|
||
"removeUserDisabled": false
|
||
}`)
|
||
|
||
fields := make(map[string][]string)
|
||
fields["team_id"] = []string{fmt.Sprintf("%d", testTeam.ID)}
|
||
body, headers := generateNewProfileMultipartRequest(t, "remove-user-disabled.json", bytes, s.token, fields)
|
||
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
|
||
require.NotNil(t, res)
|
||
|
||
// profiles should be added with NULL status even before the cron job (s.awaitTriggerAndroidProfileSchedule(t))
|
||
var profiles []fleet.HostMDMAndroidProfile
|
||
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
||
err := sqlx.SelectContext(ctx, q, &profiles, "SELECT host_uuid, status, operation_type FROM host_mdm_android_profiles")
|
||
require.NoError(t, err)
|
||
return nil
|
||
})
|
||
|
||
require.Len(t, profiles, 2)
|
||
require.Nil(t, profiles[0].Status)
|
||
require.Nil(t, profiles[1].Status)
|
||
|
||
overrideProfile1 := []byte(`{
|
||
"maximumTimeToLock": "1"
|
||
}`)
|
||
overrideProfile2 := []byte(`{
|
||
"maximumTimeToLock": "2"
|
||
}`)
|
||
|
||
body, headers = generateNewProfileMultipartRequest(t, "maximum-time-to-lock-1.json", overrideProfile1, s.token, fields)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
|
||
require.NotNil(t, res)
|
||
|
||
body, headers = generateNewProfileMultipartRequest(t, "maximum-time-to-lock-2.json", overrideProfile2, s.token, fields)
|
||
res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
|
||
require.NotNil(t, res)
|
||
|
||
s.awaitTriggerAndroidProfileSchedule(t)
|
||
|
||
getHostProfiles := func(hostID uint, wantStatus []string) {
|
||
var hostResp getHostResponse
|
||
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostID), nil, http.StatusOK, &hostResp)
|
||
require.NotNil(t, hostResp.Host.MDM.Profiles)
|
||
require.Len(t, *hostResp.Host.MDM.Profiles, len(wantStatus))
|
||
actualProfileStatuses := make([]string, 0, len(wantStatus))
|
||
for _, p := range *hostResp.Host.MDM.Profiles {
|
||
if p.Status != nil {
|
||
actualProfileStatuses = append(actualProfileStatuses, *p.Status)
|
||
}
|
||
}
|
||
require.ElementsMatch(t, wantStatus, actualProfileStatuses)
|
||
}
|
||
|
||
getHostProfiles(host1.ID, []string{string(fleet.MDMDeliveryPending), string(fleet.MDMDeliveryPending), string(fleet.MDMDeliveryFailed)})
|
||
|
||
// send a pub-sub status report
|
||
policyName := fmt.Sprintf("enterprises/%s/policies/%s", enterpriseID, host1.UUID)
|
||
reportMsg := statusReportMessageWithEnterpriseSpecificID(
|
||
t,
|
||
androidmanagement.Device{
|
||
Name: deviceInfo1.Name,
|
||
EnrollmentTokenData: deviceInfo1.EnrollmentTokenData,
|
||
AppliedPolicyName: policyName,
|
||
AppliedPolicyVersion: 2,
|
||
LastPolicySyncTime: time.Now().Format(time.RFC3339Nano),
|
||
},
|
||
host1.UUID,
|
||
)
|
||
req := android_service.PubSubPushRequest{PubSubMessage: *reportMsg}
|
||
s.Do("POST", "/api/v1/fleet/android_enterprise/pubsub", &req, http.StatusOK, "token", string(pubSubToken.Value))
|
||
|
||
// Profiles that failed because they were overriden should stay failed
|
||
getHostProfiles(host1.ID, []string{string(fleet.MDMDeliveryVerified), string(fleet.MDMDeliveryVerified), string(fleet.MDMDeliveryFailed)})
|
||
}
|