fleet/server/service/integration_mdm_test.go

3795 lines
151 KiB
Go
Raw Normal View History

package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
mathrand "math/rand"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/fleetdm/fleet/v4/server/service/schedule"
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/jmoiron/sqlx"
micromdm "github.com/micromdm/micromdm/mdm/mdm"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/godep"
nanodep_storage "github.com/micromdm/nanodep/storage"
"github.com/micromdm/nanodep/tokenpki"
"github.com/micromdm/nanomdm/mdm"
"github.com/micromdm/nanomdm/push"
nanomdm_pushsvc "github.com/micromdm/nanomdm/push/service"
scepclient "github.com/micromdm/scep/v2/client"
"github.com/micromdm/scep/v2/cryptoutil/x509util"
"github.com/micromdm/scep/v2/scep"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"go.mozilla.org/pkcs7"
)
func TestIntegrationsMDM(t *testing.T) {
testingSuite := new(integrationMDMTestSuite)
testingSuite.s = &testingSuite.Suite
suite.Run(t, testingSuite)
}
type integrationMDMTestSuite struct {
suite.Suite
withServer
fleetCfg config.FleetConfig
fleetDMNextCSRStatus atomic.Value
pushProvider *mock.APNSPushProvider
depStorage nanodep_storage.AllStorage
depSchedule *schedule.Schedule
profileSchedule *schedule.Schedule
onScheduleDone func() // function called when profileSchedule.Trigger() job completed
mdmStorage *mysql.NanoMDMStorage
}
func (s *integrationMDMTestSuite) SetupSuite() {
s.withDS.SetupSuite("integrationMDMTestSuite")
appConf, err := s.ds.AppConfig(context.Background())
require.NoError(s.T(), err)
appConf.MDM.EnabledAndConfigured = true
err = s.ds.SaveAppConfig(context.Background(), appConf)
require.NoError(s.T(), err)
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(s.T(), err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, testCertPEM, testKeyPEM, testBMToken)
fleetCfg.Osquery.EnrollCooldown = 0
mdmStorage, err := s.ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
require.NoError(s.T(), err)
depStorage, err := s.ds.NewMDMAppleDEPStorage(*testBMToken)
require.NoError(s.T(), err)
scepStorage, err := s.ds.NewSCEPDepot(testCertPEM, testKeyPEM)
require.NoError(s.T(), err)
pushFactory, pushProvider := newMockAPNSPushProviderFactory()
mdmPushService := nanomdm_pushsvc.New(
mdmStorage,
mdmStorage,
pushFactory,
NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)),
)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
var depSchedule *schedule.Schedule
var profileSchedule *schedule.Schedule
config := TestServerOpts{
License: &fleet.LicenseInfo{
Tier: fleet.TierPremium,
},
FleetConfig: &fleetCfg,
MDMStorage: mdmStorage,
DEPStorage: depStorage,
SCEPStorage: scepStorage,
MDMPusher: mdmPushService,
Pool: redisPool,
StartCronSchedules: []TestNewScheduleFunc{
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
const name = string(fleet.CronAppleMDMDEPProfileAssigner)
logger := kitlog.NewJSONLogger(os.Stdout)
fleetSyncer := apple_mdm.NewDEPService(ds, depStorage, logger, true)
depSchedule = schedule.New(
ctx, name, s.T().Name(), 1*time.Hour, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("dep_syncer", func(ctx context.Context) error {
return fleetSyncer.RunAssigner(ctx)
}),
)
return depSchedule, nil
}
},
func(ctx context.Context, ds fleet.Datastore) fleet.NewCronScheduleFunc {
return func() (fleet.CronSchedule, error) {
const name = string(fleet.CronMDMAppleProfileManager)
logger := kitlog.NewJSONLogger(os.Stdout)
profileSchedule = schedule.New(
ctx, name, s.T().Name(), 1*time.Hour, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("manage_profiles", func(ctx context.Context) error {
if s.onScheduleDone != nil {
defer s.onScheduleDone()
}
return ReconcileProfiles(ctx, ds, apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService), logger)
}),
)
return profileSchedule, nil
}
},
},
}
users, server := RunServerForTestsWithDS(s.T(), s.ds, &config)
s.server = server
s.users = users
s.token = s.getTestAdminToken()
s.cachedAdminToken = s.token
s.fleetCfg = fleetCfg
s.pushProvider = pushProvider
s.depStorage = depStorage
s.depSchedule = depSchedule
s.profileSchedule = profileSchedule
s.mdmStorage = mdmStorage
fleetdmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
status := s.fleetDMNextCSRStatus.Swap(http.StatusOK)
w.WriteHeader(status.(int))
_, _ = w.Write([]byte(fmt.Sprintf("status: %d", status)))
}))
s.T().Setenv("TEST_FLEETDM_API_URL", fleetdmSrv.URL)
s.T().Cleanup(fleetdmSrv.Close)
}
func (s *integrationMDMTestSuite) FailNextCSRRequestWith(status int) {
s.fleetDMNextCSRStatus.Store(status)
}
func (s *integrationMDMTestSuite) SucceedNextCSRRequest() {
s.fleetDMNextCSRStatus.Store(http.StatusOK)
}
func (s *integrationMDMTestSuite) TearDownTest() {
t := s.T()
ctx := context.Background()
s.token = s.getTestAdminToken()
appCfg := s.getConfig()
if appCfg.MDM.MacOSSettings.EnableDiskEncryption {
// ensure global disk encryption is disabled on exit
s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": false } }
}`), http.StatusOK)
}
s.withServer.commonTearDownTest(t)
// use a sql statement to delete all profiles, since the datastore prevents
// deleting the fleet-specific ones.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "DELETE FROM mdm_apple_configuration_profiles")
return err
})
}
func (s *integrationMDMTestSuite) mockDEPResponse(handler http.Handler) {
t := s.T()
srv := httptest.NewServer(handler)
err := s.depStorage.StoreConfig(context.Background(), apple_mdm.DEPName, &nanodep_client.Config{BaseURL: srv.URL})
require.NoError(t, err)
t.Cleanup(func() {
srv.Close()
err := s.depStorage.StoreConfig(context.Background(), apple_mdm.DEPName, &nanodep_client.Config{BaseURL: nanodep_client.DefaultBaseURL})
require.NoError(t, err)
})
}
func (s *integrationMDMTestSuite) TestAppleGetAppleMDM() {
t := s.T()
var mdmResp getAppleMDMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple", nil, http.StatusOK, &mdmResp)
// returned values are dummy, this is a test certificate
require.Equal(t, "FleetDM", mdmResp.Issuer)
require.NotZero(t, mdmResp.SerialNumber)
require.Equal(t, "FleetDM", mdmResp.CommonName)
require.NotZero(t, mdmResp.RenewDate)
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/account":
_, _ = w.Write([]byte(`{"admin_id": "abc", "org_name": "test_org"}`))
}
}))
var getAppleBMResp getAppleBMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusOK, &getAppleBMResp)
require.NoError(t, getAppleBMResp.Err)
require.Equal(t, "abc", getAppleBMResp.AppleID)
require.Equal(t, "test_org", getAppleBMResp.OrgName)
require.Equal(t, "https://example.org/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
require.Empty(t, getAppleBMResp.DefaultTeam)
// create a new team
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
// set the default bm assignment to that team
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
"apple_bm_default_team": %q
}
}`, tm.Name)), http.StatusOK, &acResp)
// try again, this time we get a default team in the response
getAppleBMResp = getAppleBMResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusOK, &getAppleBMResp)
require.NoError(t, getAppleBMResp.Err)
require.Equal(t, "abc", getAppleBMResp.AppleID)
require.Equal(t, "test_org", getAppleBMResp.OrgName)
require.Equal(t, "https://example.org/mdm/apple/mdm", getAppleBMResp.MDMServerURL)
require.Equal(t, tm.Name, getAppleBMResp.DefaultTeam)
}
func (s *integrationMDMTestSuite) TestABMExpiredToken() {
t := s.T()
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"code": "T_C_NOT_SIGNED"}`))
}))
config := s.getConfig()
require.False(t, config.MDM.AppleBMTermsExpired)
var getAppleBMResp getAppleBMResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple_bm", nil, http.StatusInternalServerError, &getAppleBMResp)
config = s.getConfig()
require.True(t, config.MDM.AppleBMTermsExpired)
}
func (s *integrationMDMTestSuite) TestProfileManagement() {
t := s.T()
ctx := context.Background()
err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
var fleetdProfile bytes.Buffer
params := mobileconfig.FleetdProfileOptions{
EnrollSecret: t.Name(),
ServerURL: "https://example.org",
PayloadType: mobileconfig.FleetdConfigPayloadIdentifier,
}
err = mobileconfig.FleetdProfileTemplate.Execute(&fleetdProfile, params)
require.NoError(t, err)
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
}
wantGlobalProfiles := append(globalProfiles, fleetdProfile.Bytes())
// add global profiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// create a new team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
require.NoError(t, err)
teamProfiles := [][]byte{
mobileconfigForTest("N3", "I3"),
}
wantTeamProfiles := append(teamProfiles, fleetdProfile.Bytes())
// add profiles to the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
// create a non-macOS host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
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{
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 and enroll a host in MDM
d := newDevice(s)
host, err := s.ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: d.uuid,
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
HardwareSerial: d.serial,
})
require.NoError(t, err)
d.mdmEnroll(s)
triggerSchedule := func() {
ch := make(chan bool)
s.onScheduleDone = func() { close(ch) }
_, err := s.profileSchedule.Trigger()
require.NoError(t, err)
<-ch
}
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
require.Len(t, pushes, 1)
require.Equal(t, pushes[0].PushMagic, "pushmagic"+d.serial)
res := map[string]*push.Response{
pushes[0].Token.String(): {
Id: uuid.New().String(),
Err: nil,
},
}
return res, nil
}
checkNextPayloads := func() ([][]byte, []string) {
var cmd *micromdm.CommandPayload
installs := [][]byte{}
removes := []string{}
for {
// on the first run, cmd will be nil and we need to
// ping the server via idle
if cmd == nil {
cmd = d.idle()
} else {
cmd = d.acknowledge(cmd.CommandUUID)
}
// if after idle or acknowledge cmd is still nil, it
// means there aren't any commands left to run
if cmd == nil {
break
}
switch cmd.Command.RequestType {
case "InstallProfile":
installs = append(installs, cmd.Command.InstallProfile.Payload)
case "RemoveProfile":
removes = append(removes, cmd.Command.RemoveProfile.Identifier)
}
}
return installs, removes
}
// trigger a profile sync
triggerSchedule()
installs, removes := checkNextPayloads()
// verify that we received all profiles
require.ElementsMatch(t, wantGlobalProfiles, installs)
require.Empty(t, removes)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
require.NoError(t, err)
// trigger a profile sync
triggerSchedule()
installs, removes = checkNextPayloads()
// verify that we should install the team profile
require.ElementsMatch(t, wantTeamProfiles, installs)
// verify that we should delete both profiles
require.ElementsMatch(t, []string{"I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier}, removes)
// set new team profiles (delete + addition)
teamProfiles = [][]byte{
mobileconfigForTest("N4", "I4"),
mobileconfigForTest("N5", "I5"),
}
wantTeamProfiles = teamProfiles
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
// trigger a profile sync
triggerSchedule()
installs, removes = checkNextPayloads()
// verify that we should install the team profiles
require.ElementsMatch(t, wantTeamProfiles, installs)
// verify that we should delete the old team profiles
require.ElementsMatch(t, []string{"I3"}, removes)
// with no changes
_, err = s.profileSchedule.Trigger()
require.NoError(t, err)
installs, removes = checkNextPayloads()
require.Empty(t, installs)
require.Empty(t, removes)
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.NotEmpty(t, hostResp.Host.MDM.Profiles)
resProfiles := *hostResp.Host.MDM.Profiles
// one extra profile for the fleetd config
require.Len(t, resProfiles, len(wantTeamProfiles)+1)
var teamSummaryResp getMDMAppleProfilesSummaryResponse
s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryRequest{TeamID: &tm.ID}, http.StatusOK, &teamSummaryResp)
require.Equal(t, uint(0), teamSummaryResp.Pending)
require.Equal(t, uint(0), teamSummaryResp.Failed)
require.Equal(t, uint(1), teamSummaryResp.Verifying)
var noTeamSummaryResp getMDMAppleProfilesSummaryResponse
s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryRequest{}, http.StatusOK, &noTeamSummaryResp)
require.Equal(t, uint(0), noTeamSummaryResp.Pending)
require.Equal(t, uint(0), noTeamSummaryResp.Failed)
require.Equal(t, uint(0), noTeamSummaryResp.Verifying)
}
func (s *integrationMDMTestSuite) TestDEPProfileAssignment() {
t := s.T()
devices := []godep.Device{
{SerialNumber: uuid.New().String(), Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: uuid.New().String(), Model: "MacBook Mini", OS: "osx", OpType: "added"},
}
var wg sync.WaitGroup
wg.Add(2)
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
require.NoError(t, err)
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "abc"})
require.NoError(t, err)
case "/server/devices":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices[:1]})
require.NoError(t, err)
case "/devices/sync":
// This endpoint is polled over time to sync devices from
// ABM, send a repeated serial and a new one
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
require.NoError(t, err)
case "/profile/devices":
wg.Done()
_, _ = w.Write([]byte(`{}`))
default:
_, _ = w.Write([]byte(`{}`))
}
}))
// query all hosts
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Empty(t, listHostsRes.Hosts)
// trigger a profile sync
_, err := s.depSchedule.Trigger()
require.NoError(t, err)
wg.Wait()
// both hosts should be returned from the hosts endpoint
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 2)
require.Equal(t, listHostsRes.Hosts[0].HardwareSerial, devices[0].SerialNumber)
require.Equal(t, listHostsRes.Hosts[1].HardwareSerial, devices[1].SerialNumber)
require.EqualValues(
t,
[]string{devices[0].SerialNumber, devices[1].SerialNumber},
[]string{listHostsRes.Hosts[0].HardwareSerial, listHostsRes.Hosts[1].HardwareSerial},
)
// create a new host
createHostAndDeviceToken(t, s.ds, "not-dep")
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 3)
// filtering by MDM status works
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 2)
d := newDevice(s)
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{}, nil
}
// enroll one of the hosts
d.serial = devices[0].SerialNumber
d.mdmEnroll(s)
// make sure the host gets a request to install fleetd
var fleetdCmd *micromdm.CommandPayload
cmd := d.idle()
for cmd != nil {
if cmd.Command.RequestType == "InstallEnterpriseApplication" &&
cmd.Command.InstallEnterpriseApplication.ManifestURL != nil &&
strings.Contains(*cmd.Command.InstallEnterpriseApplication.ManifestURL, apple_mdm.FleetdPublicManifestURL) {
fleetdCmd = cmd
}
cmd = d.acknowledge(cmd.CommandUUID)
}
require.NotNil(t, fleetdCmd)
require.NotNil(t, fleetdCmd.Command)
// only one shows up as pending
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts?mdm_enrollment_status=pending", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
activities := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at")
found := false
for _, activity := range activities.Activities {
if activity.Type == "mdm_enrolled" &&
strings.Contains(string(*activity.Details), devices[0].SerialNumber) {
found = true
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
require.JSONEq(
t,
fmt.Sprintf(
`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": true}`,
devices[0].SerialNumber, devices[0].Model, devices[0].SerialNumber,
),
string(*activity.Details),
)
}
}
require.True(t, found)
}
func (s *integrationMDMTestSuite) TestDeviceMDMManualEnroll() {
t := s.T()
token := "token_test_manual_enroll"
createHostAndDeviceToken(t, s.ds, token)
// invalid token fails
s.DoRaw("GET", "/api/latest/fleet/device/invalid_token/mdm/apple/manual_enrollment_profile", nil, http.StatusUnauthorized)
// valid token downloads the profile
resp := s.DoRaw("GET", "/api/latest/fleet/device/"+token+"/mdm/apple/manual_enrollment_profile", nil, http.StatusOK)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
require.Contains(t, resp.Header, "Content-Disposition")
require.Contains(t, resp.Header, "Content-Type")
require.Contains(t, resp.Header, "X-Content-Type-Options")
require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
headerLen, err := strconv.Atoi(resp.Header.Get("Content-Length"))
require.NoError(t, err)
require.Equal(t, len(body), headerLen)
var profile struct {
PayloadIdentifier string `plist:"PayloadIdentifier"`
}
require.NoError(t, plist.Unmarshal(body, &profile))
require.Equal(t, apple_mdm.FleetPayloadIdentifier, profile.PayloadIdentifier)
}
func (s *integrationMDMTestSuite) TestAppleMDMDeviceEnrollment() {
t := s.T()
// Enroll two devices into MDM
deviceA := newMDMEnrolledDevice(s)
deviceB := newMDMEnrolledDevice(s)
// Find the ID of Fleet's MDM solution
var mdmID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(context.Background(), q, &mdmID,
`SELECT id FROM mobile_device_management_solutions WHERE name = ?`, fleet.WellKnownMDMFleet)
})
// Check that both devices are returned by the /hosts endpoint
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes, "mdm_id", fmt.Sprint(mdmID))
require.Len(t, listHostsRes.Hosts, 2)
require.EqualValues(
t,
[]string{deviceA.uuid, deviceB.uuid},
[]string{listHostsRes.Hosts[0].UUID, listHostsRes.Hosts[1].UUID},
)
var targetHostID uint
var lastEnroll time.Time
for _, host := range listHostsRes.Hosts {
if host.UUID == deviceA.uuid {
targetHostID = host.ID
lastEnroll = host.LastEnrolledAt
break
}
}
// Activities are generated for each device
activities := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities, "order_key", "created_at")
require.GreaterOrEqual(t, len(activities.Activities), 2)
details := []*json.RawMessage{}
for _, activity := range activities.Activities {
if activity.Type == "mdm_enrolled" {
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
details = append(details, activity.Details)
}
}
require.Len(t, details, 2)
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, deviceA.serial, deviceA.model, deviceA.serial), string(*details[len(details)-2]))
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, deviceB.serial, deviceB.model, deviceB.serial), string(*details[len(details)-1]))
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: t.Name()}},
},
}, http.StatusOK, &applyResp)
// simulate a matching host enrolling via osquery
j, err := json.Marshal(&enrollAgentRequest{
EnrollSecret: t.Name(),
HostIdentifier: deviceA.uuid,
})
require.NoError(t, err)
var enrollResp enrollAgentResponse
hres := s.DoRawNoAuth("POST", "/api/osquery/enroll", j, http.StatusOK)
defer hres.Body.Close()
require.NoError(t, json.NewDecoder(hres.Body).Decode(&enrollResp))
require.NotEmpty(t, enrollResp.NodeKey)
// query all hosts
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
// we still have only two hosts
require.Len(t, listHostsRes.Hosts, 2)
// LastEnrolledAt should have been updated
var getHostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", targetHostID), nil, http.StatusOK, &getHostResp)
require.Greater(t, getHostResp.Host.LastEnrolledAt, lastEnroll)
// Unenroll a device
deviceA.checkout()
// An activity is created
activities = listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities)
found := false
for _, activity := range activities.Activities {
if activity.Type == "mdm_unenrolled" {
found = true
require.Nil(t, activity.ActorID)
require.Nil(t, activity.ActorFullName)
details = append(details, activity.Details)
require.JSONEq(t, fmt.Sprintf(`{"host_serial": "%s", "host_display_name": "%s (%s)", "installed_from_dep": false}`, deviceA.serial, deviceA.model, deviceA.serial), string(*activity.Details))
}
}
require.True(t, found)
}
func (s *integrationMDMTestSuite) TestDeviceMultipleAuthMessages() {
d := newMDMEnrolledDevice(s)
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(s.T(), listHostsRes.Hosts, 1)
// send the auth message again, we still have only one host
d.authenticate()
listHostsRes = listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(s.T(), listHostsRes.Hosts, 1)
}
func (s *integrationMDMTestSuite) TestAppleMDMCSRRequest() {
t := s.T()
var errResp validationErrResp
// missing arguments
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Equal(t, errResp.Errors[0].Name, "email_address")
// invalid email address
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "abc", Organization: "def"}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Equal(t, errResp.Errors[0].Name, "email_address")
// missing organization
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: ""}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Equal(t, errResp.Errors[0].Name, "organization")
// fleetdm CSR request failed
s.FailNextCSRRequestWith(http.StatusBadRequest)
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusUnprocessableEntity, &errResp)
require.Len(t, errResp.Errors, 1)
require.Contains(t, errResp.Errors[0].Reason, "this email address is not valid")
s.FailNextCSRRequestWith(http.StatusInternalServerError)
errResp = validationErrResp{}
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusBadGateway, &errResp)
require.Len(t, errResp.Errors, 1)
require.Contains(t, errResp.Errors[0].Reason, "FleetDM CSR request failed")
var reqCSRResp requestMDMAppleCSRResponse
// fleetdm CSR request succeeds
s.SucceedNextCSRRequest()
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/request_csr", requestMDMAppleCSRRequest{EmailAddress: "a@b.c", Organization: "test"}, http.StatusOK, &reqCSRResp)
require.Contains(t, string(reqCSRResp.APNsKey), "-----BEGIN RSA PRIVATE KEY-----\n")
require.Contains(t, string(reqCSRResp.SCEPCert), "-----BEGIN CERTIFICATE-----\n")
require.Contains(t, string(reqCSRResp.SCEPKey), "-----BEGIN RSA PRIVATE KEY-----\n")
}
func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
t := s.T()
// enroll into mdm
d := newMDMEnrolledDevice(s)
// set an enroll secret
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: t.Name()}},
},
}, http.StatusOK, &applyResp)
// simulate a matching host enrolling via osquery
j, err := json.Marshal(&enrollAgentRequest{
EnrollSecret: t.Name(),
HostIdentifier: d.uuid,
})
require.NoError(t, err)
var enrollResp enrollAgentResponse
hres := s.DoRawNoAuth("POST", "/api/osquery/enroll", j, http.StatusOK)
defer hres.Body.Close()
require.NoError(t, json.NewDecoder(hres.Body).Decode(&enrollResp))
require.NotEmpty(t, enrollResp.NodeKey)
listHostsRes := listHostsResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts", nil, http.StatusOK, &listHostsRes)
require.Len(t, listHostsRes.Hosts, 1)
h := listHostsRes.Hosts[0]
// assign profiles to the host
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
mobileconfigForTest("N3", "I3"),
}}, http.StatusNoContent)
// trigger a sync and verify that there are profiles assigned to the host
_, err = s.profileSchedule.Trigger()
require.NoError(t, err)
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", h.ID), getHostRequest{}, http.StatusOK, &hostResp)
// 3 profiles added + 1 profile with fleetd configuration
require.Len(t, *hostResp.Host.MDM.Profiles, 4)
// try to unenroll the host, fails since the host doesn't respond
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusGatewayTimeout)
// we're going to modify this mock, make sure we restore its default
originalPushMock := s.pushProvider.PushFunc
defer func() { s.pushProvider.PushFunc = originalPushMock }()
// if there's an error coming from APNs servers
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
return map[string]*push.Response{
pushes[0].Token.String(): {
Id: uuid.New().String(),
Err: errors.New("test"),
},
}, nil
}
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusBadGateway)
// if there was an error unrelated to APNs
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
res := map[string]*push.Response{
pushes[0].Token.String(): {
Id: uuid.New().String(),
Err: nil,
},
}
return res, errors.New("baz")
}
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusInternalServerError)
// try again, but this time the host is online and answers
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
res, err := mockSuccessfulPush(pushes)
d.checkout()
return res, err
}
s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/unenroll", h.ID), nil, http.StatusOK)
// profiles are removed and the host is no longer enrolled
hostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", h.ID), getHostRequest{}, http.StatusOK, &hostResp)
require.Nil(t, hostResp.Host.MDM.Profiles)
require.Equal(t, "", hostResp.Host.MDM.Name)
}
func (s *integrationMDMTestSuite) TestMDMAppleGetEncryptionKey() {
t := s.T()
ctx := context.Background()
// create a host
host, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(t.Name()),
NodeKey: ptr.String(t.Name()),
UUID: uuid.New().String(),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
// install a filevault profile for that host
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": true } }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
fileVaultProf := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
hostCmdUUID := uuid.New().String()
err = s.ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{
{
ProfileID: fileVaultProf.ProfileID,
ProfileIdentifier: fileVaultProf.Identifier,
HostUUID: host.UUID,
CommandUUID: hostCmdUUID,
OperationType: fleet.MDMAppleOperationTypeInstall,
Status: &fleet.MDMAppleDeliveryVerifying,
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
Checksum: []byte("csum"),
},
})
require.NoError(t, err)
t.Cleanup(func() {
err := s.ds.UpdateOrDeleteHostMDMAppleProfile(ctx, &fleet.HostMDMAppleProfile{
HostUUID: host.UUID,
CommandUUID: hostCmdUUID,
ProfileID: fileVaultProf.ProfileID,
Status: &fleet.MDMAppleDeliveryVerifying,
OperationType: fleet.MDMAppleOperationTypeRemove,
})
require.NoError(t, err)
// not an error if the profile does not exist
_ = s.ds.DeleteMDMAppleConfigProfile(ctx, fileVaultProf.ProfileID)
})
// get that host - it has no encryption key at this point, so it should
// report "action_required" disk encryption and "log_out" action.
getHostResp := getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.Equal(t, fleet.DiskEncryptionActionRequired, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired)
require.Equal(t, fleet.ActionRequiredLogOut, *getHostResp.Host.MDM.MacOSSettings.ActionRequired)
// add an encryption key for the host
cert, _, _, err := s.fleetCfg.MDM.AppleSCEP()
require.NoError(t, err)
parsed, err := x509.ParseCertificate(cert.Certificate[0])
require.NoError(t, err)
recoveryKey := "AAA-BBB-CCC"
encryptedKey, err := pkcs7.Encrypt([]byte(recoveryKey), []*x509.Certificate{parsed})
require.NoError(t, err)
base64EncryptedKey := base64.StdEncoding.EncodeToString(encryptedKey)
err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, base64EncryptedKey)
require.NoError(t, err)
// get that host - it has an encryption key with unknown decryptability, so
// it should report "enforcing" disk encryption.
getHostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.Equal(t, fleet.DiskEncryptionEnforcing, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.Nil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired)
// request with no token
res := s.DoRawNoAuth("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusUnauthorized)
res.Body.Close()
// encryption key not processed yet
resp := getHostEncryptionKeyResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp)
// unable to decrypt encryption key
err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, false, time.Now())
require.NoError(t, err)
resp = getHostEncryptionKeyResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp)
// get that host - it has an encryption key that is un-decryptable, so it
// should report "action_required" disk encryption and "rotate_key" action.
getHostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.Equal(t, fleet.DiskEncryptionActionRequired, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired)
require.Equal(t, fleet.ActionRequiredRotateKey, *getHostResp.Host.MDM.MacOSSettings.ActionRequired)
// no activities created so far
activities := listActivitiesResponse{}
s.DoJSON("GET", "/api/latest/fleet/activities", nil, http.StatusOK, &activities)
found := false
for _, activity := range activities.Activities {
if activity.Type == "read_host_disk_encryption_key" {
found = true
}
}
require.False(t, found)
// decryptable key
checkDecryptableKey := func(u fleet.User) {
err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, true, time.Now())
require.NoError(t, err)
resp = getHostEncryptionKeyResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusOK, &resp)
require.Equal(t, recoveryKey, resp.EncryptionKey.DecryptedValue)
// use the admin token to get the activities
currToken := s.token
defer func() { s.token = currToken }()
s.token = s.getTestAdminToken()
s.lastActivityMatches(
"read_host_disk_encryption_key",
fmt.Sprintf(`{"host_display_name": "%s", "host_id": %d}`, host.DisplayName(), host.ID),
0,
)
}
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
team, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 4827,
Name: "team1_" + t.Name(),
Description: "desc team1_" + t.Name(),
})
require.NoError(t, err)
// enable disk encryption on the team so the key is not deleted when the host is added
teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: "team1_" + t.Name(),
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"enable_disk_encryption": true},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// we're about to mess up with the token, make sure to set it to the
// default value when the test ends
currToken := s.token
t.Cleanup(func() { s.token = currToken })
// admins are able to see the host encryption key
s.token = s.getTestAdminToken()
checkDecryptableKey(s.users["admin1@example.com"])
// get that host - it has an encryption key that is decryptable, so it
// should report "applied" disk encryption.
getHostResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp)
require.NotNil(t, getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.Equal(t, fleet.DiskEncryptionVerifying, *getHostResp.Host.MDM.MacOSSettings.DiskEncryption)
require.Nil(t, getHostResp.Host.MDM.MacOSSettings.ActionRequired)
// maintainers are able to see the token
u := s.users["user1@example.com"]
s.token = s.getTestToken(u.Email, test.GoodPassword)
checkDecryptableKey(u)
// observers are able to see the token
u = s.users["user2@example.com"]
s.token = s.getTestToken(u.Email, test.GoodPassword)
checkDecryptableKey(u)
// add the host to a team
err = s.ds.AddHostsToTeam(ctx, &team.ID, []uint{host.ID})
require.NoError(t, err)
// admins are still able to see the token
s.token = s.getTestAdminToken()
checkDecryptableKey(s.users["admin1@example.com"])
// maintainers are still able to see the token
u = s.users["user1@example.com"]
s.token = s.getTestToken(u.Email, test.GoodPassword)
checkDecryptableKey(u)
// observers are still able to see the token
u = s.users["user2@example.com"]
s.token = s.getTestToken(u.Email, test.GoodPassword)
checkDecryptableKey(u)
// add a team member
u = fleet.User{
Name: "test team user",
Email: "user1+team@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team,
Role: fleet.RoleMaintainer,
},
},
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(ctx, &u)
require.NoError(t, err)
// members are able to see the token
s.token = s.getTestToken(u.Email, test.GoodPassword)
checkDecryptableKey(u)
// create a separate team
team2, err := s.ds.NewTeam(context.Background(), &fleet.Team{
ID: 4828,
Name: "team2_" + t.Name(),
Description: "desc team2_" + t.Name(),
})
require.NoError(t, err)
// add a team member
u = fleet.User{
Name: "test team user",
Email: "user1+team2@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *team2,
Role: fleet.RoleMaintainer,
},
},
}
require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(ctx, &u)
require.NoError(t, err)
// non-members aren't able to see the token
s.token = s.getTestToken(u.Email, test.GoodPassword)
resp = getHostEncryptionKeyResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/hosts/%d/encryption_key", host.ID), nil, http.StatusForbidden, &resp)
}
func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() {
t := s.T()
ctx := context.Background()
testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
require.NoError(t, err)
testProfiles := make(map[string]fleet.MDMAppleConfigProfile)
generateTestProfile := func(name string, identifier string) {
i := identifier
if i == "" {
i = fmt.Sprintf("%s.SomeIdentifier", name)
}
cp := fleet.MDMAppleConfigProfile{
Name: name,
Identifier: i,
}
cp.Mobileconfig = mcBytesForTest(cp.Name, cp.Identifier, fmt.Sprintf("%s.UUID", name))
testProfiles[name] = cp
}
setTestProfileID := func(name string, id uint) {
tp := testProfiles[name]
tp.ProfileID = id
testProfiles[name] = tp
}
generateNewReq := func(name string, teamID *uint) (*bytes.Buffer, map[string]string) {
return generateNewProfileMultipartRequest(t, teamID, "some_filename", testProfiles[name].Mobileconfig, s.token)
}
checkGetResponse := func(resp *http.Response, expected fleet.MDMAppleConfigProfile) {
// check expected headers
require.Contains(t, resp.Header["Content-Type"], "application/x-apple-aspen-config")
require.Contains(t, resp.Header["Content-Disposition"], fmt.Sprintf(`attachment;filename="%s_%s.%s"`, time.Now().Format("2006-01-02"), strings.ReplaceAll(expected.Name, " ", "_"), "mobileconfig"))
// check expected body
var bb bytes.Buffer
_, err = io.Copy(&bb, resp.Body)
require.NoError(t, err)
require.Equal(t, []byte(expected.Mobileconfig), bb.Bytes())
}
checkConfigProfile := func(expected fleet.MDMAppleConfigProfile, actual fleet.MDMAppleConfigProfile) {
require.Equal(t, expected.Name, actual.Name)
require.Equal(t, expected.Identifier, actual.Identifier)
}
// create new profile (no team)
generateTestProfile("TestNoTeam", "")
body, headers := generateNewReq("TestNoTeam", nil)
newResp := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
var newCP fleet.MDMAppleConfigProfile
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestNoTeam", newCP.ProfileID)
// create new profile (with team id)
generateTestProfile("TestWithTeamID", "")
body, headers = generateNewReq("TestWithTeamID", &testTeam.ID)
newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
err = json.NewDecoder(newResp.Body).Decode(&newCP)
require.NoError(t, err)
require.NotEmpty(t, newCP.ProfileID)
setTestProfileID("TestWithTeamID", newCP.ProfileID)
// list profiles (no team)
expectedCP := testProfiles["TestNoTeam"]
var listResp listMDMAppleConfigProfilesResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 1)
respCP := listResp.ConfigProfiles[0]
require.Equal(t, expectedCP.Name, respCP.Name)
checkConfigProfile(expectedCP, *respCP)
require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
require.Empty(t, respCP.TeamID) // zero means no team
// list profiles (team 1)
expectedCP = testProfiles["TestWithTeamID"]
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 1)
respCP = listResp.ConfigProfiles[0]
require.Equal(t, expectedCP.Name, respCP.Name)
checkConfigProfile(expectedCP, *respCP)
require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
require.Equal(t, testTeam.ID, *respCP.TeamID) // team 1
// get profile (no team)
expectedCP = testProfiles["TestNoTeam"]
getPath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
getResp := s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
checkGetResponse(getResp, expectedCP)
// get profile (team 1)
expectedCP = testProfiles["TestWithTeamID"]
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
getResp = s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
checkGetResponse(getResp, expectedCP)
// delete profile (no team)
deletedCP := testProfiles["TestNoTeam"]
deletePath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
var deleteResp deleteMDMAppleConfigProfileResponse
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// delete profile (team 1)
deletedCP = testProfiles["TestWithTeamID"]
deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
deleteResp = deleteMDMAppleConfigProfileResponse{}
s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
// confirm deleted
listResp = listMDMAppleConfigProfilesResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
require.Len(t, listResp.ConfigProfiles, 0)
getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
_ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
// trying to add/delete profiles 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)
}
// make fleet add a FileVault profile
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": true } }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
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) TestAppConfigMDMAppleProfiles() {
t := s.T()
// set the macos custom settings fields
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "custom_settings": ["foo", "bar"] } }
}`), http.StatusOK, &acResp)
assert.Equal(t, []string{"foo", "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, []string{"foo", "bar"}, acResp.MDM.MacOSSettings.CustomSettings)
// patch without specifying the macos custom settings fields and an unrelated
// field, should not remove them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": {"enable_disk_encryption": true} }
}`), http.StatusOK, &acResp)
assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.MacOSSettings.CustomSettings)
// patch with explicitly empty macos 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 } }
}`), http.StatusOK, &acResp, "dry_run", "true")
assert.Equal(t, []string{"foo", "bar"}, acResp.MDM.MacOSSettings.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 } }
}`), http.StatusOK, &acResp)
assert.Empty(t, acResp.MDM.MacOSSettings.CustomSettings)
}
func (s *integrationMDMTestSuite) TestAppConfigMDMAppleDiskEncryption() {
t := s.T()
// set the macos disk encryption field
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": true } }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
enabledDiskActID := s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
`{"team_id": null, "team_name": null}`, 0)
// will have generated the macos config profile
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
// check that they are returned by a GET /config
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
// patch without specifying the macos disk encryption and an unrelated field,
// should not alter it
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": {"custom_settings": ["a"]} }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
assert.Equal(t, []string{"a"}, acResp.MDM.MacOSSettings.CustomSettings)
s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
``, enabledDiskActID)
// patch with false, would reset it but this is a dry-run
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": false } }
}`), http.StatusOK, &acResp, "dry_run", "true")
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
assert.Equal(t, []string{"a"}, acResp.MDM.MacOSSettings.CustomSettings)
s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
``, enabledDiskActID)
// patch with false, resets it
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": false, "custom_settings": ["b"] } }
}`), http.StatusOK, &acResp)
assert.False(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings)
s.lastActivityMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(),
`{"team_id": null, "team_name": null}`, 0)
// will have deleted the macos config profile
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, false)
// use the MDM settings endpoint to set it to true
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings",
fleet.MDMAppleSettingsPayload{EnableDiskEncryption: ptr.Bool(true)}, http.StatusNoContent)
enabledDiskActID = s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
`{"team_id": null, "team_name": null}`, 0)
// will have created the macos config profile
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings)
// call update endpoint with no changes
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings",
fleet.MDMAppleSettingsPayload{}, http.StatusNoContent)
s.lastActivityMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
``, enabledDiskActID)
// the macos config profile still exists
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
acResp = appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
assert.Equal(t, []string{"b"}, acResp.MDM.MacOSSettings.CustomSettings)
}
func (s *integrationMDMTestSuite) TestMDMAppleDiskEncryptionAggregate() {
t := s.T()
ctx := context.Background()
// no hosts with any disk encryption status's
fvsResp := getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(0), fvsResp.Verifying)
require.Equal(t, uint(0), fvsResp.ActionRequired)
require.Equal(t, uint(0), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// 10 new hosts
var hosts []*fleet.Host
for i := 0; i < 10; i++ {
h, err := s.ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-1 * time.Minute),
OsqueryHostID: ptr.String(fmt.Sprintf("%s-%d", t.Name(), i)),
NodeKey: ptr.String(fmt.Sprintf("%s-%d", t.Name(), i)),
UUID: fmt.Sprintf("%d-%s", i, uuid.New().String()),
Hostname: fmt.Sprintf("%sfoo.local", t.Name()),
Platform: "darwin",
})
require.NoError(t, err)
hosts = append(hosts, h)
}
// no team tests ====
// new filevault profile with no team
prof, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTest("filevault-1", mobileconfig.FleetFileVaultPayloadIdentifier), ptr.Uint(0))
require.NoError(t, err)
// generates a disk encryption aggregate value based on the arguments passed in
generateAggregateValue := func(
hosts []*fleet.Host,
operationType fleet.MDMAppleOperationType,
status *fleet.MDMAppleDeliveryStatus,
decryptable bool,
) {
for _, host := range hosts {
hostCmdUUID := uuid.New().String()
err := s.ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{
{
ProfileID: prof.ProfileID,
ProfileIdentifier: prof.Identifier,
HostUUID: host.UUID,
CommandUUID: hostCmdUUID,
OperationType: operationType,
Status: status,
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
Checksum: []byte("csum"),
},
})
require.NoError(t, err)
oneMinuteAfterThreshold := time.Now().Add(+1 * time.Minute)
err = s.ds.SetOrUpdateHostDiskEncryptionKey(ctx, host.ID, "test-key")
require.NoError(t, err)
err = s.ds.SetHostsDiskEncryptionKeyStatus(ctx, []uint{host.ID}, decryptable, oneMinuteAfterThreshold)
require.NoError(t, err)
}
}
// hosts 1,2 have disk encryption "applied" status
generateAggregateValue(hosts[0:2], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, true)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(0), fvsResp.ActionRequired)
require.Equal(t, uint(0), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// hosts 3,4 have disk encryption "action required" status
generateAggregateValue(hosts[2:4], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, false)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(2), fvsResp.ActionRequired)
require.Equal(t, uint(0), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// hosts 5,6 have disk encryption "enforcing" status
// host profiles status are `pending`
generateAggregateValue(hosts[4:6], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryPending, true)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(2), fvsResp.ActionRequired)
require.Equal(t, uint(2), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// host profiles status dont exist
generateAggregateValue(hosts[4:6], fleet.MDMAppleOperationTypeInstall, nil, true)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(2), fvsResp.ActionRequired)
require.Equal(t, uint(2), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// host profile is applied but decryptable key does not exist
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(
context.Background(),
"UPDATE host_disk_encryption_keys SET decryptable = NULL WHERE host_id IN (?, ?)",
hosts[5].ID,
hosts[6].ID,
)
require.NoError(t, err)
return err
})
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(2), fvsResp.ActionRequired)
require.Equal(t, uint(2), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// hosts 7,8 have disk encryption "failed" status
generateAggregateValue(hosts[6:8], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryFailed, true)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(2), fvsResp.ActionRequired)
require.Equal(t, uint(2), fvsResp.Enforcing)
require.Equal(t, uint(2), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
// hosts 9,10 have disk encryption "removing enforcement" status
generateAggregateValue(hosts[8:10], fleet.MDMAppleOperationTypeRemove, &fleet.MDMAppleDeliveryPending, true)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp)
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(2), fvsResp.ActionRequired)
require.Equal(t, uint(2), fvsResp.Enforcing)
require.Equal(t, uint(2), fvsResp.Failed)
require.Equal(t, uint(2), fvsResp.RemovingEnforcement)
// team tests ====
// host 1,2 added to team 1
tm, _ := s.ds.NewTeam(ctx, &fleet.Team{Name: "team-1"})
err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{hosts[0].ID, hosts[1].ID})
require.NoError(t, err)
// new filevault profile for team 1
prof, err = fleet.NewMDMAppleConfigProfile(mobileconfigForTest("filevault-1", mobileconfig.FleetFileVaultPayloadIdentifier), ptr.Uint(1))
require.NoError(t, err)
prof.TeamID = &tm.ID
require.NoError(t, err)
// filtering by the "team_id" query param
generateAggregateValue(hosts[0:2], fleet.MDMAppleOperationTypeInstall, &fleet.MDMAppleDeliveryVerifying, true)
fvsResp = getMDMAppleFileVauleSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/filevault/summary", nil, http.StatusOK, &fvsResp, "team_id", strconv.Itoa(int(tm.ID)))
require.Equal(t, uint(2), fvsResp.Verifying)
require.Equal(t, uint(0), fvsResp.ActionRequired)
require.Equal(t, uint(0), fvsResp.Enforcing)
require.Equal(t, uint(0), fvsResp.Failed)
require.Equal(t, uint(0), fvsResp.RemovingEnforcement)
}
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": []string{"foo", "bar"}},
},
}}}
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, []string{"foo", "bar"}, 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 strings 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{
MacOSSettings: map[string]interface{}{"enable_disk_encryption": 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, []string{"foo", "bar"}, 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": []string{}},
},
}}}
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, []string{"foo", "bar"}, 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": []string{}},
},
}}}
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, []string{}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
}
func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
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
// no macos config profile yet
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false)
// apply with disk encryption
teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"enable_disk_encryption": true},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
lastDiskActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0)
// macos config profile created
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true)
// retrieving the team returns the disk encryption setting
var teamResp getTeamResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
// apply with invalid disk encryption value should fail
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"enable_disk_encryption": 123},
},
}}}
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.enable_disk_encryption': expected bool but got float64`)
// apply an empty set of batch profiles to the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(team.ID)), "team_name", team.Name)
// the configuration profile is still there
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true)
// apply without disk encryption settings specified and unrelated field,
// should not replace existing disk encryption
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"custom_settings": []string{"a"}},
},
}}}
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.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
require.Equal(t, []string{"a"}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
``, lastDiskActID)
// apply with false would clear the existing setting, but dry-run
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"enable_disk_encryption": false},
},
}}}
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.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
``, lastDiskActID)
// apply with false clears the existing setting
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: teamName,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{"enable_disk_encryption": 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.False(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0)
// macos config profile deleted
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false)
// modify team's disk encryption via ModifyTeam endpoint
var modResp teamResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
MDM: &fleet.TeamPayloadMDM{
MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: true},
},
}, http.StatusOK, &modResp)
require.True(t, modResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0)
// macos config profile created
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true)
// modify team's disk encryption and description via ModifyTeam endpoint
modResp = teamResponse{}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), fleet.TeamPayload{
Description: ptr.String("foobar"),
MDM: &fleet.TeamPayloadMDM{
MacOSSettings: &fleet.MacOSSettings{EnableDiskEncryption: false},
},
}, http.StatusOK, &modResp)
require.False(t, modResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
require.Equal(t, "foobar", modResp.Team.Description)
s.lastActivityOfTypeMatches(fleet.ActivityTypeDisabledMacosDiskEncryption{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0)
// macos config profile deleted
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, false)
// use the MDM settings endpoint to set it to true
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings",
fleet.MDMAppleSettingsPayload{TeamID: ptr.Uint(team.ID), EnableDiskEncryption: ptr.Bool(true)}, http.StatusNoContent)
lastDiskActID = s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, team.ID, teamName), 0)
// macos config profile created
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
// use the MDM settings endpoint with no changes
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings",
fleet.MDMAppleSettingsPayload{TeamID: ptr.Uint(team.ID)}, http.StatusNoContent)
s.lastActivityOfTypeMatches(fleet.ActivityTypeEnabledMacosDiskEncryption{}.ActivityName(),
``, lastDiskActID)
// macos config profile still exists
s.assertConfigProfilesByIdentifier(ptr.Uint(team.ID), mobileconfig.FleetFileVaultPayloadIdentifier, true)
teamResp = getTeamResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
require.True(t, teamResp.Team.Config.MDM.MacOSSettings.EnableDiskEncryption)
// use the MDM settings endpoint with an unknown team id
s.Do("PATCH", "/api/latest/fleet/mdm/apple/settings",
fleet.MDMAppleSettingsPayload{TeamID: ptr.Uint(9999)}, http.StatusNotFound)
}
func (s *integrationMDMTestSuite) TestBatchSetMDMAppleProfiles() {
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/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil}, http.StatusNoContent)
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
`{"team_id": null, "team_name": null}`,
0,
)
// apply to both team id and name
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name)
// invalid team name
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil},
http.StatusNotFound, "team_name", uuid.New().String())
// duplicate profile names
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N1", "I2"),
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
// profiles with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest(p, p),
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
}
// payloads with reserved types
for p := range mobileconfig.FleetPayloadTypes() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", "II1", p),
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
}
// payloads with reserved identifiers
for p := range mobileconfig.FleetPayloadIdentifiers() {
res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTestWithContent("N1", "I1", p, "random"),
}}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
}
// successfully apply a profile for the team
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
mobileconfigForTest("N1", "I1"),
}}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
s.lastActivityMatches(
fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
0,
)
}
func (s *integrationMDMTestSuite) TestEnrollOrbitAfterDEPSync() {
t := s.T()
ctx := context.Background()
// create a host with minimal information and the serial, no uuid/osquery id
// (as when created via DEP sync). Platform must be "darwin" as this is the
// only supported OS with DEP.
dbZeroTime := time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC)
h, err := s.ds.NewHost(ctx, &fleet.Host{
HardwareSerial: uuid.New().String(),
Platform: "darwin",
LastEnrolledAt: dbZeroTime,
DetailUpdatedAt: dbZeroTime,
RefetchRequested: true,
})
require.NoError(t, err)
// create an enroll secret
secret := uuid.New().String()
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: secret}},
},
}, http.StatusOK, &applyResp)
// enroll the host from orbit, it should match the host above via the serial
var resp EnrollOrbitResponse
hostUUID := uuid.New().String()
s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
EnrollSecret: secret,
HardwareUUID: hostUUID, // will not match any existing host
HardwareSerial: h.HardwareSerial,
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.OrbitNodeKey)
// fetch the host, it will match the one created above
// (NOTE: cannot check the returned OrbitNodeKey, this field is not part of the response)
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", h.ID), nil, http.StatusOK, &hostResp)
require.Equal(t, h.ID, hostResp.Host.ID)
got, err := s.ds.LoadHostByOrbitNodeKey(ctx, resp.OrbitNodeKey)
require.NoError(t, err)
require.Equal(t, h.ID, got.ID)
// enroll the host from osquery, it should match the same host
var osqueryResp enrollAgentResponse
osqueryID := uuid.New().String()
s.DoJSON("POST", "/api/osquery/enroll", enrollAgentRequest{
EnrollSecret: secret,
HostIdentifier: osqueryID, // osquery host_identifier may not be the same as the host UUID, simulate that here
HostDetails: map[string]map[string]string{
"system_info": {
"uuid": hostUUID,
"hardware_serial": h.HardwareSerial,
},
},
}, http.StatusOK, &osqueryResp)
require.NotEmpty(t, osqueryResp.NodeKey)
// load the host by osquery node key, should match the initial host
got, err = s.ds.LoadHostByNodeKey(ctx, osqueryResp.NodeKey)
require.NoError(t, err)
require.Equal(t, h.ID, got.ID)
}
func (s *integrationMDMTestSuite) TestDiskEncryptionRotation() {
t := s.T()
h := createOrbitEnrolledHost(t, "darwin", "h", s.ds)
// false by default
resp := orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp)
require.False(t, resp.Notifications.RotateDiskEncryptionKey)
// create an auth token for h
token := "much_valid"
mysql.ExecAdhocSQL(t, s.ds, func(db sqlx.ExtContext) error {
_, err := db.ExecContext(context.Background(), `INSERT INTO host_device_auth (host_id, token) VALUES (?, ?)`, h.ID, token)
return err
})
tokRes := s.DoRawNoAuth("POST", "/api/latest/fleet/device/"+token+"/rotate_encryption_key", nil, http.StatusOK)
tokRes.Body.Close()
// true after the POST request
resp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp)
require.True(t, resp.Notifications.RotateDiskEncryptionKey)
// false on following requests
resp = orbitGetConfigResponse{}
s.DoJSON("POST", "/api/fleet/orbit/config", json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q}`, *h.OrbitNodeKey)), http.StatusOK, &resp)
require.False(t, resp.Notifications.RotateDiskEncryptionKey)
}
func (s *integrationMDMTestSuite) TestHostMDMProfilesStatus() {
t := s.T()
ctx := context.Background()
createManualMDMEnrollWithOrbit := func(secret string) *fleet.Host {
// orbit enrollment happens before mdm enrollment, otherwise the host would
// always receive the "no team" profiles on mdm enrollment since it would
// not be part of any team yet (team assignment is done when it enrolls
// with orbit).
d := newDevice(s)
// enroll the device with orbit
var resp EnrollOrbitResponse
s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
EnrollSecret: secret,
HardwareUUID: d.uuid, // will not match any existing host
HardwareSerial: d.serial,
}, 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
d.mdmEnroll(s)
return h
}
triggerReconcileProfiles := func() {
ch := make(chan bool)
s.onScheduleDone = func() { close(ch) }
_, err := s.profileSchedule.Trigger()
require.NoError(t, err)
<-ch
// 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.MacOSSettingsVerifying, fleet.MacOSSettingsPending)
return err
})
}
// add a couple global profiles
globalProfiles := [][]byte{
mobileconfigForTest("G1", "G1"),
mobileconfigForTest("G2", "G2"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// create the no-team enroll secret
var applyResp applyEnrollSecretSpecResponse
globalEnrollSec := "global_enroll_sec"
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
},
}, http.StatusOK, &applyResp)
// create a team with a couple profiles
tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_1"})
require.NoError(t, err)
tm1Profiles := [][]byte{
mobileconfigForTest("T1.1", "T1.1"),
mobileconfigForTest("T1.2", "T1.2"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
"team_id", strconv.Itoa(int(tm1.ID)))
// create the team 1 enroll secret
var teamResp teamEnrollSecretsResponse
tm1EnrollSec := "team1_enroll_sec"
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1.ID),
modifyTeamEnrollSecretsRequest{
Secrets: []fleet.EnrollSecret{{Secret: tm1EnrollSec}},
}, http.StatusOK, &teamResp)
// create another team with different profiles
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_2"})
require.NoError(t, err)
tm2Profiles := [][]byte{
mobileconfigForTest("T2.1", "T2.1"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
"team_id", strconv.Itoa(int(tm2.ID)))
// enroll a couple hosts in no team
h1 := createManualMDMEnrollWithOrbit(globalEnrollSec)
require.Nil(t, h1.TeamID)
h2 := createManualMDMEnrollWithOrbit(globalEnrollSec)
require.Nil(t, h2.TeamID)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h2: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
})
// enroll a couple hosts in team 1
h3 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
require.NotNil(t, h3.TeamID)
require.Equal(t, tm1.ID, *h3.TeamID)
h4 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
require.NotNil(t, h4.TeamID)
require.Equal(t, tm1.ID, *h4.TeamID)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T1.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h4: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T1.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// switch a no team host (h1) to a team (tm2)
var moveHostResp addHostsToTeamResponse
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h1.ID}}, http.StatusOK, &moveHostResp)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h2: {
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// switch a team host (h3) to another team (tm2)
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h3.ID}}, http.StatusOK, &moveHostResp)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T1.1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h4: {
{Identifier: "T1.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "T1.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// switch a team host (h4) to no team
s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{h4.ID}}, http.StatusOK, &moveHostResp)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h3: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T1.1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h4: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T1.1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T1.2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// add a profile to no team (h2 and h4 are now part of no team)
body, headers := generateNewProfileMultipartRequest(t, nil,
"some_name", mobileconfigForTest("G3", "G3"), s.token)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h4: {
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// add a profile to team 2 (h1 and h3 are now part of team 2)
body, headers = generateNewProfileMultipartRequest(t, &tm2.ID,
"some_name", mobileconfigForTest("T2.2", "T2.2"), s.token)
s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h3: {
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// delete a no team profile
noTeamProfs, err := s.ds.ListMDMAppleConfigProfiles(ctx, nil)
require.NoError(t, err)
var g1ProfID uint
for _, p := range noTeamProfs {
if p.Identifier == "G1" {
g1ProfID = p.ProfileID
break
}
}
require.NotZero(t, g1ProfID)
var delProfResp deleteMDMAppleConfigProfileResponse
s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", g1ProfID),
deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h4: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// 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.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h3: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.1", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.2", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// bulk-set profiles for no team, with add/delete/edit
g2Edited := mobileconfigForTest("G2b", "G2b")
g4Content := mobileconfigForTest("G4", "G4")
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
// G3 is deleted
g4Content,
},
}, http.StatusNoContent)
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h2: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G3", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h4: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "G2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G3", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// 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.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h3: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.2", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// bulk-set profiles for no team and team 2, without changes, and team 1 added (but no host affected)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
g2Edited,
g4Content,
},
}, http.StatusNoContent)
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
t22Edited,
t23Content,
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{
Profiles: [][]byte{
mobileconfigForTest("T1.3", "T1.3"),
},
}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID))
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "T2.2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "T2.3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h3: {
{Identifier: "T2.2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "T2.3", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
// 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.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.2b", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
h3: {
various profile fixes (#11084) ### Related tickets https://github.com/fleetdm/fleet/issues/10775 https://github.com/fleetdm/fleet/issues/10678 https://github.com/fleetdm/fleet/issues/11024 https://github.com/fleetdm/fleet/issues/11026 ### What's happening - Implemented the hashing mechanism defined by @mna in #10678, however this mechanism is mainly relevant for batch profile updates via the CLI, we can't leverage it when a host switches teams. - Modified `BulkSetPendingMDMAppleHostProfiles` so when two profiles with the same identifier are sheduled both for removal and update, the function will now mark only the `install` as `pending` so it's picked by the cron, and will `DELETE` the `remove` entry from the database so it's not picked by the cron and never sent to the user. - `GetHostMDMProfiles` and consequently the profiles returned in `GET /api/_version_/fleet/hosts` return `host_mdm_apple_profiles.state = NULL` as "Enforcing (pending", the distinction between `status = 'pending'` and `status IS NULL` is only useful for the cron, for users both mean the same thing, and all our profile aggregations already behave this way. - Using the solution implemented by @gillespi314 in https://github.com/fleetdm/fleet/pull/10998 we're now deleting the host row from `host_disk_encryption_keys` if a host is moved from a team that enforces disk encryption to a team that doesn't. # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Added/updated tests - [x] Manual QA for all new/changed functionality
2023-04-09 02:23:36 +00:00
{Identifier: "T2.2b", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "T2.3", OperationType: fleet.MDMAppleOperationTypeRemove, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryPending},
},
})
// apply the pending profiles
triggerReconcileProfiles()
// final state
s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
h1: {
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h2: {
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h3: {
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
h4: {
{Identifier: "G2b", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: "G4", OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
{Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMAppleOperationTypeInstall, Status: &fleet.MDMAppleDeliveryVerifying},
},
})
}
func (s *integrationMDMTestSuite) TestFleetdConfiguration() {
t := s.T()
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetdConfigPayloadIdentifier, false)
triggerSchedule := func() {
ch := make(chan bool)
s.onScheduleDone = func() { close(ch) }
_, err := s.profileSchedule.Trigger()
require.NoError(t, err)
<-ch
}
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: t.Name()}},
},
}, http.StatusOK, &applyResp)
// a new fleetd configuration profile for "no team" is created
triggerSchedule()
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetdConfigPayloadIdentifier, true)
// create a new team
tm, err := s.ds.NewTeam(context.Background(), &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
s.assertConfigProfilesByIdentifier(&tm.ID, mobileconfig.FleetdConfigPayloadIdentifier, false)
// set the default bm assignment to that team
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
"apple_bm_default_team": %q
}
}`, tm.Name)), http.StatusOK, &acResp)
// the team doesn't have any enroll secrets yet, a profile is created using the global enroll secret
triggerSchedule()
p := s.assertConfigProfilesByIdentifier(&tm.ID, mobileconfig.FleetdConfigPayloadIdentifier, true)
require.Contains(t, string(p.Mobileconfig), t.Name())
// create an enroll secret for the team
teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: tm.Name,
Secrets: []fleet.EnrollSecret{{Secret: t.Name() + "team-secret"}},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// a new fleetd configuration profile for that team is created
triggerSchedule()
p = s.assertConfigProfilesByIdentifier(&tm.ID, mobileconfig.FleetdConfigPayloadIdentifier, true)
require.Contains(t, string(p.Mobileconfig), t.Name()+"team-secret")
// the old configuration profile is kept
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetdConfigPayloadIdentifier, true)
}
func (s *integrationMDMTestSuite) TestEnqueueMDMCommand() {
ctx := context.Background()
t := s.T()
unenrolledHost := createHostAndDeviceToken(t, s.ds, "unused")
enrolledHost := newMDMEnrolledDevice(s)
base64Cmd := func(rawCmd string) string {
return base64.RawStdEncoding.EncodeToString([]byte(rawCmd))
}
newRawCmd := func(cmdUUID string) string {
return fmt.Sprintf(`<?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>Command</key>
<dict>
<key>ManagedOnly</key>
<false/>
<key>RequestType</key>
<string>ProfileList</string>
</dict>
<key>CommandUUID</key>
<string>%s</string>
</dict>
</plist>`, cmdUUID)
}
// call with unknown host UUID
uuid1 := uuid.New().String()
s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
Command: base64Cmd(newRawCmd(uuid1)),
DeviceIDs: []string{"no-such-host"},
}, http.StatusNotFound)
// get command results returns 404, that command does not exist
var cmdResResp getMDMAppleCommandResultsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusNotFound, &cmdResResp, "command_uuid", uuid1)
// list commands returns empty set
var listCmdResp listMDMAppleCommandsResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
require.Empty(t, listCmdResp.Results)
// call with unenrolled host UUID
res := s.Do("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
Command: base64Cmd(newRawCmd(uuid.New().String())),
DeviceIDs: []string{unenrolledHost.UUID},
}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, "at least one of the hosts is not enrolled in MDM")
// call with enrolled host UUID
uuid2 := uuid.New().String()
rawCmd := newRawCmd(uuid2)
var resp enqueueMDMAppleCommandResponse
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enqueue",
enqueueMDMAppleCommandRequest{
Command: base64Cmd(rawCmd),
DeviceIDs: []string{enrolledHost.uuid},
}, http.StatusOK, &resp)
require.NotEmpty(t, resp.CommandUUID)
require.Contains(t, rawCmd, resp.CommandUUID)
require.Empty(t, resp.FailedUUIDs)
require.Equal(t, "ProfileList", resp.RequestType)
// the command exists but no results yet
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusOK, &cmdResResp, "command_uuid", uuid2)
require.Len(t, cmdResResp.Results, 0)
// simulate a result and call again
err := s.mdmStorage.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: enrolledHost.uuid},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "ProfileList",
Raw: []byte(rawCmd),
})
require.NoError(t, err)
h, err := s.ds.HostByIdentifier(ctx, enrolledHost.uuid)
require.NoError(t, err)
h.Hostname = "test-host"
err = s.ds.UpdateHost(ctx, h)
require.NoError(t, err)
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commandresults", nil, http.StatusOK, &cmdResResp, "command_uuid", uuid2)
require.Len(t, cmdResResp.Results, 1)
require.NotZero(t, cmdResResp.Results[0].UpdatedAt)
cmdResResp.Results[0].UpdatedAt = time.Time{}
require.Equal(t, &fleet.MDMAppleCommandResult{
DeviceID: enrolledHost.uuid,
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "ProfileList",
Result: []byte(rawCmd),
Hostname: "test-host",
}, cmdResResp.Results[0])
// list commands returns that command
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/commands", nil, http.StatusOK, &listCmdResp)
require.Len(t, listCmdResp.Results, 1)
require.NotZero(t, listCmdResp.Results[0].UpdatedAt)
listCmdResp.Results[0].UpdatedAt = time.Time{}
require.Equal(t, &fleet.MDMAppleCommand{
DeviceID: enrolledHost.uuid,
CommandUUID: uuid2,
Status: "Acknowledged",
RequestType: "ProfileList",
Hostname: "test-host",
}, listCmdResp.Results[0])
}
func (s *integrationMDMTestSuite) TestBootstrapPackage() {
t := s.T()
read := func(name string) []byte {
b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", name))
require.NoError(t, err)
return b
}
invalidPkg := read("invalid.tar.gz")
unsignedPkg := read("unsigned.pkg")
wrongTOCPkg := read("wrong-toc.pkg")
signedPkg := read("signed.pkg")
// empty bootstrap package
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{}, http.StatusBadRequest, "package multipart field is required")
// no name
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg}, http.StatusBadRequest, "package multipart field is required")
// invalid
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: invalidPkg, Name: "invalid.tar.gz"}, http.StatusBadRequest, "invalid file type")
// unsigned
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: unsignedPkg, Name: "pkg.pkg"}, http.StatusBadRequest, "file is not signed")
// wrong TOC
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: wrongTOCPkg, Name: "pkg.pkg"}, http.StatusBadRequest, "invalid package")
// successfully upload a package
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "pkg.pkg", TeamID: 0}, http.StatusOK, "")
// check the activity log
s.lastActivityMatches(
fleet.ActivityTypeAddedBootstrapPackage{}.ActivityName(),
`{"bootstrap_package_name": "pkg.pkg", "team_id": null, "team_name": null}`,
0,
)
// get package metadata
var metadataResp bootstrapPackageMetadataResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap/0/metadata", nil, http.StatusOK, &metadataResp)
require.Equal(t, metadataResp.MDMAppleBootstrapPackage.Name, "pkg.pkg")
require.NotEmpty(t, metadataResp.MDMAppleBootstrapPackage.Sha256, "")
require.NotEmpty(t, metadataResp.MDMAppleBootstrapPackage.Token)
// download a package, wrong token
var downloadResp downloadBootstrapPackageResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap?token=bad", nil, http.StatusNotFound, &downloadResp)
resp := s.DoRaw("GET", fmt.Sprintf("/api/latest/fleet/mdm/apple/bootstrap?token=%s", metadataResp.MDMAppleBootstrapPackage.Token), nil, http.StatusOK)
respBytes, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.EqualValues(t, signedPkg, respBytes)
// missing package
metadataResp = bootstrapPackageMetadataResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap/1/metadata", nil, http.StatusNotFound, &metadataResp)
// delete package
var deleteResp deleteBootstrapPackageResponse
s.DoJSON("DELETE", "/api/latest/fleet/mdm/apple/bootstrap/0", nil, http.StatusOK, &deleteResp)
// check the activity log
s.lastActivityMatches(
fleet.ActivityTypeDeletedBootstrapPackage{}.ActivityName(),
`{"bootstrap_package_name": "pkg.pkg", "team_id": null, "team_name": null}`,
0,
)
metadataResp = bootstrapPackageMetadataResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap/0/metadata", nil, http.StatusNotFound, &metadataResp)
// trying to delete again is a bad request
s.DoJSON("DELETE", "/api/latest/fleet/mdm/apple/bootstrap/0", nil, http.StatusNotFound, &deleteResp)
}
func (s *integrationMDMTestSuite) TestBootstrapPackageStatus() {
t := s.T()
pkg, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
require.NoError(t, err)
// upload a bootstrap package for "no team"
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: pkg, Name: "pkg.pkg", TeamID: 0}, http.StatusOK, "")
// get package metadata
var metadataResp bootstrapPackageMetadataResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap/0/metadata", nil, http.StatusOK, &metadataResp)
globalBootstrapPackage := metadataResp.MDMAppleBootstrapPackage
// create a team and upload a bootstrap package for that team.
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
// upload a bootstrap package for the team
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: pkg, Name: "pkg.pkg", TeamID: team.ID}, http.StatusOK, "")
// get package metadata
metadataResp = bootstrapPackageMetadataResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/apple/bootstrap/%d/metadata", team.ID), nil, http.StatusOK, &metadataResp)
teamBootstrapPackage := metadataResp.MDMAppleBootstrapPackage
type deviceWithResponse struct {
// An empty string here means "skip the response", ie: pending
bootstrapResponse string
device *device
}
// Note: The responses specified here are not a 1:1 mapping of the possible responses specified
// by Apple. Instead `enrollAndCheckBootstrapPackage` below uses them to simulate scenarios in
// which a device may or may not send a response. For example, "Offline" means that no response
// will be sent by the device, which should in turn be interpreted by Fleet as "Pending"). See
// https://developer.apple.com/documentation/devicemanagement/installenterpriseapplicationresponse
noTeamDevices := []deviceWithResponse{
{"Acknowledge", newDevice(s)},
{"Acknowledge", newDevice(s)},
{"Acknowledge", newDevice(s)},
{"Error", newDevice(s)},
{"Offline", newDevice(s)},
{"Offline", newDevice(s)},
}
teamDevices := []deviceWithResponse{
{"Acknowledge", newDevice(s)},
{"Acknowledge", newDevice(s)},
{"Error", newDevice(s)},
{"Error", newDevice(s)},
{"Error", newDevice(s)},
{"Offline", newDevice(s)},
}
expectedUUIDsByTeamAndStatus := make(map[uint]map[fleet.MDMBootstrapPackageStatus][]string)
expectedUUIDsByTeamAndStatus[0] = map[fleet.MDMBootstrapPackageStatus][]string{
fleet.MDMBootstrapPackageInstalled: {noTeamDevices[0].device.uuid, noTeamDevices[1].device.uuid, noTeamDevices[2].device.uuid},
fleet.MDMBootstrapPackageFailed: {noTeamDevices[3].device.uuid},
fleet.MDMBootstrapPackagePending: {noTeamDevices[4].device.uuid, noTeamDevices[5].device.uuid},
}
expectedUUIDsByTeamAndStatus[team.ID] = map[fleet.MDMBootstrapPackageStatus][]string{
fleet.MDMBootstrapPackageInstalled: {teamDevices[0].device.uuid, teamDevices[1].device.uuid},
fleet.MDMBootstrapPackageFailed: {teamDevices[2].device.uuid, teamDevices[3].device.uuid, teamDevices[4].device.uuid},
fleet.MDMBootstrapPackagePending: {teamDevices[5].device.uuid},
}
// for good measure, add a couple of manually enrolled hosts
_ = newMDMEnrolledDevice(s)
_ = newMDMEnrolledDevice(s)
// create a non-macOS host
_, err = s.ds.NewHost(context.Background(), &fleet.Host{
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{
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)
ch := make(chan bool)
mockRespDevices := noTeamDevices
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
require.NoError(t, err)
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "abc"})
require.NoError(t, err)
case "/server/devices":
err := encoder.Encode(godep.DeviceResponse{})
require.NoError(t, err)
case "/devices/sync":
depResp := []godep.Device{}
for _, gd := range mockRespDevices {
depResp = append(depResp, godep.Device{SerialNumber: gd.device.serial})
}
err := encoder.Encode(godep.DeviceResponse{Devices: depResp})
require.NoError(t, err)
case "/profile/devices":
ch <- true
_, _ = w.Write([]byte(`{}`))
default:
_, _ = w.Write([]byte(`{}`))
}
}))
// trigger a dep sync
_, err = s.depSchedule.Trigger()
require.NoError(t, err)
<-ch
var summaryResp getMDMAppleBootstrapPackageSummaryResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap/summary", nil, http.StatusOK, &summaryResp)
require.Equal(t, fleet.MDMAppleBootstrapPackageSummary{Pending: uint(len(noTeamDevices))}, summaryResp.MDMAppleBootstrapPackageSummary)
// set the default bm assignment to `team`
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
"mdm": {
"apple_bm_default_team": %q
}
}`, team.Name)), http.StatusOK, &acResp)
// trigger a dep sync
mockRespDevices = teamDevices
_, err = s.depSchedule.Trigger()
require.NoError(t, err)
<-ch
summaryResp = getMDMAppleBootstrapPackageSummaryResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/apple/bootstrap/summary?team_id=%d", team.ID), nil, http.StatusOK, &summaryResp)
require.Equal(t, fleet.MDMAppleBootstrapPackageSummary{Pending: uint(len(noTeamDevices))}, summaryResp.MDMAppleBootstrapPackageSummary)
mockErrorChain := []mdm.ErrorChain{
{ErrorCode: 12021, ErrorDomain: "MCMDMErrorDomain", LocalizedDescription: "Unknown command", USEnglishDescription: "Unknown command"},
}
// devices send their responses
enrollAndCheckBootstrapPackage := func(d *deviceWithResponse, bp *fleet.MDMAppleBootstrapPackage) {
d.device.mdmEnroll(s)
cmd := d.device.idle()
for cmd != nil {
// if the command is to install the bootstrap package
if manifest := cmd.Command.InstallEnterpriseApplication.Manifest; manifest != nil {
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.Equal(t, "software-package", (*manifest).ManifestItems[0].Assets[0].Kind)
wantURL, err := bp.URL("https://example.org")
require.NoError(t, err)
require.Equal(t, wantURL, (*manifest).ManifestItems[0].Assets[0].URL)
// respond to the command accordingly
switch d.bootstrapResponse {
case "Acknowledge":
cmd = d.device.acknowledge(cmd.CommandUUID)
continue
case "Error":
cmd = d.device.err(cmd.CommandUUID, mockErrorChain)
continue
case "Offline":
// host is offline, can't process any more commands
cmd = nil
continue
}
}
cmd = d.device.acknowledge(cmd.CommandUUID)
}
}
for _, d := range noTeamDevices {
dd := d
enrollAndCheckBootstrapPackage(&dd, globalBootstrapPackage)
}
for _, d := range teamDevices {
dd := d
enrollAndCheckBootstrapPackage(&dd, teamBootstrapPackage)
}
checkHostDetails := func(t *testing.T, hostID uint, hostUUID string, expectedStatus fleet.MDMBootstrapPackageStatus) {
var hostResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", hostID), nil, http.StatusOK, &hostResp)
require.NotNil(t, hostResp.Host)
require.NotNil(t, hostResp.Host.MDM.MacOSSetup)
UI for bootstrap package flows (#11288) relates to #10935 This is the UI for all the flows around adding, removing, downloading, and viewing information about a bootstrap package for fleet mdm. This is pretty comprehensive but includes: ### Backend **Update `Get host/id`** to include bootstrap package name ```json { "macos_setup": { ... "bootstrap_package_name": "test.pkg" } } ``` ### Frontend **UI for ABM not being set up**: ![image](https://user-images.githubusercontent.com/1153709/234018772-3221e27b-50a4-454e-8e9f-b62c9d349010.png) **UIs for uploading, downloading, and deleting bootstrap package**: ![image](https://user-images.githubusercontent.com/1153709/234017915-871f252f-bf80-4282-9acf-5ebea12c6efa.png) ![image](https://user-images.githubusercontent.com/1153709/234018029-322a5f30-dd22-44e3-b9ae-a4af7acb68b4.png) ![image](https://user-images.githubusercontent.com/1153709/234018163-4b84a2ce-a064-4952-a63d-0c8307391052.png) **UIs for seeing bootstrap status aggregate data** ![image](https://user-images.githubusercontent.com/1153709/234018107-455d63ab-5b2c-4727-ad20-eef6b269c336.png) **UIs for filtering hosts by bootstrap status** ![image](https://user-images.githubusercontent.com/1153709/234018334-170fe93a-700e-48eb-b198-2a1cc54d31a7.png) **UIs for seeing package status on host details and my device page**: ![image](https://user-images.githubusercontent.com/1153709/234018488-7b515db4-1248-4be7-8de3-9b74bb5d4795.png) ![image](https://user-images.githubusercontent.com/1153709/234018525-d653cb2d-9ef9-437e-8eba-141e557f4f39.png) - [x] Changes file added for user-visible changes in `changes/` or `orbit/changes/`. See [Changes files](https://fleetdm.com/docs/contributing/committing-changes#changes-files) for more information. - [x] Manual QA for all new/changed functionality --------- Co-authored-by: Roberto Dip <dip.jesusr@gmail.com> Co-authored-by: gillespi314 <73313222+gillespi314@users.noreply.github.com> Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
2023-04-27 15:10:41 +00:00
require.Equal(t, hostResp.Host.MDM.MacOSSetup.BootstrapPackageName, "pkg.pkg")
require.Equal(t, hostResp.Host.MDM.MacOSSetup.BootstrapPackageStatus, expectedStatus)
if expectedStatus == fleet.MDMBootstrapPackageFailed {
require.Equal(t, hostResp.Host.MDM.MacOSSetup.Detail, apple_mdm.FmtErrorChain(mockErrorChain))
} else {
require.Empty(t, hostResp.Host.MDM.MacOSSetup.Detail)
}
require.Nil(t, hostResp.Host.MDM.MacOSSetup.Result)
var hostByIdentifierResp getHostResponse
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", hostUUID), nil, http.StatusOK, &hostByIdentifierResp)
require.NotNil(t, hostByIdentifierResp.Host)
require.NotNil(t, hostByIdentifierResp.Host.MDM.MacOSSetup)
require.Equal(t, hostByIdentifierResp.Host.MDM.MacOSSetup.BootstrapPackageStatus, expectedStatus)
if expectedStatus == fleet.MDMBootstrapPackageFailed {
require.Equal(t, hostResp.Host.MDM.MacOSSetup.Detail, apple_mdm.FmtErrorChain(mockErrorChain))
} else {
require.Empty(t, hostResp.Host.MDM.MacOSSetup.Detail)
}
require.Nil(t, hostResp.Host.MDM.MacOSSetup.Result)
}
checkHostAPIs := func(t *testing.T, status fleet.MDMBootstrapPackageStatus, teamID *uint) {
var expectedUUIDs []string
if teamID == nil {
expectedUUIDs = expectedUUIDsByTeamAndStatus[0][status]
} else {
expectedUUIDs = expectedUUIDsByTeamAndStatus[*teamID][status]
}
listHostsPath := fmt.Sprintf("/api/latest/fleet/hosts?bootstrap_package=%s", status)
if teamID != nil {
listHostsPath += fmt.Sprintf("&team_id=%d", *teamID)
}
var listHostsResp listHostsResponse
s.DoJSON("GET", listHostsPath, nil, http.StatusOK, &listHostsResp)
require.NotNil(t, listHostsResp.Hosts)
require.Len(t, listHostsResp.Hosts, len(expectedUUIDs))
gotHostsByUUID := make(map[string]fleet.HostResponse)
for _, h := range listHostsResp.Hosts {
gotHostsByUUID[h.UUID] = h
}
require.Len(t, gotHostsByUUID, len(expectedUUIDs))
for _, uuid := range expectedUUIDs {
require.Contains(t, gotHostsByUUID, uuid)
h := gotHostsByUUID[uuid]
checkHostDetails(t, h.ID, h.UUID, status)
}
countPath := fmt.Sprintf("/api/latest/fleet/hosts/count?bootstrap_package=%s", status)
if teamID != nil {
countPath += fmt.Sprintf("&team_id=%d", *teamID)
}
var countResp countHostsResponse
s.DoJSON("GET", countPath, nil, http.StatusOK, &countResp)
require.Equal(t, countResp.Count, len(expectedUUIDs))
}
// check summary no team hosts
summaryResp = getMDMAppleBootstrapPackageSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/bootstrap/summary", nil, http.StatusOK, &summaryResp)
require.Equal(t, fleet.MDMAppleBootstrapPackageSummary{
Installed: uint(3),
Pending: uint(2),
Failed: uint(1),
}, summaryResp.MDMAppleBootstrapPackageSummary)
checkHostAPIs(t, fleet.MDMBootstrapPackageInstalled, nil)
checkHostAPIs(t, fleet.MDMBootstrapPackagePending, nil)
checkHostAPIs(t, fleet.MDMBootstrapPackageFailed, nil)
// check team summary
summaryResp = getMDMAppleBootstrapPackageSummaryResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/mdm/apple/bootstrap/summary?team_id=%d", team.ID), nil, http.StatusOK, &summaryResp)
require.Equal(t, fleet.MDMAppleBootstrapPackageSummary{
Installed: uint(2),
Pending: uint(1),
Failed: uint(3),
}, summaryResp.MDMAppleBootstrapPackageSummary)
checkHostAPIs(t, fleet.MDMBootstrapPackageInstalled, &team.ID)
checkHostAPIs(t, fleet.MDMBootstrapPackagePending, &team.ID)
checkHostAPIs(t, fleet.MDMBootstrapPackageFailed, &team.ID)
}
func (s *integrationMDMTestSuite) TestMacosSetupAssistant() {
ctx := context.Background()
t := s.T()
// get for no team returns 404
var getResp getMDMAppleSetupAssistantResponse
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusNotFound, &getResp)
// get for non-existing team returns 404
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusNotFound, &getResp, "team_id", "123")
// create a setup assistant for no team
noTeamProf := `{"x": 1}`
var createResp createMDMAppleSetupAssistantResponse
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: nil,
Name: "no-team",
EnrollmentProfile: json.RawMessage(noTeamProf),
}, http.StatusOK, &createResp)
noTeamAsst := createResp.MDMAppleSetupAssistant
require.Nil(t, noTeamAsst.TeamID)
require.NotZero(t, noTeamAsst.UploadedAt)
require.Equal(t, "no-team", noTeamAsst.Name)
require.JSONEq(t, noTeamProf, string(noTeamAsst.Profile))
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
`{"name": "no-team", "team_id": null, "team_name": null}`, 0)
// create a team and a setup assistant for that team
tm, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: t.Name(),
Description: "desc",
})
require.NoError(t, err)
tmProf := `{"y": 1}`
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team1",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusOK, &createResp)
tmAsst := createResp.MDMAppleSetupAssistant
require.NotNil(t, tmAsst.TeamID)
require.Equal(t, tm.ID, *tmAsst.TeamID)
require.NotZero(t, tmAsst.UploadedAt)
require.Equal(t, "team1", tmAsst.Name)
require.JSONEq(t, tmProf, string(tmAsst.Profile))
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team1", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0)
// update no-team
noTeamProf = `{"x": 2}`
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: nil,
Name: "no-team2",
EnrollmentProfile: json.RawMessage(noTeamProf),
}, http.StatusOK, &createResp)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
`{"name": "no-team2", "team_id": null, "team_name": null}`, 0)
// update team
tmProf = `{"y": 2}`
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team2",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusOK, &createResp)
lastChangedActID := s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0)
// sleep a second so the uploaded-at timestamp would change if there were
// changes, then update again no team/team but without any change, doesn't
// create a changed activity.
time.Sleep(time.Second)
// no change to no-team
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: nil,
Name: "no-team2",
EnrollmentProfile: json.RawMessage(noTeamProf),
}, http.StatusOK, &createResp)
// the last activity is that of the team (i.e. no new activity was created for no-team)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), lastChangedActID)
// no change to team
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team2",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusOK, &createResp)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), lastChangedActID)
// update team with only a setup assistant JSON change, should detect it
// and create a new activity (name is the same)
tmProf = `{"y": 3}`
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team2",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusOK, &createResp)
latestChangedActID := s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0)
require.Greater(t, latestChangedActID, lastChangedActID)
// get no team
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusOK, &getResp)
require.Nil(t, getResp.TeamID)
require.NotZero(t, getResp.UploadedAt)
require.Equal(t, "no-team2", getResp.Name)
require.JSONEq(t, noTeamProf, string(getResp.Profile))
// get team
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusOK, &getResp, "team_id", fmt.Sprint(tm.ID))
require.NotNil(t, getResp.TeamID)
require.Equal(t, tm.ID, *getResp.TeamID)
require.NotZero(t, getResp.UploadedAt)
require.Equal(t, "team2", getResp.Name)
require.JSONEq(t, tmProf, string(getResp.Profile))
// try to set the configuration_web_url key
tmProf = `{"configuration_web_url": "https://example.com"}`
res := s.Do("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team3",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusUnprocessableEntity)
errMsg := extractServerErrorText(res.Body)
require.Contains(t, errMsg, `The automatic enrollment profile cant include configuration_web_url.`)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), latestChangedActID)
// try to set the await_device_configured
tmProf = `{"await_device_configured": true}`
res = s.Do("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team4",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `The automatic enrollment profile cant include await_device_configured.`)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), latestChangedActID)
// try to set the url
tmProf = `{"url": "https://example.com"}`
res = s.Do("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team5",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusUnprocessableEntity)
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `The automatic enrollment profile cant include url.`)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), latestChangedActID)
// try to set a non-object json value
tmProf = `true`
res = s.Do("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm.ID,
Name: "team6",
EnrollmentProfile: json.RawMessage(tmProf),
}, http.StatusInternalServerError) // TODO: that should be a 4xx error, see #4406
errMsg = extractServerErrorText(res.Body)
require.Contains(t, errMsg, `cannot unmarshal bool into Go value of type map[string]interface`)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "team2", "team_id": %d, "team_name": %q}`, tm.ID, tm.Name), latestChangedActID)
// delete the no-team setup assistant
s.Do("DELETE", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusNoContent)
latestChangedActID = s.lastActivityMatches(fleet.ActivityTypeDeletedMacosSetupAssistant{}.ActivityName(),
`{"name": "no-team2", "team_id": null, "team_name": null}`, 0)
// get for no team returns 404
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusNotFound, &getResp)
// delete the team (not the assistant), this also deletes the assistant
err = s.ds.DeleteTeam(ctx, tm.ID)
require.NoError(t, err)
// get for team returns 404
s.DoJSON("GET", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusNotFound, &getResp, "team_id", fmt.Sprint(tm.ID))
// no deleted activity was created for the team as the whole team was deleted
// (a deleted team activity would exist if that was done via the API and not
// directly with the datastore)
s.lastActivityMatches(fleet.ActivityTypeDeletedMacosSetupAssistant{}.ActivityName(),
`{"name": "no-team2", "team_id": null, "team_name": null}`, latestChangedActID)
// create another team and a setup assistant for that team
tm2, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: t.Name() + "2",
Description: "desc2",
})
require.NoError(t, err)
tm2Prof := `{"z": 1}`
s.DoJSON("POST", "/api/latest/fleet/mdm/apple/enrollment_profile", createMDMAppleSetupAssistantRequest{
TeamID: &tm2.ID,
Name: "teamB",
EnrollmentProfile: json.RawMessage(tm2Prof),
}, http.StatusOK, &createResp)
s.lastActivityMatches(fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "teamB", "team_id": %d, "team_name": %q}`, tm2.ID, tm2.Name), 0)
// delete that team's setup assistant
s.Do("DELETE", "/api/latest/fleet/mdm/apple/enrollment_profile", nil, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
s.lastActivityMatches(fleet.ActivityTypeDeletedMacosSetupAssistant{}.ActivityName(),
fmt.Sprintf(`{"name": "teamB", "team_id": %d, "team_name": %q}`, tm2.ID, tm2.Name), 0)
}
// only asserts the profile identifier, status and operation (per host)
func (s *integrationMDMTestSuite) assertHostConfigProfiles(want map[*fleet.Host][]fleet.HostMDMAppleProfile) {
t := s.T()
ds := s.ds
ctx := context.Background()
for h, wantProfs := range want {
gotProfs, err := ds.GetHostMDMProfiles(ctx, h.UUID)
require.NoError(t, err)
require.Equal(t, len(wantProfs), len(gotProfs), "host uuid: %s", h.UUID)
sort.Slice(gotProfs, func(i, j int) bool {
l, r := gotProfs[i], gotProfs[j]
return l.Identifier < r.Identifier
})
sort.Slice(wantProfs, func(i, j int) bool {
l, r := wantProfs[i], wantProfs[j]
return l.Identifier < r.Identifier
})
for i, wp := range wantProfs {
gp := gotProfs[i]
require.Equal(t, wp.Identifier, gp.Identifier, "host uuid: %s, prof id: %s", h.UUID, gp.Identifier)
require.Equal(t, wp.OperationType, gp.OperationType, "host uuid: %s, prof id: %s", h.UUID, gp.Identifier)
require.Equal(t, wp.Status, gp.Status, "host uuid: %s, prof id: %s", h.UUID, gp.Identifier)
}
}
}
func (s *integrationMDMTestSuite) assertConfigProfilesByIdentifier(teamID *uint, profileIdent string, exists bool) (profile *fleet.MDMAppleConfigProfile) {
t := s.T()
if teamID == nil {
teamID = ptr.Uint(0)
}
var cfgProfs []*fleet.MDMAppleConfigProfile
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(context.Background(), q, &cfgProfs, `SELECT * FROM mdm_apple_configuration_profiles WHERE team_id = ?`, teamID)
})
label := "exist"
if !exists {
label = "not exist"
}
require.Condition(t, func() bool {
for _, p := range cfgProfs {
if p.Identifier == profileIdent {
profile = p
return exists // success if we want it to exist, failure if we don't
}
}
return !exists
}, "a config profile must %s with identifier: %s", label, profileIdent)
return profile
}
// generates the body and headers part of a multipart request ready to be
// used via s.DoRawWithHeaders to POST /api/_version_/fleet/mdm/apple/profiles.
func generateNewProfileMultipartRequest(t *testing.T, tmID *uint,
fileName string, fileContent []byte, token string,
) (*bytes.Buffer, map[string]string) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if tmID != nil {
err := writer.WriteField("team_id", fmt.Sprintf("%d", *tmID))
require.NoError(t, err)
}
ff, err := writer.CreateFormFile("profile", fileName)
require.NoError(t, err)
_, err = io.Copy(ff, bytes.NewReader(fileContent))
require.NoError(t, err)
err = writer.Close()
require.NoError(t, err)
headers := map[string]string{
"Content-Type": writer.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", token),
}
return &body, headers
}
func (s *integrationMDMTestSuite) uploadBootstrapPackage(
pkg *fleet.MDMAppleBootstrapPackage,
expectedStatus int,
wantErr string,
) {
t := s.T()
var b bytes.Buffer
w := multipart.NewWriter(&b)
// add the package field
fw, err := w.CreateFormFile("package", pkg.Name)
require.NoError(t, err)
_, err = io.Copy(fw, bytes.NewBuffer(pkg.Bytes))
require.NoError(t, err)
// add the team_id field
err = w.WriteField("team_id", fmt.Sprint(pkg.TeamID))
require.NoError(t, err)
w.Close()
headers := map[string]string{
"Content-Type": w.FormDataContentType(),
"Accept": "application/json",
"Authorization": fmt.Sprintf("Bearer %s", s.token),
}
res := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/bootstrap", b.Bytes(), expectedStatus, headers)
if wantErr != "" {
errMsg := extractServerErrorText(res.Body)
assert.Contains(t, errMsg, wantErr)
}
}
type device struct {
uuid string
serial string
model string
s *integrationMDMTestSuite
scepCert *x509.Certificate
scepKey *rsa.PrivateKey
}
func newDevice(s *integrationMDMTestSuite) *device {
return &device{
uuid: strings.ToUpper(uuid.New().String()),
serial: randSerial(),
model: "MacBookPro16,1",
s: s,
}
}
func newMDMEnrolledDevice(s *integrationMDMTestSuite) *device {
d := newDevice(s)
d.mdmEnroll(s)
return d
}
func (d *device) mdmEnroll(s *integrationMDMTestSuite) {
d.scepEnroll()
d.authenticate()
d.tokenUpdate()
}
func (d *device) authenticate() {
payload := map[string]any{
"MessageType": "Authenticate",
"UDID": d.uuid,
"Model": d.model,
"DeviceName": "testdevice" + d.serial,
"Topic": "com.apple.mgmt.External." + d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"SerialNumber": d.serial,
}
d.request("application/x-apple-aspen-mdm-checkin", payload)
}
func (d *device) tokenUpdate() {
payload := map[string]any{
"MessageType": "TokenUpdate",
"UDID": d.uuid,
"Topic": "com.apple.mgmt.External." + d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"NotOnConsole": "false",
"PushMagic": "pushmagic" + d.serial,
"Token": []byte("token" + d.serial),
}
d.request("application/x-apple-aspen-mdm-checkin", payload)
}
func (d *device) checkout() {
payload := map[string]any{
"MessageType": "CheckOut",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
}
d.request("application/x-apple-aspen-mdm-checkin", payload)
}
// Devices send an Idle status to signal the server that they're ready to
// receive commands.
// The server can signal back with either a command to run
// or an empty response body to end the communication.
func (d *device) idle() *micromdm.CommandPayload {
payload := map[string]any{
"Status": "Idle",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
}
return d.sendAndDecodeCommandResponse(payload)
}
func (d *device) acknowledge(cmdUUID string) *micromdm.CommandPayload {
payload := map[string]any{
"Status": "Acknowledged",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"CommandUUID": cmdUUID,
}
return d.sendAndDecodeCommandResponse(payload)
}
func (d *device) err(cmdUUID string, errChain []mdm.ErrorChain) *micromdm.CommandPayload {
payload := map[string]any{
"Status": "Error",
"Topic": "com.apple.mgmt.External." + d.uuid,
"UDID": d.uuid,
"EnrollmentID": "testenrollmentid-" + d.uuid,
"CommandUUID": cmdUUID,
"ErrorChain": errChain,
}
return d.sendAndDecodeCommandResponse(payload)
}
func (d *device) sendAndDecodeCommandResponse(payload map[string]any) *micromdm.CommandPayload {
res := d.request("", payload)
if res.ContentLength == 0 {
return nil
}
raw, err := io.ReadAll(res.Body)
require.NoError(d.s.T(), err)
cmd, err := mdm.DecodeCommand(raw)
require.NoError(d.s.T(), err)
var p micromdm.CommandPayload
err = plist.Unmarshal(cmd.Raw, &p)
require.NoError(d.s.T(), err)
return &p
}
func (d *device) request(reqType string, payload map[string]any) *http.Response {
body, err := plist.Marshal(payload)
require.NoError(d.s.T(), err)
signedData, err := pkcs7.NewSignedData(body)
require.NoError(d.s.T(), err)
err = signedData.AddSigner(d.scepCert, d.scepKey, pkcs7.SignerInfoConfig{})
require.NoError(d.s.T(), err)
sig, err := signedData.Finish()
require.NoError(d.s.T(), err)
return d.s.DoRawWithHeaders(
"POST",
"/mdm/apple/mdm",
body,
200,
map[string]string{
"Content-Type": reqType,
"Mdm-Signature": base64.StdEncoding.EncodeToString(sig),
},
)
}
func (d *device) scepEnroll() {
t := d.s.T()
ctx := context.Background()
logger := kitlog.NewJSONLogger(os.Stdout)
logger = level.NewFilter(logger, level.AllowDebug())
client, err := scepclient.New(d.s.server.URL+apple_mdm.SCEPPath, logger)
require.NoError(t, err)
resp, _, err := client.GetCACert(ctx, "")
require.NoError(t, err)
certs, err := x509.ParseCertificates(resp)
require.NoError(t, err)
key, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
csrTemplate := x509util.CertificateRequest{
CertificateRequest: x509.CertificateRequest{
Subject: pkix.Name{
CommonName: "fleet-test",
},
SignatureAlgorithm: x509.SHA256WithRSA,
},
ChallengePassword: d.s.fleetCfg.MDM.AppleSCEPChallenge,
}
csrDerBytes, err := x509util.CreateCertificateRequest(rand.Reader, &csrTemplate, key)
require.NoError(t, err)
csr, err := x509.ParseCertificateRequest(csrDerBytes)
require.NoError(t, err)
notBefore := time.Now()
2023-01-17 20:33:27 +00:00
notAfter := notBefore.Add(time.Hour)
certTemplate := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "SCEP SIGNER",
Organization: csr.Subject.Organization,
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certDerBytes, err := x509.CreateCertificate(rand.Reader, &certTemplate, &certTemplate, &key.PublicKey, key)
require.NoError(t, err)
cert, err := x509.ParseCertificate(certDerBytes)
require.NoError(t, err)
tmpl := &scep.PKIMessage{
MessageType: scep.PKCSReq,
Recipients: certs,
SignerKey: key,
SignerCert: cert,
CSRReqMessage: &scep.CSRReqMessage{
ChallengePassword: d.s.fleetCfg.MDM.AppleSCEPChallenge,
},
}
msg, err := scep.NewCSRRequest(csr, tmpl, scep.WithLogger(logger))
require.NoError(t, err)
respBytes, err := client.PKIOperation(ctx, msg.Raw)
require.NoError(t, err)
respMsg, err := scep.ParsePKIMessage(respBytes, scep.WithLogger(logger), scep.WithCACerts(msg.Recipients))
require.NoError(t, err)
require.Equal(t, scep.SUCCESS, respMsg.PKIStatus)
err = respMsg.DecryptPKIEnvelope(cert, key)
require.NoError(t, err)
d.scepCert = respMsg.CertRepMessage.Certificate
d.scepKey = key
}
// numbers plus capital letters without I, L, O for readability
const serialLetters = "0123456789ABCDEFGHJKMNPQRSTUVWXYZ"
func randSerial() string {
b := make([]byte, 12)
for i := range b {
//nolint:gosec // not used for crypto, only to generate random serial for testing
b[i] = serialLetters[mathrand.Intn(len(serialLetters))]
}
return string(b)
}
var testBMToken = &nanodep_client.OAuth1Tokens{
ConsumerKey: "test_consumer",
ConsumerSecret: "test_secret",
AccessToken: "test_access_token",
AccessSecret: "test_access_secret",
AccessTokenExpiry: time.Date(2999, 1, 1, 0, 0, 0, 0, time.UTC),
}
// TestGitOpsUserActions tests the MDM permissions listed in ../../docs/Using-Fleet/Permissions.md.
func (s *integrationMDMTestSuite) TestGitOpsUserActions() {
t := s.T()
ctx := context.Background()
//
// Setup test data.
// All setup actions are authored by a global admin.
//
t1, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Foo",
})
require.NoError(t, err)
t2, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Bar",
})
require.NoError(t, err)
t3, err := s.ds.NewTeam(ctx, &fleet.Team{
Name: "Zoo",
})
require.NoError(t, err)
// Create the global GitOps user we'll use in tests.
u := &fleet.User{
Name: "GitOps",
Email: "gitops1-mdm@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)
// Create a GitOps user for team t1 we'll use in tests.
u2 := &fleet.User{
Name: "GitOps 2",
Email: "gitops2-mdm@example.com",
GlobalRole: nil,
Teams: []fleet.UserTeam{
{
Team: *t1,
Role: fleet.RoleGitOps,
},
{
Team: *t3,
Role: fleet.RoleGitOps,
},
},
}
require.NoError(t, u2.SetPassword(test.GoodPassword, 10, 10))
_, err = s.ds.NewUser(context.Background(), u2)
require.NoError(t, err)
//
// Start running permission tests with user gitops1-mdm.
//
s.setTokenForTest(t, "gitops1-mdm@example.com", test.GoodPassword)
// Attempt to edit global MDM settings, should allow.
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": { "enable_disk_encryption": true } }
}`), http.StatusOK, &acResp)
assert.True(t, acResp.MDM.MacOSSettings.EnableDiskEncryption)
// Attempt to set profile batch globally, should allow.
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
// Attempt to edit team MDM settings, should allow.
teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: t1.Name,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{
"enable_disk_encryption": true,
"custom_settings": []interface{}{"foo", "bar"},
},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// Attempt to set profile batch for team t1, should allow.
teamProfiles := [][]byte{
mobileconfigForTest("N3", "I3"),
mobileconfigForTest("N4", "I4"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{
Profiles: teamProfiles,
}, http.StatusNoContent, "team_id", strconv.Itoa(int(t1.ID)))
//
// Start running permission tests with user gitops2-mdm,
// which is GitOps for teams t1 and t3.
//
s.setTokenForTest(t, "gitops2-mdm@example.com", test.GoodPassword)
// Attempt to edit team t1 MDM settings, should allow.
teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
Name: t1.Name,
MDM: fleet.TeamSpecMDM{
MacOSSettings: map[string]interface{}{
"enable_disk_encryption": true,
"custom_settings": []interface{}{"foo", "bar"},
},
},
}}}
s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
// Attempt to set profile batch for team t1, should allow.
teamProfiles = [][]byte{
mobileconfigForTest("N5", "I5"),
mobileconfigForTest("N6", "I6"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{
Profiles: teamProfiles,
}, http.StatusNoContent, "team_id", strconv.Itoa(int(t1.ID)))
// Attempt to set profile batch for team t2, should not allow.
teamProfiles = [][]byte{
mobileconfigForTest("N7", "I7"),
mobileconfigForTest("N8", "I8"),
}
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{
Profiles: teamProfiles,
}, http.StatusForbidden, "team_id", strconv.Itoa(int(t2.ID)))
}
func (s *integrationMDMTestSuite) setTokenForTest(t *testing.T, email, password string) {
oldToken := s.token
t.Cleanup(func() {
s.token = oldToken
})
s.token = s.getCachedUserToken(email, password)
}
func (s *integrationMDMTestSuite) TestSSO() {
t := s.T()
var lastSubmittedProfile *godep.Profile
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/profile":
lastSubmittedProfile = &godep.Profile{}
rawProfile, err := io.ReadAll(r.Body)
require.NoError(t, err)
err = json.Unmarshal(rawProfile, lastSubmittedProfile)
require.NoError(t, err)
encoder := json.NewEncoder(w)
err = encoder.Encode(godep.ProfileResponse{ProfileUUID: "abc"})
require.NoError(t, err)
}
}))
// MDM SSO fields are empty by default
acResp := appConfigResponse{}
s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
assert.Empty(t, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
// set the SSO fields
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"end_user_authentication": {
"entity_id": "https://localhost:8080",
"issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
"idp_name": "SimpleSAML",
"metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
}
}
}`), http.StatusOK, &acResp)
wantSettings := fleet.SSOProviderSettings{
EntityID: "https://localhost:8080",
IssuerURI: "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
IDPName: "SimpleSAML",
MetadataURL: "http://localhost:9080/simplesaml/saml2/idp/metadata.php",
}
assert.Equal(t, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
// 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, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
// check that the last submitted DEP profile has been updated accordingly
require.Contains(t, lastSubmittedProfile.URL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
require.Equal(t, acResp.ServerSettings.ServerURL+"/mdm/sso", lastSubmittedProfile.ConfigurationWebURL)
// patch without specifying the mdm sso settings fields and an unrelated
// field, should not remove them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": {"enable_disk_encryption": true} }
}`), http.StatusOK, &acResp)
assert.Equal(t, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
// patch with explicitly empty mdm sso settings fields, would remove
// them but this is a dry-run
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
"idp_name": "",
"metadata_url": ""
}
}
}`), http.StatusOK, &acResp, "dry_run", "true")
assert.Equal(t, wantSettings, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
// patch with explicitly empty mdm sso settings fields, removes them
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": {
"end_user_authentication": {
"entity_id": "",
"issuer_uri": "",
"idp_name": "",
"metadata_url": ""
}
}
}`), http.StatusOK, &acResp)
assert.Empty(t, acResp.MDM.EndUserAuthentication.SSOProviderSettings)
require.Equal(t, lastSubmittedProfile.ConfigurationWebURL, lastSubmittedProfile.URL)
// set-up valid settings
acResp = appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"server_settings": {"server_url": "https://localhost:8080"},
"mdm": {
"end_user_authentication": {
"entity_id": "https://localhost:8080",
"issuer_uri": "http://localhost:8080/simplesaml/saml2/idp/SSOService.php",
"idp_name": "SimpleSAML",
"metadata_url": "http://localhost:9080/simplesaml/saml2/idp/metadata.php"
}
}
}`), http.StatusOK, &acResp)
require.Contains(t, lastSubmittedProfile.URL, acResp.ServerSettings.ServerURL+"/api/mdm/apple/enroll?token=")
require.Equal(t, acResp.ServerSettings.ServerURL+"/mdm/sso", lastSubmittedProfile.ConfigurationWebURL)
res := s.LoginMDMSSOUser("sso_user", "user123#")
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
defer res.Body.Close()
require.Contains(t, res.Header, "Content-Disposition")
require.Contains(t, res.Header, "Content-Type")
require.Contains(t, res.Header, "X-Content-Type-Options")
require.Contains(t, res.Header.Get("Content-Disposition"), "attachment;")
require.Contains(t, res.Header.Get("Content-Type"), "application/x-apple-aspen-config")
require.Contains(t, res.Header.Get("X-Content-Type-Options"), "nosniff")
headerLen, err := strconv.Atoi(res.Header.Get("Content-Length"))
require.NoError(t, err)
require.Equal(t, len(body), headerLen)
var profile struct {
PayloadIdentifier string `plist:"PayloadIdentifier"`
}
require.NoError(t, plist.Unmarshal(body, &profile))
require.Equal(t, apple_mdm.FleetPayloadIdentifier, profile.PayloadIdentifier)
}