diff --git a/cmd/fleet/cron.go b/cmd/fleet/cron.go
index 4109a47b30..2e4e31249b 100644
--- a/cmd/fleet/cron.go
+++ b/cmd/fleet/cron.go
@@ -1014,6 +1014,7 @@ func newMDMProfileManager(
commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
loggingDebug bool,
+ cfg config.MDMConfig,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronMDMAppleProfileManager)
@@ -1022,12 +1023,22 @@ func newMDMProfileManager(
// cron interval as we scale to more hosts.
defaultInterval = 30 * time.Second
)
+
+ if !cfg.IsAppleSCEPSet() {
+ return nil, ctxerr.New(ctx, "SCEP configuration is required")
+ }
+
+ cert, _, _, err := cfg.AppleSCEP()
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "getting Apple SCEP keypair")
+ }
+
logger = kitlog.With(logger, "cron", name)
s := schedule.New(
ctx, name, instanceID, defaultInterval, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob("manage_apple_profiles", func(ctx context.Context) error {
- return service.ReconcileAppleProfiles(ctx, ds, commander, logger)
+ return service.ReconcileAppleProfiles(ctx, ds, commander, logger, cert)
}),
schedule.WithJob("manage_apple_declarations", func(ctx context.Context) error {
return service.ReconcileAppleDeclarations(ctx, ds, commander, logger)
diff --git a/cmd/fleet/serve.go b/cmd/fleet/serve.go
index e010e6675e..f13e0a453d 100644
--- a/cmd/fleet/serve.go
+++ b/cmd/fleet/serve.go
@@ -546,7 +546,7 @@ the way that the Fleet server works.
} else {
mdmPushService = nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushProviderFactory, nanoMDMLogger)
}
- commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
+ commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
mdmCheckinAndCommandService = service.NewMDMAppleCheckinAndCommandService(ds, commander, logger)
ddmService = service.NewMDMAppleDDMService(ds, logger)
appCfg.MDM.EnabledAndConfigured = true
@@ -640,7 +640,7 @@ the way that the Fleet server works.
mailService,
clock.C,
depStorage,
- apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
+ apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
mdmPushCertTopic,
ssoSessionStore,
profileMatcher,
@@ -697,7 +697,7 @@ the way that the Fleet server works.
func() (fleet.CronSchedule, error) {
var commander *apple_mdm.MDMAppleCommander
if appCfg.MDM.EnabledAndConfigured {
- commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
+ commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
}
return newCleanupsAndAggregationSchedule(
ctx, instanceID, ds, logger, redisWrapperDS, &config, commander,
@@ -740,7 +740,7 @@ the way that the Fleet server works.
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
var commander *apple_mdm.MDMAppleCommander
if appCfg.MDM.EnabledAndConfigured {
- commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
+ commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM)
}
return newWorkerIntegrationsSchedule(ctx, instanceID, ds, logger, depStorage, commander)
}); err != nil {
@@ -761,9 +761,10 @@ the way that the Fleet server works.
ctx,
instanceID,
ds,
- apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
+ apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
logger,
config.Logging.Debug,
+ config.MDM,
)
}); err != nil {
initFatal(err, "failed to register mdm_apple_profile_manager schedule")
diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go
index 95de007b5f..acc2718510 100644
--- a/ee/server/service/mdm.go
+++ b/ee/server/service/mdm.go
@@ -1156,5 +1156,11 @@ func (svc *Service) GetMDMManualEnrollmentProfile(ctx context.Context) ([]byte,
return nil, ctxerr.Wrap(ctx, err)
}
+ // NOTE: the profile returned by this endpoint is intentionally not
+ // signed so it can be modified and signed by the IT admin with a
+ // custom certificate.
+ //
+ // Per @marko-lisica, we can add a parameter like `signed=true` if the
+ // need arises.
return mobileConfig, nil
}
diff --git a/pkg/mdm/mdmtest/apple.go b/pkg/mdm/mdmtest/apple.go
index deb52664b7..a2c2f46e33 100644
--- a/pkg/mdm/mdmtest/apple.go
+++ b/pkg/mdm/mdmtest/apple.go
@@ -214,7 +214,23 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string) error {
if err := response.Body.Close(); err != nil {
return fmt.Errorf("close body: %w", err)
}
- enrollInfo, err := ParseEnrollmentProfile(body)
+
+ rawProfile := body
+ if !bytes.HasPrefix(rawProfile, []byte("
@@ -59,7 +67,7 @@ func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []st
`, uuid, base64Profile)
- err := svc.EnqueueCommand(ctx, hostUUIDs, raw)
+ err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
return ctxerr.Wrap(ctx, err, "commander install profile")
}
diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go
index af7cc7dd33..3f8702fd50 100644
--- a/server/mdm/apple/commander_test.go
+++ b/server/mdm/apple/commander_test.go
@@ -3,10 +3,10 @@ package apple_mdm
import (
"context"
"crypto/tls"
- "encoding/base64"
"fmt"
"testing"
+ "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
@@ -15,7 +15,10 @@ import (
"github.com/fleetdm/fleet/v4/server/mock"
svcmock "github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/google/uuid"
+ "github.com/groob/plist"
+ micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/stretchr/testify/require"
+ "go.mozilla.org/pkcs7"
)
func TestMDMAppleCommander(t *testing.T) {
@@ -28,7 +31,10 @@ func TestMDMAppleCommander(t *testing.T) {
pushFactory,
stdlogfmt.New(),
)
- cmdr := NewMDMAppleCommander(mdmStorage, pusher)
+ cmdr := NewMDMAppleCommander(mdmStorage, pusher, config.MDMConfig{
+ AppleSCEPCert: "../../service/testdata/server.pem",
+ AppleSCEPKey: "../../service/testdata/server.key",
+ })
// TODO(roberto): there's a data race in the mock when more
// than one host ID is provided because the pusher uses one
@@ -41,7 +47,11 @@ func TestMDMAppleCommander(t *testing.T) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, cmd.Command.RequestType, "InstallProfile")
- require.Contains(t, string(cmd.Raw), base64.StdEncoding.EncodeToString(mc))
+ var fullCmd micromdm.CommandPayload
+ require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
+ p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
+ require.NoError(t, err)
+ require.Equal(t, string(p7.Content), string(mc))
return nil, nil
}
diff --git a/server/mdm/apple/mobileconfig/mobileconfig.go b/server/mdm/apple/mobileconfig/mobileconfig.go
index a51b7ebbb5..ac7b11f89a 100644
--- a/server/mdm/apple/mobileconfig/mobileconfig.go
+++ b/server/mdm/apple/mobileconfig/mobileconfig.go
@@ -6,7 +6,9 @@ import (
"fmt"
"strings"
+ "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/mdm"
+ "github.com/micromdm/micromdm/pkg/crypto/profileutil"
"go.mozilla.org/pkcs7"
"howett.net/plist"
)
@@ -20,6 +22,9 @@ const (
// by fleetd to read configuration values from the system.
FleetdConfigPayloadIdentifier = "com.fleetdm.fleetd.config"
+ // FleetCARootConfigPayloadIdentifier TODO
+ FleetCARootConfigPayloadIdentifier = "com.fleetdm.caroot"
+
// FleetEnrollmentPayloadIdentifier is the value for the PayloadIdentifier used
// by Fleet to enroll a device with the MDM server.
FleetEnrollmentPayloadIdentifier = "com.fleetdm.fleet.mdm.apple.mdm"
@@ -43,8 +48,9 @@ const (
// files around due to import cycles.
func FleetPayloadIdentifiers() map[string]struct{} {
return map[string]struct{}{
- FleetFileVaultPayloadIdentifier: {},
- FleetdConfigPayloadIdentifier: {},
+ FleetFileVaultPayloadIdentifier: {},
+ FleetdConfigPayloadIdentifier: {},
+ FleetCARootConfigPayloadIdentifier: {},
}
}
@@ -83,7 +89,7 @@ type Parsed struct {
// Adapted from https://github.com/micromdm/micromdm/blob/main/platform/profile/profile.go
func (mc Mobileconfig) ParseConfigProfile() (*Parsed, error) {
mcBytes := mc
- if !bytes.HasPrefix(mcBytes, []byte("
`))
+
+// FleetCARootTemplateOptions are the keys required to execute a
+// FleetCARootTemplate.
+type FleetCARootTemplateOptions struct {
+ PayloadName string
+ PayloadIdentifier string
+ Certificate string
+}
+
+var FleetCARootTemplate = template.Must(template.New("").Option("missingkey=error").Parse(`
+
+
+
+ PayloadContent
+
+
+ PayloadCertificateFileName
+ CertificateRoot
+ PayloadContent
+ {{ .Certificate }}
+ PayloadDescription
+ {{ .PayloadName }}
+ PayloadDisplayName
+ {{ .PayloadName }}
+ PayloadIdentifier
+ {{ .PayloadIdentifier }}.certpayload
+ PayloadType
+ com.apple.security.root
+ PayloadUUID
+ B295992E-861A-4F92-902-17BCF4E33C61
+ PayloadVersion
+ 1
+ AllowAllAppsAccess
+
+
+
+ PayloadDisplayName
+ {{ .PayloadName }}
+ PayloadIdentifier
+ {{ .PayloadIdentifier }}
+ PayloadType
+ Configuration
+ PayloadUUID
+ 4F5428DE-05B6-4965-87AD-532CAFC35FCF
+ PayloadVersion
+ 1
+
+
+`))
diff --git a/server/mdm/mdm.go b/server/mdm/mdm.go
index 761cb01192..8439070714 100644
--- a/server/mdm/mdm.go
+++ b/server/mdm/mdm.go
@@ -86,6 +86,10 @@ const (
// fleetd to read configuration values from the system.
FleetdConfigProfileName = "Fleetd configuration"
+ // FleetCAConfigProfileName is the value for the PayloadDisplayName used by
+ // fleetd to read configuration values from the system.
+ FleetCAConfigProfileName = "Fleet root certificate authority (CA)"
+
// FleetdFileVaultProfileName is the value for the PayloadDisplayName used
// by Fleet to configure FileVault and FileVault Escrow.
FleetFileVaultProfileName = "Disk encryption"
@@ -107,6 +111,7 @@ func FleetReservedProfileNames() map[string]struct{} {
FleetFileVaultProfileName: {},
FleetWindowsOSUpdatesProfileName: {},
FleetMacOSUpdatesProfileName: {},
+ FleetCAConfigProfileName: {},
}
}
@@ -119,7 +124,7 @@ func ListFleetReservedWindowsProfileNames() []string {
// ListFleetReservedMacOSProfileNames returns a list of PayloadDisplayName strings
// that are reserved by Fleet for macOS.
func ListFleetReservedMacOSProfileNames() []string {
- return []string{FleetFileVaultProfileName, FleetdConfigProfileName}
+ return []string{FleetFileVaultProfileName, FleetdConfigProfileName, FleetCAConfigProfileName}
}
// ListFleetReservedMacOSDeclarationNames returns a list of declaration names
diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go
index d1fa9f5f7e..dc4eadde00 100644
--- a/server/service/apple_mdm.go
+++ b/server/service/apple_mdm.go
@@ -3,6 +3,8 @@ package service
import (
"bytes"
"context"
+ "crypto/tls"
+ "encoding/base64"
"encoding/json"
"errors"
"fmt"
@@ -1330,16 +1332,22 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok
return nil, ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
}
- mobileconfig, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
+ enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
appConfig.OrgInfo.OrgName,
enrollURL,
svc.config.MDM.AppleSCEPChallenge,
svc.mdmPushCertTopic,
)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err)
+ return nil, ctxerr.Wrap(ctx, err, "generating enrollment profile")
}
- return mobileconfig, nil
+
+ signed, err := mobileconfig.Sign(enrollmentProf, svc.config.MDM)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "signing profile")
+ }
+
+ return signed, nil
}
type mdmAppleCommandRemoveEnrollmentProfileRequest struct {
@@ -2710,7 +2718,7 @@ func mdmAppleDeliveryStatusFromCommandStatus(cmdStatus string) *fleet.MDMDeliver
}
}
-// ensureFleetdConfig ensures there's a fleetd configuration profile in
+// ensureFleetProfiles ensures there's a fleetd configuration profile in
// mdm_apple_configuration_profiles for each team and for "no team"
//
// We try our best to use each team's secret but we default to creating a
@@ -2720,12 +2728,26 @@ func mdmAppleDeliveryStatusFromCommandStatus(cmdStatus string) *fleet.MDMDeliver
// This profile will be installed to all hosts in the team (or "no team",) but it
// will only be used by hosts that have a fleetd installation without an enroll
// secret and fleet URL (mainly DEP enrolled hosts).
-func ensureFleetdConfig(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger) error {
+func ensureFleetProfiles(ctx context.Context, ds fleet.Datastore, logger kitlog.Logger, signingCert *tls.Certificate) error {
appCfg, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetching app config")
}
+ var rootCAProfContents bytes.Buffer
+ params := mobileconfig.FleetCARootTemplateOptions{
+ PayloadIdentifier: mobileconfig.FleetCARootConfigPayloadIdentifier,
+ PayloadName: mdm_types.FleetCAConfigProfileName,
+ Certificate: base64.StdEncoding.EncodeToString(signingCert.Certificate[0]),
+ }
+
+ if err := mobileconfig.FleetCARootTemplate.Execute(&rootCAProfContents, params); err != nil {
+ return ctxerr.Wrap(ctx, err, "executing fleet root CA config template")
+ }
+
+ b := rootCAProfContents.Bytes()
+ fmt.Println(string(b))
+
enrollSecrets, err := ds.AggregateEnrollSecretPerTeam(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting enroll secrets aggregates")
@@ -2767,11 +2789,15 @@ func ensureFleetdConfig(ctx context.Context, ds fleet.Datastore, logger kitlog.L
cp, err := fleet.NewMDMAppleConfigProfile(contents.Bytes(), es.TeamID)
if err != nil {
- return ctxerr.Wrap(ctx, err, "building configuration profile")
+ return ctxerr.Wrap(ctx, err, "building fleetd configuration profile")
}
-
profiles = append(profiles, cp)
+ rootCAProf, err := fleet.NewMDMAppleConfigProfile(b, es.TeamID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "building root CA configuration profile")
+ }
+ profiles = append(profiles, rootCAProf)
}
if err := ds.BulkUpsertMDMAppleConfigProfiles(ctx, profiles); err != nil {
@@ -2813,6 +2839,7 @@ func ReconcileAppleProfiles(
ds fleet.Datastore,
commander *apple_mdm.MDMAppleCommander,
logger kitlog.Logger,
+ signingCert *tls.Certificate,
) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
@@ -2821,7 +2848,7 @@ func ReconcileAppleProfiles(
if !appConfig.MDM.EnabledAndConfigured {
return nil
}
- if err := ensureFleetdConfig(ctx, ds, logger); err != nil {
+ if err := ensureFleetProfiles(ctx, ds, logger, signingCert); err != nil {
logger.Log("err", "unable to ensure a fleetd configuration profiles are in place", "details", err)
}
diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go
index 2f22fa7216..07d12c9749 100644
--- a/server/service/apple_mdm_test.go
+++ b/server/service/apple_mdm_test.go
@@ -19,6 +19,7 @@ import (
"net/http/httptest"
"os"
"strings"
+ "sync"
"sync/atomic"
"testing"
"time"
@@ -41,7 +42,10 @@ import (
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/kit/log"
"github.com/google/uuid"
+ "github.com/groob/plist"
+ micromdm "github.com/micromdm/micromdm/mdm/mdm"
"github.com/stretchr/testify/require"
+ "go.mozilla.org/pkcs7"
)
type nopProfileMatcher struct{}
@@ -57,6 +61,9 @@ func (nopProfileMatcher) RetrieveProfiles(ctx context.Context, extHostID string)
func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Service, context.Context, *mock.Store) {
ds := new(mock.Store)
cfg := config.TestConfig()
+ testCertPEM, testKeyPEM, err := generateCertWithAPNsTopic()
+ require.NoError(t, err)
+ config.SetTestMDMConfig(t, &cfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/server/devices"):
@@ -1165,7 +1172,7 @@ func TestMDMTokenUpdate(t *testing.T) {
pushFactory,
NewNanoMDMLogger(kitlog.NewJSONLogger(os.Stdout)),
)
- cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
+ cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher, config.MDMConfig{})
svc := MDMAppleCheckinAndCommandService{ds: ds, commander: cmdr, logger: kitlog.NewNopLogger()}
uuid, serial, model, wantTeamID := "ABC-DEF-GHI", "XYZABC", "MacBookPro 16,1", uint(12)
@@ -2088,14 +2095,17 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
pushFactory,
NewNanoMDMLogger(kitlog.NewNopLogger()),
)
- cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
+ mdmConfig := config.MDMConfig{
+ AppleSCEPCert: "./testdata/server.pem",
+ AppleSCEPKey: "./testdata/server.key",
+ }
+ cmdr := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher, mdmConfig)
hostUUID, hostUUID2 := "ABC-DEF", "GHI-JKL"
contents1 := []byte("test-content-1")
- contents1Base64 := base64.StdEncoding.EncodeToString(contents1)
contents2 := []byte("test-content-2")
- contents2Base64 := base64.StdEncoding.EncodeToString(contents2)
contents4 := []byte("test-content-4")
- contents4Base64 := base64.StdEncoding.EncodeToString(contents4)
+ signingCert, _, _, err := mdmConfig.AppleSCEP()
+ require.NoError(t, err)
p1, p2, p3, p4 := "a"+uuid.NewString(), "a"+uuid.NewString(), "a"+uuid.NewString(), "a"+uuid.NewString()
ds.ListMDMAppleProfilesToInstallFunc = func(ctx context.Context) ([]*fleet.MDMAppleProfilePayload, error) {
@@ -2130,6 +2140,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
}
var enqueueFailForOp fleet.MDMOperationType
+ var mu sync.Mutex
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
require.NotNil(t, cmd)
require.NotEmpty(t, cmd.CommandUUID)
@@ -2143,10 +2154,18 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
require.Len(t, id, 1)
}
- if !strings.Contains(string(cmd.Raw), contents1Base64) && !strings.Contains(string(cmd.Raw), contents2Base64) &&
- !strings.Contains(string(cmd.Raw), contents4Base64) {
+ var fullCmd micromdm.CommandPayload
+ require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
+ // the p7 library doesn't support concurrent calls to Parse
+ mu.Lock()
+ p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
+ mu.Unlock()
+ require.NoError(t, err)
+
+ if !bytes.Equal(p7.Content, contents1) && !bytes.Equal(p7.Content, contents2) &&
+ !bytes.Equal(p7.Content, contents4) {
require.Failf(t, "profile contents don't match", "expected to contain %s, %s or %s but got %s",
- contents1Base64, contents2Base64, contents4Base64, string(cmd.Raw))
+ contents1, contents2, contents4, p7.Content)
}
case "RemoveProfile":
require.ElementsMatch(t, []string{hostUUID, hostUUID2}, id)
@@ -2299,7 +2318,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
failedCount++
require.Len(t, payload, 0)
}
- err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger())
+ err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), signingCert)
require.NoError(t, err)
require.Equal(t, 1, failedCount)
checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked)
@@ -2335,7 +2354,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
}
enqueueFailForOp = fleet.MDMOperationTypeRemove
- err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger())
+ err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), signingCert)
require.NoError(t, err)
require.Equal(t, 1, failedCount)
checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked)
@@ -2388,7 +2407,7 @@ func TestMDMAppleReconcileAppleProfiles(t *testing.T) {
}
enqueueFailForOp = fleet.MDMOperationTypeInstall
- err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger())
+ err := ReconcileAppleProfiles(ctx, ds, cmdr, kitlog.NewNopLogger(), signingCert)
require.NoError(t, err)
require.Equal(t, 1, failedCount)
checkAndReset(t, true, &ds.ListMDMAppleProfilesToInstallFuncInvoked)
@@ -2420,6 +2439,12 @@ func TestEnsureFleetdConfig(t *testing.T) {
testURL := "https://example.com"
testTeamName := "test-team"
logger := kitlog.NewNopLogger()
+ mdmConfig := config.MDMConfig{
+ AppleSCEPCert: "./testdata/server.pem",
+ AppleSCEPKey: "./testdata/server.key",
+ }
+ signingCert, _, _, err := mdmConfig.AppleSCEP()
+ require.NoError(t, err)
t.Run("no enroll secret found", func(t *testing.T) {
ctx := context.Background()
@@ -2435,7 +2460,7 @@ func TestEnsureFleetdConfig(t *testing.T) {
require.Empty(t, ps)
return nil
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.NoError(t, err)
require.True(t, ds.BulkUpsertMDMAppleConfigProfilesFuncInvoked)
require.True(t, ds.AggregateEnrollSecretPerTeamFuncInvoked)
@@ -2460,7 +2485,7 @@ func TestEnsureFleetdConfig(t *testing.T) {
require.Empty(t, ps)
return nil
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.NoError(t, err)
require.True(t, ds.BulkUpsertMDMAppleConfigProfilesFuncInvoked)
require.True(t, ds.AggregateEnrollSecretPerTeamFuncInvoked)
@@ -2485,16 +2510,28 @@ func TestEnsureFleetdConfig(t *testing.T) {
return secrets, nil
}
ds.BulkUpsertMDMAppleConfigProfilesFunc = func(ctx context.Context, ps []*fleet.MDMAppleConfigProfile) error {
- require.Len(t, ps, len(secrets))
- for i, p := range ps {
+ // fleetd + CA profiles
+ require.Len(t, ps, len(secrets)*2)
+ var fleetd, fleetCA []*fleet.MDMAppleConfigProfile
+ for _, p := range ps {
+ switch p.Identifier {
+ case mobileconfig.FleetdConfigPayloadIdentifier:
+ fleetd = append(fleetd, p)
+ case mobileconfig.FleetCARootConfigPayloadIdentifier:
+ fleetCA = append(fleetCA, p)
+ }
+ }
+ require.Len(t, fleetd, 3)
+ require.Len(t, fleetCA, 3)
+
+ for i, p := range fleetd {
require.Contains(t, string(p.Mobileconfig), testURL)
require.Contains(t, string(p.Mobileconfig), secrets[i].Secret)
- require.Equal(t, mobileconfig.FleetdConfigPayloadIdentifier, p.Identifier)
}
return nil
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.NoError(t, err)
require.True(t, ds.AggregateEnrollSecretPerTeamFuncInvoked)
require.True(t, ds.BulkUpsertMDMAppleConfigProfilesFuncInvoked)
@@ -2517,15 +2554,27 @@ func TestEnsureFleetdConfig(t *testing.T) {
return secrets, nil
}
ds.BulkUpsertMDMAppleConfigProfilesFunc = func(ctx context.Context, ps []*fleet.MDMAppleConfigProfile) error {
- require.Len(t, ps, len(secrets))
- for i, p := range ps {
+ // fleetd + CA profiles
+ require.Len(t, ps, len(secrets)*2)
+ var fleetd, fleetCA []*fleet.MDMAppleConfigProfile
+ for _, p := range ps {
+ switch p.Identifier {
+ case mobileconfig.FleetdConfigPayloadIdentifier:
+ fleetd = append(fleetd, p)
+ case mobileconfig.FleetCARootConfigPayloadIdentifier:
+ fleetCA = append(fleetCA, p)
+ }
+ }
+ require.Len(t, fleetd, 2)
+ require.Len(t, fleetCA, 2)
+
+ for i, p := range fleetd {
require.Contains(t, string(p.Mobileconfig), testURL)
require.Contains(t, string(p.Mobileconfig), secrets[i].Secret)
- require.Equal(t, mobileconfig.FleetdConfigPayloadIdentifier, p.Identifier)
}
return nil
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.NoError(t, err)
require.True(t, ds.AppConfigFuncInvoked)
require.True(t, ds.AggregateEnrollSecretPerTeamFuncInvoked)
@@ -2538,7 +2587,7 @@ func TestEnsureFleetdConfig(t *testing.T) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return nil, testError
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.ErrorIs(t, err, testError)
})
@@ -2551,7 +2600,7 @@ func TestEnsureFleetdConfig(t *testing.T) {
ds.AggregateEnrollSecretPerTeamFunc = func(ctx context.Context) ([]*fleet.EnrollSecret, error) {
return nil, testError
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.ErrorIs(t, err, testError)
})
@@ -2571,7 +2620,7 @@ func TestEnsureFleetdConfig(t *testing.T) {
ds.BulkUpsertMDMAppleConfigProfilesFunc = func(ctx context.Context, p []*fleet.MDMAppleConfigProfile) error {
return testError
}
- err := ensureFleetdConfig(ctx, ds, logger)
+ err := ensureFleetProfiles(ctx, ds, logger, signingCert)
require.ErrorIs(t, err, testError)
require.True(t, ds.AppConfigFuncInvoked)
require.True(t, ds.AggregateEnrollSecretPerTeamFuncInvoked)
@@ -2852,7 +2901,10 @@ func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *conf
pushFactory,
stdlogfmt.New(),
)
- commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
+ commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher, config.MDMConfig{
+ AppleSCEPCert: "./testdata/server.pem",
+ AppleSCEPKey: "./testdata/server.key",
+ })
return ctx, logger, ds, &cfg, mdmStorage, commander
}
diff --git a/server/service/devices.go b/server/service/devices.go
index ea8069efc3..7f8efe4eb5 100644
--- a/server/service/devices.go
+++ b/server/service/devices.go
@@ -16,6 +16,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"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"
)
@@ -500,16 +501,22 @@ func (svc *Service) GetDeviceMDMAppleEnrollmentProfile(ctx context.Context) ([]b
return nil, ctxerr.Wrap(ctx, err)
}
- mobileConfig, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
+ enrollmentProf, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
appConfig.OrgInfo.OrgName,
appConfig.ServerSettings.ServerURL,
svc.config.MDM.AppleSCEPChallenge,
svc.mdmPushCertTopic,
)
if err != nil {
- return nil, ctxerr.Wrap(ctx, err)
+ return nil, ctxerr.Wrap(ctx, err, "generating manual enrollment profile")
}
- return mobileConfig, nil
+
+ signed, err := mobileconfig.Sign(enrollmentProf, svc.config.MDM)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "signing profile")
+ }
+
+ return signed, nil
}
////////////////////////////////////////////////////////////////////////////////
diff --git a/server/service/integration_ddm_test.go b/server/service/integration_mdm_ddm_test.go
similarity index 100%
rename from server/service/integration_ddm_test.go
rename to server/service/integration_mdm_ddm_test.go
diff --git a/server/service/integration_mdm_dep_test.go b/server/service/integration_mdm_dep_test.go
index a4f38c6493..9d019941bc 100644
--- a/server/service/integration_mdm_dep_test.go
+++ b/server/service/integration_mdm_dep_test.go
@@ -272,12 +272,12 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
require.NoError(t, err)
}
- // expected commands: install fleetd, install bootstrap, install profiles
+ // expected commands: install fleetd, install bootstrap, install CA, install profiles
// (custom one and fleetd configuration) (not expected: account
// configuration, since enrollment_reference not set)
- require.Len(t, cmds, 4)
+ require.Len(t, cmds, 5)
var installProfileCount, installEnterpriseCount, otherCount int
- var profileCustomSeen, profileFleetdSeen bool
+ var profileCustomSeen, profileFleetdSeen, profileFleetCASeen bool
for _, cmd := range cmds {
switch cmd.Command.RequestType {
case "InstallProfile":
@@ -286,6 +286,8 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
profileCustomSeen = true
} else if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetdConfigPayloadIdentifier)) {
profileFleetdSeen = true
+ } else if strings.Contains(string(cmd.Command.InstallProfile.Payload), fmt.Sprintf("%s", mobileconfig.FleetCARootConfigPayloadIdentifier)) {
+ profileFleetCASeen = true
}
case "InstallEnterpriseApplication":
@@ -294,11 +296,12 @@ func (s *integrationMDMTestSuite) runDEPEnrollReleaseDeviceTest(t *testing.T, de
otherCount++
}
}
- require.Equal(t, 2, installProfileCount)
+ require.Equal(t, 3, installProfileCount)
require.Equal(t, 2, installEnterpriseCount)
require.Equal(t, 0, otherCount)
require.True(t, profileCustomSeen)
require.True(t, profileFleetdSeen)
+ require.True(t, profileFleetCASeen)
if enableReleaseManually {
// get the worker's pending job from the future, there should not be any
diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go
new file mode 100644
index 0000000000..0b10dbeb4c
--- /dev/null
+++ b/server/service/integration_mdm_profiles_test.go
@@ -0,0 +1,4323 @@
+package service
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5" // nolint:gosec // used only for tests
+ "crypto/x509"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
+ "github.com/fleetdm/fleet/v4/pkg/optjson"
+ "github.com/fleetdm/fleet/v4/server/datastore/mysql"
+ "github.com/fleetdm/fleet/v4/server/fleet"
+ servermdm "github.com/fleetdm/fleet/v4/server/mdm"
+ apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
+ "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
+ microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
+ "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml"
+ "github.com/fleetdm/fleet/v4/server/ptr"
+ "github.com/fleetdm/fleet/v4/server/test"
+ "github.com/google/uuid"
+ "github.com/jmoiron/sqlx"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.mozilla.org/pkcs7"
+)
+
+func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) {
+ t := s.T()
+ rootCA := x509.NewCertPool()
+ require.True(t, rootCA.AppendCertsFromPEM([]byte(s.fleetCfg.MDM.AppleSCEPCertBytes)))
+
+ // verify that all the profiles were signed usign the SCEP certificate,
+ // and grab their contents
+ signedContents := [][]byte{}
+ for _, prof := range got {
+ p7, err := pkcs7.Parse(prof)
+ require.NoError(t, err)
+ require.NoError(t, p7.VerifyWithChain(rootCA))
+ signedContents = append(signedContents, p7.Content)
+ }
+
+ // verify that contents match
+ require.ElementsMatch(t, want, signedContents)
+}
+
+func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
+ t := s.T()
+ ctx := context.Background()
+
+ err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
+ require.NoError(t, err)
+
+ globalProfiles := [][]byte{
+ mobileconfigForTest("N1", "I1"),
+ mobileconfigForTest("N2", "I2"),
+ }
+ wantGlobalProfiles := append(
+ globalProfiles,
+ setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil),
+ )
+
+ // add global profiles
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
+
+ // create a new team
+ tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
+ require.NoError(t, err)
+
+ // add an enroll secret so the fleetd profiles differ
+ var teamResp teamEnrollSecretsResponse
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm.ID),
+ modifyTeamEnrollSecretsRequest{
+ Secrets: []fleet.EnrollSecret{{Secret: "team1_enroll_sec"}},
+ }, http.StatusOK, &teamResp)
+
+ teamProfiles := [][]byte{
+ mobileconfigForTest("N3", "I3"),
+ }
+ wantTeamProfiles := append(
+ teamProfiles,
+ setupExpectedFleetdProfile(t, s.server.URL, "team1_enroll_sec", &tm.ID),
+ )
+ // add profiles to the team
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
+
+ // create a non-macOS host
+ _, err = s.ds.NewHost(context.Background(), &fleet.Host{
+ ID: 1,
+ OsqueryHostID: ptr.String("non-macos-host"),
+ NodeKey: ptr.String("non-macos-host"),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
+ Platform: "windows",
+ })
+ require.NoError(t, err)
+
+ // create a host that's not enrolled into MDM
+ _, err = s.ds.NewHost(context.Background(), &fleet.Host{
+ ID: 2,
+ OsqueryHostID: ptr.String("not-mdm-enrolled"),
+ NodeKey: ptr.String("not-mdm-enrolled"),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+
+ // Create a host and then enroll to MDM.
+ host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+ setupPusher(s, t, mdmDevice)
+
+ // trigger a profile sync
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes := checkNextPayloads(t, mdmDevice, false)
+ // verify that we received all profiles
+ s.signedProfilesMatch(
+ append(wantGlobalProfiles, setupExpectedCAProfile(t, s.fleetCfg.MDM)),
+ installs,
+ )
+ require.Empty(t, removes)
+
+ expectedNoTeamSummary := fleet.MDMProfilesSummary{
+ Pending: 0,
+ Failed: 0,
+ Verifying: 1,
+ Verified: 0,
+ }
+ expectedTeamSummary := fleet.MDMProfilesSummary{}
+ s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary)
+ s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // empty because no hosts in team
+
+ // add the host to a team
+ err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
+ require.NoError(t, err)
+
+ // trigger a profile sync
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ // verify that we should install the team profile
+ s.signedProfilesMatch(wantTeamProfiles, installs)
+ // verify that we should delete both profiles
+ require.ElementsMatch(t, []string{"I1", "I2"}, removes)
+
+ expectedNoTeamSummary = fleet.MDMProfilesSummary{}
+ expectedTeamSummary = fleet.MDMProfilesSummary{
+ Pending: 0,
+ Failed: 0,
+ Verifying: 1,
+ Verified: 0,
+ }
+ s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
+ s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host now verifying team profiles
+
+ // set new team profiles (delete + addition)
+ teamProfiles = [][]byte{
+ mobileconfigForTest("N4", "I4"),
+ mobileconfigForTest("N5", "I5"),
+ }
+ wantTeamProfiles = teamProfiles
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
+
+ // trigger a profile sync
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ // verify that we should install the team profiles
+ s.signedProfilesMatch(wantTeamProfiles, installs)
+ // verify that we should delete the old team profiles
+ require.ElementsMatch(t, []string{"I3"}, removes)
+
+ s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
+ s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
+
+ // with no changes
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+
+ var hostResp getHostResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
+ require.NotEmpty(t, hostResp.Host.MDM.Profiles)
+ resProfiles := *hostResp.Host.MDM.Profiles
+ // two extra profiles: fleetd config and root CA
+ require.Len(t, resProfiles, len(wantTeamProfiles)+2)
+
+ s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
+ s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
+
+ // add a new profile to the team
+ mcUUID := "a" + uuid.NewString()
+ prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID)
+ wantTeamProfiles = append(wantTeamProfiles, prof)
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
+ _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID))
+ return err
+ })
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Len(t, installs, 1)
+ s.signedProfilesMatch([][]byte{prof}, installs)
+ require.Empty(t, removes)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
+
+ // can't resend profile while verifying
+ res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
+
+ // set the profile to pending, can't resend
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID)
+ return err
+ })
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil)
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
+
+ // set the profile to failed, can resend
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
+ return err
+ })
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
+ _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted)
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Len(t, installs, 1)
+ s.signedProfilesMatch([][]byte{prof}, installs)
+ require.Empty(t, removes)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
+
+ // can't resend profile while verifying
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
+
+ // set the profile to verified, can resend
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID)
+ return err
+ })
+ _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted)
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Len(t, installs, 1)
+ s.signedProfilesMatch([][]byte{prof}, installs)
+ require.Empty(t, removes)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
+ s.lastActivityMatches(
+ fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
+ fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID),
+ 0)
+
+ // add a declaration to the team
+ declIdent := "decl-ident-" + uuid.NewString()
+ fields := map[string][]string{
+ "team_id": {fmt.Sprintf("%d", tm.ID)},
+ }
+ body, headers := generateNewProfileMultipartRequest(
+ t, "some-declaration.json", declarationForTest(declIdent), s.token, fields,
+ )
+ res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
+ var resp newMDMConfigProfileResponse
+ err = json.NewDecoder(res.Body).Decode(&resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.ProfileUUID)
+ require.Equal(t, "d", string(resp.ProfileUUID[0]))
+ declUUID := resp.ProfileUUID
+
+ checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
+ require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger))
+ cmd, err := d.Idle()
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+ require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
+ cmd, err = d.Acknowledge(cmd.CommandUUID)
+ require.NoError(t, err)
+ require.Nil(t, cmd, fmt.Sprintf("expected no more commands, but got: %+v", cmd))
+ _, err = d.DeclarativeManagement("tokens")
+ require.NoError(t, err)
+ }
+ checkDDMSync(mdmDevice)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
+
+ // can't resend declaration while verifying
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusConflict)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
+
+ // set the declaration to verified, can resend
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID)
+ return err
+ })
+ _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusAccepted)
+ checkDDMSync(mdmDevice)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
+ s.lastActivityMatches(
+ fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
+ fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": "some-declaration"}`, host.ID, host.DisplayName()),
+ 0)
+
+ // transfer the host to the global team
+ err = s.ds.AddHostsToTeam(ctx, nil, []uint{host.ID})
+ require.NoError(t, err)
+
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Len(t, installs, len(wantGlobalProfiles))
+ s.signedProfilesMatch(wantGlobalProfiles, installs)
+ require.Len(t, removes, len(wantTeamProfiles))
+ expectedNoTeamSummary = fleet.MDMProfilesSummary{Verifying: 1}
+ expectedTeamSummary = fleet.MDMProfilesSummary{}
+ s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // host now verifying global profiles
+ s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
+
+ // can't resend profile from another team
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusNotFound)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Unable to match profile to host")
+
+ // add a Windows profile, resend not supported when host is macOS
+ wpUUID := mysql.InsertWindowsProfileForTest(t, s.ds, 0)
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, wpUUID), nil, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Profile is not compatible with host platform")
+
+ // invalid profile UUID prefix should return 404
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Invalid profile UUID prefix")
+
+ // set OS updates settings for no-team and team, should not change the
+ // summaries as this profile is ignored.
+ s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "macos_updates": {
+ "deadline": "2023-12-31",
+ "minimum_version": "13.3.7"
+ }
+ }
+ }`), http.StatusOK)
+ s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), fleet.TeamPayload{
+ MDM: &fleet.TeamPayloadMDM{
+ MacOSUpdates: &fleet.MacOSUpdates{
+ Deadline: optjson.SetString("1992-01-01"),
+ MinimumVersion: optjson.SetString("13.1.1"),
+ },
+ },
+ }, http.StatusOK)
+ s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary)
+ s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
+
+ // it should also not show up in the host's profiles list
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
+ require.NotEmpty(t, hostResp.Host.MDM.Profiles)
+ resProfiles = *hostResp.Host.MDM.Profiles
+ // two extra profiles: fleetd config and root CA
+ require.Len(t, resProfiles, len(wantGlobalProfiles)+2)
+}
+
+func (s *integrationMDMTestSuite) TestAppleProfileRetries() {
+ t := s.T()
+ ctx := context.Background()
+
+ enrollSecret := "test-profile-retries-secret"
+ err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}})
+ require.NoError(t, err)
+
+ testProfiles := [][]byte{
+ mobileconfigForTest("N1", "I1"),
+ mobileconfigForTest("N2", "I2"),
+ }
+ initialExpectedProfiles := append(
+ testProfiles,
+ setupExpectedFleetdProfile(t, s.server.URL, enrollSecret, nil),
+ setupExpectedCAProfile(t, s.fleetCfg.MDM),
+ )
+
+ h, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+ setupPusher(s, t, mdmDevice)
+
+ expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
+ "I1": fleet.MDMDeliveryVerifying,
+ "I2": fleet.MDMDeliveryVerifying,
+ mobileconfig.FleetdConfigPayloadIdentifier: fleet.MDMDeliveryVerifying,
+ mobileconfig.FleetCARootConfigPayloadIdentifier: fleet.MDMDeliveryVerifying,
+ }
+ checkProfilesStatus := func(t *testing.T) {
+ storedProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, h.UUID)
+ require.NoError(t, err)
+ require.Len(t, storedProfs, len(expectedProfileStatuses))
+ for _, p := range storedProfs {
+ want, ok := expectedProfileStatuses[p.Identifier]
+ require.True(t, ok, "unexpected profile: %s", p.Identifier)
+ require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Identifier)
+ }
+ }
+
+ expectedRetryCounts := map[string]uint{
+ "I1": 0,
+ "I2": 0,
+ mobileconfig.FleetdConfigPayloadIdentifier: 0,
+ mobileconfig.FleetCARootConfigPayloadIdentifier: 0,
+ }
+ checkRetryCounts := func(t *testing.T) {
+ counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h)
+ require.NoError(t, err)
+ require.Len(t, counts, len(expectedRetryCounts))
+ for _, c := range counts {
+ want, ok := expectedRetryCounts[c.ProfileIdentifier]
+ require.True(t, ok, "unexpected profile: %s", c.ProfileIdentifier)
+ require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileIdentifier)
+ }
+ }
+
+ hostProfsByIdent := map[string]*fleet.HostMacOSProfile{
+ "I1": {
+ Identifier: "I1",
+ DisplayName: "N1",
+ InstallDate: time.Now().Add(15 * time.Minute),
+ },
+ "I2": {
+ Identifier: "I2",
+ DisplayName: "N2",
+ InstallDate: time.Now().Add(15 * time.Minute),
+ },
+ mobileconfig.FleetdConfigPayloadIdentifier: {
+ Identifier: mobileconfig.FleetdConfigPayloadIdentifier,
+ DisplayName: "Fleetd configuration",
+ InstallDate: time.Now().Add(15 * time.Minute),
+ },
+ }
+ reportHostProfs := func(t *testing.T, identifiers ...string) {
+ report := make(map[string]*fleet.HostMacOSProfile, len(hostProfsByIdent))
+ for _, ident := range identifiers {
+ report[ident] = hostProfsByIdent[ident]
+ }
+ require.NoError(t, apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, report))
+ }
+
+ setProfileUploadedAt := func(t *testing.T, uploadedAt time.Time, identifiers ...interface{}) {
+ bindVars := strings.TrimSuffix(strings.Repeat("?, ", len(identifiers)), ", ")
+ stmt := fmt.Sprintf("UPDATE mdm_apple_configuration_profiles SET uploaded_at = ? WHERE identifier IN(%s)", bindVars)
+ args := append([]interface{}{uploadedAt}, identifiers...)
+ mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
+ _, err := tx.ExecContext(ctx, stmt, args...)
+ return err
+ })
+ }
+
+ t.Run("retry after verifying", func(t *testing.T) {
+ // upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier)
+
+ // trigger initial profile sync and confirm that we received all profiles
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes := checkNextPayloads(t, mdmDevice, false)
+ s.signedProfilesMatch(initialExpectedProfiles, installs)
+ require.Empty(t, removes)
+
+ checkProfilesStatus(t) // all profiles verifying
+ checkRetryCounts(t) // no retries yet
+
+ // report osquery results with I2 missing and confirm I2 marked as pending and other profiles are marked as verified
+ reportHostProfs(t, "I1", mobileconfig.FleetdConfigPayloadIdentifier)
+ expectedProfileStatuses["I2"] = fleet.MDMDeliveryPending
+ expectedProfileStatuses["I1"] = fleet.MDMDeliveryVerified
+ expectedProfileStatuses[mobileconfig.FleetdConfigPayloadIdentifier] = fleet.MDMDeliveryVerified
+ checkProfilesStatus(t)
+ expectedRetryCounts["I2"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for I2 was resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ s.signedProfilesMatch([][]byte{initialExpectedProfiles[1]}, installs)
+ require.Empty(t, removes)
+
+ // report osquery results with I2 present and confirm that all profiles are verified
+ reportHostProfs(t, "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier)
+ expectedProfileStatuses["I2"] = fleet.MDMDeliveryVerified
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that no profiles were sent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+ })
+
+ t.Run("retry after verification", func(t *testing.T) {
+ // report osquery results with I1 missing and confirm that the I1 marked as pending (initial retry)
+ reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
+ expectedProfileStatuses["I1"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["I1"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for I1 was resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes := checkNextPayloads(t, mdmDevice, false)
+ s.signedProfilesMatch([][]byte{initialExpectedProfiles[0]}, installs)
+ require.Empty(t, removes)
+
+ // report osquery results with I1 missing again and confirm that the I1 marked as failed (max retries exceeded)
+ reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
+ expectedProfileStatuses["I1"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for I1 was not resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+ })
+
+ t.Run("retry after device error", func(t *testing.T) {
+ // add another profile and set the updated_at timestamp back by 48 hours
+ newProfile := mobileconfigForTest("N3", "I3")
+ testProfiles = append(testProfiles, newProfile)
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3")
+
+ // trigger a profile sync and confirm that the install profile command for I3 was sent and
+ // simulate a device error
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes := checkNextPayloads(t, mdmDevice, true)
+ s.signedProfilesMatch([][]byte{newProfile}, installs)
+ require.Empty(t, removes)
+ expectedProfileStatuses["I3"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["I3"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for I3 was sent and
+ // simulate a device ack
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ s.signedProfilesMatch([][]byte{newProfile}, installs)
+ require.Empty(t, removes)
+ expectedProfileStatuses["I3"] = fleet.MDMDeliveryVerifying
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // report osquery results with I3 missing and confirm that the I3 marked as failed (max
+ // retries exceeded)
+ reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
+ expectedProfileStatuses["I3"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for I3 was not resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+ })
+
+ t.Run("repeated device error", func(t *testing.T) {
+ // add another profile and set the updated_at timestamp back by 48 hours
+ newProfile := mobileconfigForTest("N4", "I4")
+ testProfiles = append(testProfiles, newProfile)
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4")
+
+ // trigger a profile sync and confirm that the install profile command for I3 was sent and
+ // simulate a device error
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes := checkNextPayloads(t, mdmDevice, true)
+ s.signedProfilesMatch([][]byte{newProfile}, installs)
+ require.Empty(t, removes)
+ expectedProfileStatuses["I4"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["I4"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for I4 was sent and
+ // simulate a second device error
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, true)
+ s.signedProfilesMatch([][]byte{newProfile}, installs)
+ require.Empty(t, removes)
+ expectedProfileStatuses["I4"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for I3 was not resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+ })
+
+ t.Run("retry count does not reset", func(t *testing.T) {
+ // add another profile and set the updated_at timestamp back by 48 hours
+ newProfile := mobileconfigForTest("N5", "I5")
+ testProfiles = append(testProfiles, newProfile)
+ hostProfsByIdent["I5"] = &fleet.HostMacOSProfile{Identifier: "I5", DisplayName: "N5", InstallDate: time.Now()}
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4", "I5")
+
+ // trigger a profile sync and confirm that the install profile command for I3 was sent and
+ // simulate a device error
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes := checkNextPayloads(t, mdmDevice, true)
+ s.signedProfilesMatch([][]byte{newProfile}, installs)
+ require.Empty(t, removes)
+ expectedProfileStatuses["I5"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["I5"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for I5 was sent and
+ // simulate a device ack
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ s.signedProfilesMatch([][]byte{newProfile}, installs)
+ require.Empty(t, removes)
+ expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerifying
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // report osquery results with I5 found and confirm that the I5 marked as verified
+ reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I5")
+ expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerified
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for I5 was not resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+
+ // report osquery results again, this time I5 is missing and confirm that the I5 marked as
+ // failed (max retries exceeded)
+ reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
+ expectedProfileStatuses["I5"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for I5 was not resent
+ s.awaitTriggerProfileSchedule(t)
+ installs, removes = checkNextPayloads(t, mdmDevice, false)
+ require.Empty(t, installs)
+ require.Empty(t, removes)
+ })
+}
+
+func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
+ t := s.T()
+ ctx := context.Background()
+
+ testProfiles := []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: syncml.ForTestWithData(map[string]string{"L1": "D1"})},
+ {Name: "N2", Contents: syncml.ForTestWithData(map[string]string{"L2": "D2", "L3": "D3"})},
+ }
+
+ h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
+
+ expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
+ "N1": fleet.MDMDeliveryVerifying,
+ "N2": fleet.MDMDeliveryVerifying,
+ }
+ checkProfilesStatus := func(t *testing.T) {
+ storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
+ require.NoError(t, err)
+ require.Len(t, storedProfs, len(expectedProfileStatuses))
+ for _, p := range storedProfs {
+ want, ok := expectedProfileStatuses[p.Name]
+ require.True(t, ok, "unexpected profile: %s", p.Name)
+ require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name)
+ }
+ }
+
+ expectedRetryCounts := map[string]uint{
+ "N1": 0,
+ "N2": 0,
+ }
+ checkRetryCounts := func(t *testing.T) {
+ counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h)
+ require.NoError(t, err)
+ require.Len(t, counts, len(expectedRetryCounts))
+ for _, c := range counts {
+ want, ok := expectedRetryCounts[c.ProfileName]
+ require.True(t, ok, "unexpected profile: %s", c.ProfileName)
+ require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileName)
+ }
+ }
+
+ type profileData struct {
+ Status string
+ LocURI string
+ Data string
+ }
+ hostProfileReports := map[string][]profileData{
+ "N1": {{"200", "L1", "D1"}},
+ "N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
+ }
+ reportHostProfs := func(t *testing.T, profileNames ...string) {
+ var responseOps []*fleet.SyncMLCmd
+ for _, profileName := range profileNames {
+ report, ok := hostProfileReports[profileName]
+ require.True(t, ok)
+
+ for _, p := range report {
+ ref := microsoft_mdm.HashLocURI(profileName, p.LocURI)
+ responseOps = append(responseOps, &fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdStatus},
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ CmdRef: &ref,
+ Data: ptr.String(p.Status),
+ })
+
+ // the protocol can respond with only a `Status`
+ // command if the status failed
+ if p.Status != "200" || p.Data != "" {
+ responseOps = append(responseOps, &fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdResults},
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ CmdRef: &ref,
+ Items: []fleet.CmdItem{
+ {Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}},
+ },
+ })
+ }
+ }
+ }
+
+ msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps)
+ require.NoError(t, err)
+ out, err := xml.Marshal(msg)
+ require.NoError(t, err)
+ require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, out))
+ }
+
+ verifyCommands := func(wantProfileInstalls int, status string) {
+ s.awaitTriggerProfileSchedule(t)
+ cmds, err := mdmDevice.StartManagementSession()
+ require.NoError(t, err)
+ // profile installs + 2 protocol commands acks
+ require.Len(t, cmds, wantProfileInstalls+2)
+ msgID, err := mdmDevice.GetCurrentMsgID()
+ require.NoError(t, err)
+ atomicCmds := 0
+ for _, c := range cmds {
+ if c.Verb == "Atomic" {
+ atomicCmds++
+ }
+ mdmDevice.AppendResponse(fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdStatus},
+ MsgRef: &msgID,
+ CmdRef: ptr.String(c.Cmd.CmdID.Value),
+ Cmd: ptr.String(c.Verb),
+ Data: ptr.String(status),
+ Items: nil,
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ })
+ }
+ require.Equal(t, wantProfileInstalls, atomicCmds)
+ cmds, err = mdmDevice.SendResponse()
+ require.NoError(t, err)
+ // the ack of the message should be the only returned command
+ require.Len(t, cmds, 1)
+ }
+
+ t.Run("retry after verifying", func(t *testing.T) {
+ // upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ // profiles to install + 2 boilerplate
+ verifyCommands(len(testProfiles), syncml.CmdStatusOK)
+ checkProfilesStatus(t) // all profiles verifying
+ checkRetryCounts(t) // no retries yet
+
+ // report osquery results with N2 missing and confirm N2 marked
+ // as verifying and other profiles are marked as verified
+ reportHostProfs(t, "N1")
+ expectedProfileStatuses["N2"] = fleet.MDMDeliveryPending
+ expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
+ checkProfilesStatus(t)
+ expectedRetryCounts["N2"] = 1
+ checkRetryCounts(t)
+
+ // report osquery results with N2 present and confirm that all profiles are verified
+ verifyCommands(1, syncml.CmdStatusOK)
+ reportHostProfs(t, "N1", "N2")
+ expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that no profiles were sent
+ verifyCommands(0, syncml.CmdStatusOK)
+ })
+
+ t.Run("retry after verification", func(t *testing.T) {
+ // report osquery results with N1 missing and confirm that the N1 marked as pending (initial retry)
+ reportHostProfs(t, "N2")
+ expectedProfileStatuses["N1"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["N1"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for N1 was resent
+ verifyCommands(1, syncml.CmdStatusOK)
+
+ // report osquery results with N1 missing again and confirm that the N1 marked as failed (max retries exceeded)
+ reportHostProfs(t, "N2")
+ expectedProfileStatuses["N1"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for N1 was not resent
+ verifyCommands(0, syncml.CmdStatusOK)
+ })
+
+ t.Run("retry after device error", func(t *testing.T) {
+ // add another profile
+ newProfile := syncml.ForTestWithData(map[string]string{"L3": "D3"})
+ testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
+ Name: "N3",
+ Contents: newProfile,
+ })
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ // trigger a profile sync and confirm that the install profile command for N3 was sent and
+ // simulate a device error
+ verifyCommands(1, syncml.CmdStatusAtomicFailed)
+ expectedProfileStatuses["N3"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["N3"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile command for N3 was sent and
+ // simulate a device ack
+ verifyCommands(1, syncml.CmdStatusOK)
+ expectedProfileStatuses["N3"] = fleet.MDMDeliveryVerifying
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // report osquery results with N3 missing and confirm that the N3 marked as failed (max
+ // retries exceeded)
+ reportHostProfs(t, "N2")
+ expectedProfileStatuses["N3"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for N3 was not resent
+ verifyCommands(0, syncml.CmdStatusOK)
+ })
+
+ t.Run("repeated device error", func(t *testing.T) {
+ // add another profile
+ testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
+ Name: "N4",
+ Contents: syncml.ForTestWithData(map[string]string{"L4": "D4"}),
+ })
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ // trigger a profile sync and confirm that the install profile command for N4 was sent and
+ // simulate a device error
+ verifyCommands(1, syncml.CmdStatusAtomicFailed)
+ expectedProfileStatuses["N4"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["N4"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile
+ // command for N4 was sent and simulate a second device error
+ verifyCommands(1, syncml.CmdStatusAtomicFailed)
+ expectedProfileStatuses["N4"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile
+ // command for N4 was not resent
+ verifyCommands(0, syncml.CmdStatusOK)
+ })
+
+ t.Run("retry count does not reset", func(t *testing.T) {
+ // add another profile
+ testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
+ Name: "N5",
+ Contents: syncml.ForTestWithData(map[string]string{"L5": "D5"}),
+ })
+ // hostProfsByIdent["N5"] = &fleet.HostMacOSProfile{Identifier: "N5", DisplayName: "N5", InstallDate: time.Now()}
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ // trigger a profile sync and confirm that the install profile
+ // command for N5 was sent and simulate a device error
+ verifyCommands(1, syncml.CmdStatusAtomicFailed)
+ expectedProfileStatuses["N5"] = fleet.MDMDeliveryPending
+ checkProfilesStatus(t)
+ expectedRetryCounts["N5"] = 1
+ checkRetryCounts(t)
+
+ // trigger a profile sync and confirm that the install profile
+ // command for N5 was sent and simulate a device ack
+ verifyCommands(1, syncml.CmdStatusOK)
+ expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerifying
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // report osquery results with N5 found and confirm that the N5 marked as verified
+ hostProfileReports["N5"] = []profileData{{"200", "L5", "D5"}}
+ reportHostProfs(t, "N2", "N5")
+ expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerified
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for N5 was not resent
+ verifyCommands(0, syncml.CmdStatusOK)
+
+ // report osquery results again, this time N5 is missing and confirm that the N5 marked as
+ // failed (max retries exceeded)
+ reportHostProfs(t, "N2")
+ expectedProfileStatuses["N5"] = fleet.MDMDeliveryFailed
+ checkProfilesStatus(t)
+ checkRetryCounts(t) // unchanged
+
+ // trigger a profile sync and confirm that the install profile command for N5 was not resent
+ verifyCommands(0, syncml.CmdStatusOK)
+ })
+}
+
+func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() {
+ ctx := context.Background()
+ t := s.T()
+
+ // Use a gitops user for all Puppet actions
+ u := &fleet.User{
+ Name: "GitOps",
+ Email: "gitops-TestPuppetMatchPreassignProfiles@example.com",
+ GlobalRole: ptr.String(fleet.RoleGitOps),
+ }
+ require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
+ _, err := s.ds.NewUser(context.Background(), u)
+ require.NoError(t, err)
+ s.setTokenForTest(t, "gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword)
+
+ runWithAdminToken := func(cb func()) {
+ s.token = s.getTestAdminToken()
+ cb()
+ s.token = s.getCachedUserToken("gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword)
+ }
+
+ // create a host enrolled in fleet
+ mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+
+ // create a host that's not enrolled into MDM
+ nonMDMHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
+ OsqueryHostID: ptr.String("not-mdm-enrolled"),
+ NodeKey: ptr.String("not-mdm-enrolled"),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+
+ // create a setup assistant for no team, for this we need to:
+ // 1. mock the ABM API, as it gets called to set the profile
+ // 2. run the DEP schedule, as this registers the default profile
+ s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
+ }))
+ s.runDEPSchedule()
+ noTeamProf := `{"x": 1}`
+ var globalAsstResp createMDMAppleSetupAssistantResponse
+ s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
+ TeamID: nil,
+ Name: "no-team",
+ EnrollmentProfile: json.RawMessage(noTeamProf),
+ }, http.StatusOK, &globalAsstResp)
+
+ // set the global Enable Release Device manually setting to true,
+ // will be inherited by teams created via preassign/match.
+ s.Do("PATCH", "/api/latest/fleet/setup_experience",
+ json.RawMessage(jsonMustMarshal(t, map[string]any{"enable_release_device_manually": true})),
+ http.StatusNoContent)
+
+ s.runWorker()
+
+ // preassign an empty profile, fails
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "empty", HostUUID: nonMDMHost.UUID, Profile: nil}}, http.StatusUnprocessableEntity)
+
+ // preassign a valid profile to the MDM host
+ prof1 := mobileconfigForTest("n1", "i1")
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof1}}, http.StatusNoContent)
+
+ // preassign another valid profile to the MDM host
+ prof2 := mobileconfigForTest("n2", "i2")
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof2, Group: "g1"}}, http.StatusNoContent)
+
+ // preassign a valid profile to the non-MDM host, still works as the host is not validated in this call
+ prof3 := mobileconfigForTest("n3", "i3")
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "non-mdm", HostUUID: nonMDMHost.UUID, Profile: prof3, Group: "g2"}}, http.StatusNoContent)
+
+ // match with an invalid external host id, succeeds as it is the same as if
+ // there was no matching to do (no preassignment was done)
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "no-such-id"}, http.StatusNoContent)
+
+ // match with the non-mdm host fails
+ res := s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "non-mdm"}, http.StatusBadRequest)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "host is not enrolled in Fleet MDM")
+
+ // match with the mdm host succeeds and creates a team based on the group labels
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm1"}, http.StatusNoContent)
+
+ // the host is now part of that team
+ h, err := s.ds.Host(ctx, mdmHost.ID)
+ require.NoError(t, err)
+ require.NotNil(t, h.TeamID)
+ tm1, err := s.ds.Team(ctx, *h.TeamID)
+ require.NoError(t, err)
+ require.Equal(t, "g1", tm1.Name)
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+ require.True(t, tm1.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
+
+ runWithAdminToken(func() {
+ // it create activities for the new team, the profiles assigned to it,
+ // the host moved to it, and setup assistant
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeCreatedTeam{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name),
+ 0)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name),
+ 0)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeTransferredHostsToTeam{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q, "host_ids": [%d], "host_display_names": [%q]}`,
+ tm1.ID, tm1.Name, h.ID, h.DisplayName()),
+ 0)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "name": %q, "team_name": %q}`,
+ tm1.ID, globalAsstResp.Name, tm1.Name),
+ 0)
+ })
+
+ // and the team has the expected profiles (prof1 and prof2)
+ profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 2)
+ // order is guaranteed by profile name
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ // setup assistant settings are copyied from "no team"
+ teamAsst, err := s.ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID)
+ require.NoError(t, err)
+ require.Equal(t, globalAsstResp.Name, teamAsst.Name)
+ require.JSONEq(t, string(globalAsstResp.Profile), string(teamAsst.Profile))
+
+ // trigger the schedule so profiles are set in their state
+ s.awaitTriggerProfileSchedule(t)
+ s.runWorker()
+
+ // the mdm host has the same profiles (i1, i2, plus fleetd config and disk encryption)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ mdmHost: {
+ {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "i2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ },
+ })
+
+ // create a team and set profiles to it (note that it doesn't have disk encryption enabled)
+ tm2, err := s.ds.NewTeam(context.Background(), &fleet.Team{
+ Name: "g1 - g4",
+ Secrets: []*fleet.EnrollSecret{{Secret: "tm2secret"}},
+ })
+ require.NoError(t, err)
+ prof4 := mobileconfigForTest("n4", "i4")
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
+ prof1, prof4,
+ }}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
+ // tm2 has disk encryption and release device manually disabled
+ require.False(t, tm2.Config.MDM.EnableDiskEncryption)
+ require.False(t, tm2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
+
+ // create another team with a superset of profiles
+ tm3, err := s.ds.NewTeam(context.Background(), &fleet.Team{
+ Name: "team3_" + t.Name(),
+ Secrets: []*fleet.EnrollSecret{{Secret: "tm3secret"}},
+ })
+ require.NoError(t, err)
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
+ prof1, prof2, prof4,
+ }}, http.StatusNoContent, "team_id", fmt.Sprint(tm3.ID))
+
+ // and yet another team with the same profiles as tm3
+ tm4, err := s.ds.NewTeam(context.Background(), &fleet.Team{
+ Name: "team4_" + t.Name(),
+ Secrets: []*fleet.EnrollSecret{{Secret: "tm4secret"}},
+ })
+ require.NoError(t, err)
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
+ prof1, prof2, prof4,
+ }}, http.StatusNoContent, "team_id", fmt.Sprint(tm4.ID))
+
+ // preassign the MDM host to prof1 and prof4, should match existing team tm2
+ //
+ // additionally, use external host identifiers with different
+ // suffixes to simulate real world distributed scenarios where more
+ // than one puppet server might be running at the time.
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler06.test.example.com", HostUUID: mdmHost.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler01.test.example.com", HostUUID: mdmHost.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
+
+ // match with the mdm host succeeds and assigns it to tm2
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler03.test.example.com"}, http.StatusNoContent)
+
+ // the host is now part of that team
+ h, err = s.ds.Host(ctx, mdmHost.ID)
+ require.NoError(t, err)
+ require.NotNil(t, h.TeamID)
+ require.Equal(t, tm2.ID, *h.TeamID)
+ // tm2 still has disk encryption and release device manually disabled
+ tm2, err = s.ds.Team(ctx, *h.TeamID)
+ require.NoError(t, err)
+ require.False(t, tm2.Config.MDM.EnableDiskEncryption)
+ require.False(t, tm2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
+
+ // the host's profiles are:
+ // - the same as the team's and are pending (prof1 + prof4)
+ // - prof2 + old filevault are pending removal
+ // - fleetd config being reinstalled (for new enroll secret)
+ s.awaitTriggerProfileSchedule(t)
+
+ // useful for debugging
+ //mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ // mysql.DumpTable(t, q, "host_mdm_apple_profiles")
+ // return nil
+ //})
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ mdmHost: {
+ {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ },
+ })
+
+ // create a new mdm host enrolled in fleet
+ mdmHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+
+ // make it part of team 2
+ s.Do("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{mdmHost2.ID}}, http.StatusOK)
+
+ // simulate having its profiles installed
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ res, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID)
+ n, _ := res.RowsAffected()
+ require.Equal(t, 4, int(n))
+ return err
+ })
+
+ // preassign the MDM host using "g1" and "g4", should match existing
+ // team tm2, and nothing be done since the host is already in tm2
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm2"}, http.StatusNoContent)
+
+ // the host is still part of tm2
+ h, err = s.ds.Host(ctx, mdmHost2.ID)
+ require.NoError(t, err)
+ require.NotNil(t, h.TeamID)
+ require.Equal(t, tm2.ID, *h.TeamID)
+
+ // and its profiles have been left untouched
+ s.awaitTriggerProfileSchedule(t)
+
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ mdmHost2: {
+ {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+}
+
+// while s.TestPuppetMatchPreassignProfiles focuses on many edge cases/extra
+// checks around profile assignment, this test is mainly focused on
+// simulating a few puppet runs in scenarios we want to support, and ensuring that:
+//
+// - different hosts end up in the right teams
+// - teams get edited as expected
+// - commands to add/remove profiles are issued adequately
+func (s *integrationMDMTestSuite) TestPuppetRun() {
+ t := s.T()
+ ctx := context.Background()
+
+ // define a few profiles
+ prof1, prof2, prof3, prof4 := mobileconfigForTest("n1", "i1"),
+ mobileconfigForTest("n2", "i2"),
+ mobileconfigForTest("n3", "i3"),
+ mobileconfigForTest("n4", "i4")
+
+ // create three hosts
+ host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+ host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+ host3, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+ s.runWorker()
+
+ // Use a gitops user for all Puppet actions
+ u := &fleet.User{
+ Name: "GitOps",
+ Email: "gitops-TestPuppetRun@example.com",
+ GlobalRole: ptr.String(fleet.RoleGitOps),
+ }
+ require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
+ _, err := s.ds.NewUser(context.Background(), u)
+ require.NoError(t, err)
+ s.setTokenForTest(t, "gitops-TestPuppetRun@example.com", test.GoodPassword)
+
+ // preassignAndMatch simulates the puppet module doing all the
+ // preassign/match calls for a given set of profiles.
+ preassignAndMatch := func(profs []fleet.MDMApplePreassignProfilePayload) {
+ require.NotEmpty(t, profs)
+ for _, prof := range profs {
+ s.Do(
+ "POST",
+ "/api/latest/fleet/mdm/apple/profiles/preassign",
+ preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: prof},
+ http.StatusNoContent,
+ )
+ }
+ s.Do(
+ "POST",
+ "/api/latest/fleet/mdm/apple/profiles/match",
+ matchMDMApplePreassignmentRequest{ExternalHostIdentifier: profs[0].ExternalHostIdentifier},
+ http.StatusNoContent,
+ )
+ }
+
+ // node default {
+ // fleetdm::profile { 'n1':
+ // template => template('n1.mobileconfig.erb'),
+ // group => 'base',
+ // }
+ //
+ // fleetdm::profile { 'n2':
+ // template => template('n2.mobileconfig.erb'),
+ // group => 'workstations',
+ // }
+ //
+ // fleetdm::profile { 'n3':
+ // template => template('n3.mobileconfig.erb'),
+ // group => 'workstations',
+ // }
+ //
+ // if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
+ // fleetdm::profile { 'n4':
+ // template => template('fleetdm/n4.mobileconfig.erb'),
+ // group => 'kiosks',
+ // }
+ // }
+ puppetRun := func(host *fleet.Host) {
+ payload := []fleet.MDMApplePreassignProfilePayload{
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof1,
+ Group: "base",
+ },
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof2,
+ Group: "workstations",
+ },
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof3,
+ Group: "workstations",
+ },
+ }
+
+ if host.UUID == host2.UUID {
+ payload = append(payload, fleet.MDMApplePreassignProfilePayload{
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof4,
+ Group: "kiosks",
+ })
+ }
+
+ preassignAndMatch(payload)
+ }
+
+ // host1 checks in
+ puppetRun(host1)
+
+ // the host now belongs to a team
+ h1, err := s.ds.Host(ctx, host1.ID)
+ require.NoError(t, err)
+ require.NotNil(t, h1.TeamID)
+
+ // the team has the right name
+ tm1, err := s.ds.Team(ctx, *h1.TeamID)
+ require.NoError(t, err)
+ require.Equal(t, "base - workstations", tm1.Name)
+ // and the right profiles
+ profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 3)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+
+ // host2 checks in
+ puppetRun(host2)
+ // a new team is created
+ h2, err := s.ds.Host(ctx, host2.ID)
+ require.NoError(t, err)
+ require.NotNil(t, h2.TeamID)
+
+ // the team has the right name
+ tm2, err := s.ds.Team(ctx, *h2.TeamID)
+ require.NoError(t, err)
+ require.Equal(t, "base - kiosks - workstations", tm2.Name)
+ // and the right profiles
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 4)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
+ require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
+ require.True(t, tm2.Config.MDM.EnableDiskEncryption)
+
+ // host3 checks in
+ puppetRun(host3)
+ // it belongs to the same team as host1
+ h3, err := s.ds.Host(ctx, host3.ID)
+ require.NoError(t, err)
+ require.Equal(t, h1.TeamID, h3.TeamID)
+
+ // prof2 is edited
+ oldProf2 := prof2
+ prof2 = mobileconfigForTest("n2", "i2-v2")
+ // host3 checks in again
+ puppetRun(host3)
+ // still belongs to the same team
+ h3, err = s.ds.Host(ctx, host3.ID)
+ require.NoError(t, err)
+ require.Equal(t, tm1.ID, *h3.TeamID)
+
+ // but the team has prof2 updated
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 3)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
+ require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+
+ // host2 checks in, still belongs to the same team
+ puppetRun(host2)
+ h2, err = s.ds.Host(ctx, host2.ID)
+ require.NoError(t, err)
+ require.Equal(t, tm2.ID, *h2.TeamID)
+
+ // but the team has prof2 updated as well
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 4)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
+ require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
+ require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+
+ // the puppet manifest is changed, and prof3 is removed
+ // node default {
+ // fleetdm::profile { 'n1':
+ // template => template('n1.mobileconfig.erb'),
+ // group => 'base',
+ // }
+ //
+ // fleetdm::profile { 'n2':
+ // template => template('n2.mobileconfig.erb'),
+ // group => 'workstations',
+ // }
+ //
+ // if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
+ // fleetdm::profile { 'n4':
+ // template => template('fleetdm/n4.mobileconfig.erb'),
+ // group => 'kiosks',
+ // }
+ // }
+ puppetRun = func(host *fleet.Host) {
+ payload := []fleet.MDMApplePreassignProfilePayload{
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof1,
+ Group: "base",
+ },
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof2,
+ Group: "workstations",
+ },
+ }
+
+ if host.UUID == host2.UUID {
+ payload = append(payload, fleet.MDMApplePreassignProfilePayload{
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof4,
+ Group: "kiosks",
+ })
+ }
+
+ preassignAndMatch(payload)
+ }
+
+ // host1 checks in again
+ puppetRun(host1)
+ // still belongs to the same team
+ h1, err = s.ds.Host(ctx, host1.ID)
+ require.NoError(t, err)
+ require.Equal(t, tm1.ID, *h1.TeamID)
+
+ // but the team doesn't have prof3 anymore
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 2)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+
+ // same for host2
+ puppetRun(host2)
+ h2, err = s.ds.Host(ctx, host2.ID)
+ require.NoError(t, err)
+ require.Equal(t, tm2.ID, *h2.TeamID)
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 3)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.Equal(t, prof4, []byte(profs[2].Mobileconfig))
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+
+ // The puppet manifest is drastically updated, this time to use exclusions on host3:
+ //
+ // node default {
+ // fleetdm::profile { 'n1':
+ // template => template('n1.mobileconfig.erb'),
+ // group => 'base',
+ // }
+ //
+ // fleetdm::profile { 'n2':
+ // template => template('n2.mobileconfig.erb'),
+ // group => 'workstations',
+ // }
+ //
+ // if $facts['system_profiler']['hardware_uuid'] == 'host_3_uuid' {
+ // fleetdm::profile { 'n3':
+ // template => template('fleetdm/n3.mobileconfig.erb'),
+ // group => 'no-nudge',
+ // }
+ // } else {
+ // fleetdm::profile { 'n3':
+ // ensure => absent,
+ // template => template('fleetdm/n3.mobileconfig.erb'),
+ // group => 'workstations',
+ // }
+ // }
+ // }
+ puppetRun = func(host *fleet.Host) {
+ manifest := []fleet.MDMApplePreassignProfilePayload{
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof1,
+ Group: "base",
+ },
+ {
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof2,
+ Group: "workstations",
+ },
+ }
+
+ if host.UUID == host3.UUID {
+ manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof3,
+ Group: "no-nudge",
+ Exclude: true,
+ })
+ } else {
+ manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
+ ExternalHostIdentifier: host.Hostname,
+ HostUUID: host.UUID,
+ Profile: prof3,
+ Group: "workstations",
+ })
+ }
+
+ preassignAndMatch(manifest)
+ }
+
+ // host1 checks in
+ puppetRun(host1)
+
+ // the host belongs to the same team
+ h1, err = s.ds.Host(ctx, host1.ID)
+ require.NoError(t, err)
+ require.Equal(t, tm1.ID, *h1.TeamID)
+
+ // the team has the right profiles
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 3)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
+ require.True(t, tm1.Config.MDM.EnableDiskEncryption)
+
+ // host2 checks in
+ puppetRun(host2)
+ // it is assigned to tm1
+ h2, err = s.ds.Host(ctx, host2.ID)
+ require.NoError(t, err)
+ require.Equal(t, tm1.ID, *h2.TeamID)
+
+ // host3 checks in
+ puppetRun(host3)
+
+ // it is assigned to a new team
+ h3, err = s.ds.Host(ctx, host3.ID)
+ require.NoError(t, err)
+ require.NotNil(t, h3.TeamID)
+ require.NotEqual(t, tm1.ID, *h3.TeamID)
+ require.NotEqual(t, tm2.ID, *h3.TeamID)
+
+ // a new team is created
+ tm3, err := s.ds.Team(ctx, *h3.TeamID)
+ require.NoError(t, err)
+ require.Equal(t, "base - no-nudge - workstations", tm3.Name)
+ // and the right profiles
+ profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm3.ID)
+ require.NoError(t, err)
+ require.Len(t, profs, 2)
+ require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
+ require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
+ require.True(t, tm3.Config.MDM.EnableDiskEncryption)
+}
+
+func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() {
+ t := s.T()
+ ctx := context.Background()
+
+ testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
+ require.NoError(t, err)
+
+ mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
+ s.runWorker()
+
+ t.Run("no profiles", func(t *testing.T) {
+ var listResp listMDMAppleConfigProfilesResponse
+ s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
+ require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil
+ require.Len(t, listResp.ConfigProfiles, 0)
+
+ listResp = listMDMAppleConfigProfilesResponse{}
+ s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &listResp)
+ require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil
+ require.Len(t, listResp.ConfigProfiles, 0)
+
+ var hostProfilesResp getHostProfilesResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
+ require.NotNil(t, hostProfilesResp.Profiles) // expect empty slice instead of nil
+ require.Len(t, hostProfilesResp.Profiles, 0)
+ require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
+ })
+
+ t.Run("with profiles", func(t *testing.T) {
+ p1, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p1", "p1.identifier", "p1.uuid"), nil)
+ require.NoError(t, err)
+ _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p1)
+ require.NoError(t, err)
+
+ p2, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p2", "p2.identifier", "p2.uuid"), &testTeam.ID)
+ require.NoError(t, err)
+ _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p2)
+ require.NoError(t, err)
+
+ var resp listMDMAppleConfigProfilesResponse
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: 0}, http.StatusOK, &resp)
+ require.NotNil(t, resp.ConfigProfiles)
+ require.Len(t, resp.ConfigProfiles, 1)
+ require.Equal(t, p1.Name, resp.ConfigProfiles[0].Name)
+ require.Equal(t, p1.Identifier, resp.ConfigProfiles[0].Identifier)
+
+ resp = listMDMAppleConfigProfilesResponse{}
+ s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
+ require.NotNil(t, resp.ConfigProfiles)
+ require.Len(t, resp.ConfigProfiles, 1)
+ require.Equal(t, p2.Name, resp.ConfigProfiles[0].Name)
+ require.Equal(t, p2.Identifier, resp.ConfigProfiles[0].Identifier)
+
+ p3, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p3", "p3.identifier", "p3.uuid"), &testTeam.ID)
+ require.NoError(t, err)
+ _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p3)
+ require.NoError(t, err)
+
+ resp = listMDMAppleConfigProfilesResponse{}
+ s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
+ require.NotNil(t, resp.ConfigProfiles)
+ require.Len(t, resp.ConfigProfiles, 2)
+ for _, p := range resp.ConfigProfiles {
+ if p.Name == p2.Name {
+ require.Equal(t, p2.Identifier, p.Identifier)
+ } else if p.Name == p3.Name {
+ require.Equal(t, p3.Identifier, p.Identifier)
+ } else {
+ require.Fail(t, "unexpected profile name")
+ }
+ }
+
+ var hostProfilesResp getHostProfilesResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
+ require.NotNil(t, hostProfilesResp.Profiles)
+ require.Len(t, hostProfilesResp.Profiles, 1)
+ require.Equal(t, p1.Name, hostProfilesResp.Profiles[0].Name)
+ require.Equal(t, p1.Identifier, hostProfilesResp.Profiles[0].Identifier)
+ require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
+
+ // add the host to a team
+ err = s.ds.AddHostsToTeam(ctx, &testTeam.ID, []uint{mdmHost.ID})
+ require.NoError(t, err)
+
+ hostProfilesResp = getHostProfilesResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
+ require.NotNil(t, hostProfilesResp.Profiles)
+ require.Len(t, hostProfilesResp.Profiles, 2)
+ require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
+ })
+}
+
+func (s *integrationMDMTestSuite) 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": [
+ {"path": "foo", "labels": ["baz"]},
+ {"path": "bar"}
+ ]
+ }
+ }
+ }`), http.StatusOK, &acResp)
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
+
+ // check that they are returned by a GET /config
+ acResp = appConfigResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "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": { "enable_disk_encryption": true }
+ }`), http.StatusOK, &acResp)
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "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, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "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) TestApplyTeamsMDMAppleProfiles() {
+ t := s.T()
+
+ // create a team through the service so it initializes the agent ops
+ teamName := t.Name() + "team1"
+ team := &fleet.Team{
+ Name: teamName,
+ Description: "desc team1",
+ }
+ var createTeamResp teamResponse
+ s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
+ require.NotZero(t, createTeamResp.Team.ID)
+ team = createTeamResp.Team
+
+ // apply with custom macos settings
+ teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
+ Name: teamName,
+ MDM: fleet.TeamSpecMDM{
+ MacOSSettings: map[string]interface{}{
+ "custom_settings": []map[string]interface{}{{"path": "foo"}, {"path": "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, []fleet.MDMProfileSpec{{Path: "foo"}, {Path: "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 MDMProfileSpecs but got bool`)
+
+ // apply without custom macos settings specified and unrelated field, should
+ // not replace existing settings
+ teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
+ Name: teamName,
+ MDM: fleet.TeamSpecMDM{
+ EnableDiskEncryption: optjson.SetBool(false),
+ },
+ }}}
+ s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
+ teamResp = getTeamResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.Equal(t, []fleet.MDMProfileSpec{{Path: "foo"}, {Path: "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": []map[string]interface{}{}},
+ },
+ }}}
+ s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
+ teamResp = getTeamResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.Equal(t, []fleet.MDMProfileSpec{{Path: "foo"}, {Path: "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": []map[string]interface{}{}},
+ },
+ }}}
+ s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
+ teamResp = getTeamResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.Equal(t, []fleet.MDMProfileSpec{}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
+}
+
+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) TestHostMDMAppleProfilesStatus() {
+ t := s.T()
+ ctx := context.Background()
+
+ createManualMDMEnrollWithOrbit := func(secret string) *fleet.Host {
+ // orbit enrollment happens before mdm enrollment, otherwise the host would
+ // always receive the "no team" profiles on mdm enrollment since it would
+ // not be part of any team yet (team assignment is done when it enrolls
+ // with orbit).
+ mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{
+ SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
+ SCEPURL: s.server.URL + apple_mdm.SCEPPath,
+ MDMURL: s.server.URL + apple_mdm.MDMPath,
+ })
+
+ // enroll the device with orbit
+ var resp EnrollOrbitResponse
+ s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
+ EnrollSecret: secret,
+ HardwareUUID: mdmDevice.UUID, // will not match any existing host
+ HardwareSerial: mdmDevice.SerialNumber,
+ }, http.StatusOK, &resp)
+ require.NotEmpty(t, resp.OrbitNodeKey)
+ orbitNodeKey := resp.OrbitNodeKey
+ h, err := s.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey)
+ require.NoError(t, err)
+ h.OrbitNodeKey = &orbitNodeKey
+ h.Platform = "darwin"
+
+ err = mdmDevice.Enroll()
+ require.NoError(t, err)
+
+ return h
+ }
+
+ triggerReconcileProfiles := func() {
+ s.awaitTriggerProfileSchedule(t)
+ // this will only mark them as "pending", as the response to confirm
+ // profile deployment is asynchronous, so we simulate it here by
+ // updating any "pending" (not NULL) profiles to "verifying"
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending)
+ return err
+ })
+ }
+
+ assignHostToTeam := func(h *fleet.Host, teamID *uint) {
+ var moveHostResp addHostsToTeamResponse
+ s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &moveHostResp)
+
+ h.TeamID = teamID
+ }
+
+ // add a couple global profiles
+ globalProfiles := [][]byte{
+ mobileconfigForTest("G1", "G1"),
+ mobileconfigForTest("G2", "G2"),
+ }
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
+ // create the no-team enroll secret
+ var applyResp applyEnrollSecretSpecResponse
+ globalEnrollSec := "global_enroll_sec"
+ s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
+ applyEnrollSecretSpecRequest{
+ Spec: &fleet.EnrollSecretSpec{
+ Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
+ },
+ }, http.StatusOK, &applyResp)
+
+ // create a team with a couple profiles
+ tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_1"})
+ require.NoError(t, err)
+ tm1Profiles := [][]byte{
+ mobileconfigForTest("T1.1", "T1.1"),
+ mobileconfigForTest("T1.2", "T1.2"),
+ }
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
+ "team_id", strconv.Itoa(int(tm1.ID)))
+ // create the team 1 enroll secret
+ var teamResp teamEnrollSecretsResponse
+ tm1EnrollSec := "team1_enroll_sec"
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1.ID),
+ modifyTeamEnrollSecretsRequest{
+ Secrets: []fleet.EnrollSecret{{Secret: tm1EnrollSec}},
+ }, http.StatusOK, &teamResp)
+
+ // create another team with different profiles
+ tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_2"})
+ require.NoError(t, err)
+ tm2Profiles := [][]byte{
+ mobileconfigForTest("T2.1", "T2.1"),
+ }
+ s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
+ "team_id", strconv.Itoa(int(tm2.ID)))
+
+ // enroll a couple hosts in no team
+ h1 := createManualMDMEnrollWithOrbit(globalEnrollSec)
+ require.Nil(t, h1.TeamID)
+ h2 := createManualMDMEnrollWithOrbit(globalEnrollSec)
+ require.Nil(t, h2.TeamID)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ },
+ h2: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ },
+ })
+
+ // enroll a couple hosts in team 1
+ h3 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
+ require.NotNil(t, h3.TeamID)
+ require.Equal(t, tm1.ID, *h3.TeamID)
+ h4 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
+ require.NotNil(t, h4.TeamID)
+ require.Equal(t, tm1.ID, *h4.TeamID)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h3: {
+ {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ },
+ h4: {
+ {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ },
+ })
+
+ // 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: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h2: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // switch a team host (h3) to another team (tm2)
+ s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h3.ID}}, http.StatusOK, &moveHostResp)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h3: {
+ {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // switch a team host (h4) to no team
+ s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
+ addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{h4.ID}}, http.StatusOK, &moveHostResp)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h3: {
+ {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // apply the pending profiles
+ triggerReconcileProfiles()
+
+ // add a profile to no team (h2 and h4 are now part of no team)
+ body, headers := generateNewProfileMultipartRequest(t,
+ "some_name", mobileconfigForTest("G3", "G3"), s.token, nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h2: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ },
+ h4: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // add a profile to team 2 (h1 and h3 are now part of team 2)
+ body, headers = generateNewProfileMultipartRequest(t,
+ "some_name", mobileconfigForTest("T2.2", "T2.2"), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm2.ID)}})
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // apply the pending profiles
+ triggerReconcileProfiles()
+
+ // delete a no team profile
+ noTeamProfs, err := s.ds.ListMDMAppleConfigProfiles(ctx, nil)
+ require.NoError(t, err)
+ var g1ProfID uint
+ for _, p := range noTeamProfs {
+ if p.Identifier == "G1" {
+ g1ProfID = p.ProfileID
+ break
+ }
+ }
+ require.NotZero(t, g1ProfID)
+ var delProfResp deleteMDMAppleConfigProfileResponse
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", g1ProfID),
+ deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h2: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // delete a team profile
+ tm2Profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
+ require.NoError(t, err)
+ var tm21ProfID uint
+ for _, p := range tm2Profs {
+ if p.Identifier == "T2.1" {
+ tm21ProfID = p.ProfileID
+ break
+ }
+ }
+ require.NotZero(t, tm21ProfID)
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", tm21ProfID),
+ deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // apply the pending profiles
+ triggerReconcileProfiles()
+
+ // bulk-set profiles for no team, with add/delete/edit
+ g2Edited := mobileconfigForTest("G2b", "G2b")
+ g4Content := mobileconfigForTest("G4", "G4")
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{
+ Profiles: [][]byte{
+ g2Edited,
+ // G3 is deleted
+ g4Content,
+ },
+ }, http.StatusNoContent)
+
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h2: {
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // bulk-set profiles for a team, with add/delete/edit
+ t22Edited := mobileconfigForTest("T2.2b", "T2.2b")
+ t23Content := mobileconfigForTest("T2.3", "T2.3")
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{
+ Profiles: [][]byte{
+ t22Edited,
+ t23Content,
+ },
+ }, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // apply the pending profiles
+ triggerReconcileProfiles()
+
+ // bulk-set profiles for no team and team 2, without changes, and team 1 added (but no host affected)
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{
+ Profiles: [][]byte{
+ g2Edited,
+ g4Content,
+ },
+ }, http.StatusNoContent)
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{
+ Profiles: [][]byte{
+ t22Edited,
+ t23Content,
+ },
+ }, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
+ s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch",
+ batchSetMDMAppleProfilesRequest{
+ Profiles: [][]byte{
+ mobileconfigForTest("T1.3", "T1.3"),
+ },
+ }, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID))
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h2: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // delete team 2 (h1 and h3 are part of that team)
+ s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tm2.ID), nil, http.StatusOK)
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // apply the pending profiles
+ triggerReconcileProfiles()
+
+ // all profiles now verifying
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h2: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // h1 verified one of the profiles
+ require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
+ "G2b": {Identifier: "G2b", DisplayName: "G2b", InstallDate: time.Now()},
+ }))
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h2: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ // switch a team host (h1) to another team (tm1)
+ assignHostToTeam(h1, &tm1.ID)
+
+ // Create a new profile that will be labeled
+ body, headers = generateNewProfileMultipartRequest(
+ t,
+ "label_prof",
+ mobileconfigForTest("label_prof", "label_prof"),
+ s.token,
+ map[string][]string{"team_id": {fmt.Sprintf("%d", tm1.ID)}},
+ )
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
+
+ var uid string
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "label_prof")
+ })
+
+ label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "test label 1", Query: "select 1;"})
+ require.NoError(t, err)
+
+ // Update label with host membership
+ mysql.ExecAdhocSQL(
+ t, s.ds, func(db sqlx.ExtContext) error {
+ _, err := db.ExecContext(
+ context.Background(),
+ "INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
+ h1.ID,
+ label.ID,
+ )
+ return err
+ },
+ )
+
+ // Update profile <-> label mapping
+ mysql.ExecAdhocSQL(
+ t, s.ds, func(db sqlx.ExtContext) error {
+ _, err := db.ExecContext(
+ context.Background(),
+ "INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
+ uid,
+ label.Name,
+ label.ID,
+ )
+ return err
+ },
+ )
+
+ triggerReconcileProfiles()
+
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h2: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+
+ require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
+ "label_prof": {Identifier: "label_prof", DisplayName: "label_prof", InstallDate: time.Now()},
+ }))
+
+ s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
+ h1: {
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
+ },
+ h2: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h3: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ h4: {
+ {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
+ },
+ })
+}
+
+func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() {
+ t := s.T()
+ ctx := context.Background()
+
+ testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
+ require.NoError(t, err)
+
+ assertAppleProfile := func(filename, name, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
+ fields := map[string][]string{
+ "labels": labelNames,
+ }
+ if teamID > 0 {
+ fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
+ }
+ body, headers := generateNewProfileMultipartRequest(
+ t, filename, mobileconfigForTest(name, ident), s.token, fields,
+ )
+ res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
+
+ if wantErrMsg != "" {
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, wantErrMsg)
+ return ""
+ }
+
+ var resp newMDMConfigProfileResponse
+ err := json.NewDecoder(res.Body).Decode(&resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.ProfileUUID)
+ require.Equal(t, "a", string(resp.ProfileUUID[0]))
+ return resp.ProfileUUID
+ }
+ assertAppleDeclaration := func(filename, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
+ fields := map[string][]string{
+ "labels": labelNames,
+ }
+ if teamID > 0 {
+ fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
+ }
+
+ bytes := []byte(fmt.Sprintf(`{
+ "Type": "com.apple.configuration.foo",
+ "Payload": {
+ "Echo": "f1337"
+ },
+ "Identifier": "%s"
+}`, ident))
+
+ body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
+ res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
+
+ if wantErrMsg != "" {
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, wantErrMsg)
+ return ""
+ }
+
+ var resp newMDMConfigProfileResponse
+ err := json.NewDecoder(res.Body).Decode(&resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.ProfileUUID)
+ require.Equal(t, fleet.MDMAppleDeclarationUUIDPrefix, string(resp.ProfileUUID[0]))
+ return resp.ProfileUUID
+ }
+
+ createAppleProfile := func(name, ident string, teamID uint, labelNames []string) string {
+ uid := assertAppleProfile(name+".mobileconfig", name, ident, teamID, labelNames, http.StatusOK, "")
+
+ var wantJSON string
+ if teamID == 0 {
+ wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "profile_identifier": %q}`, name, ident)
+ } else {
+ wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "profile_identifier": %q}`, teamID, testTeam.Name, name, ident)
+ }
+ s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedMacosProfile{}.ActivityName(), wantJSON, 0)
+
+ return uid
+ }
+
+ createAppleDeclaration := func(name, ident string, teamID uint, labelNames []string) string {
+ uid := assertAppleDeclaration(name+".json", ident, teamID, labelNames, http.StatusOK, "")
+
+ var wantJSON string
+ if teamID == 0 {
+ wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "identifier": %q}`, name, ident)
+ } else {
+ wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "identifier": %q}`, teamID, testTeam.Name, name, ident)
+ }
+ s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedDeclarationProfile{}.ActivityName(), wantJSON, 0)
+
+ return uid
+ }
+
+ assertWindowsProfile := func(filename, locURI string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
+ fields := map[string][]string{
+ "labels": labelNames,
+ }
+ if teamID > 0 {
+ fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
+ }
+ body, headers := generateNewProfileMultipartRequest(
+ t,
+ filename,
+ []byte(fmt.Sprintf(`- %s
- %s
`, locURI, locURI)),
+ s.token,
+ fields,
+ )
+ res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
+
+ if wantErrMsg != "" {
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, wantErrMsg)
+ return ""
+ }
+
+ var resp newMDMConfigProfileResponse
+ err := json.NewDecoder(res.Body).Decode(&resp)
+ require.NoError(t, err)
+ require.NotEmpty(t, resp.ProfileUUID)
+ require.Equal(t, "w", string(resp.ProfileUUID[0]))
+ return resp.ProfileUUID
+ }
+ createWindowsProfile := func(name string, teamID uint, labels []string) string {
+ uid := assertWindowsProfile(name+".xml", "./Test", teamID, labels, http.StatusOK, "")
+
+ var wantJSON string
+ if teamID == 0 {
+ wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name)
+ } else {
+ wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name)
+ }
+ s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.ActivityName(), wantJSON, 0)
+
+ return uid
+ }
+
+ // create a couple Apple profiles for no-team and team
+ noTeamAppleProfUUID := createAppleProfile("apple-global-profile", "test-global-ident", 0, nil)
+ teamAppleProfUUID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID, nil)
+ // create a couple Windows profiles for no-team and team
+ noTeamWinProfUUID := createWindowsProfile("win-global-profile", 0, nil)
+ teamWinProfUUID := createWindowsProfile("win-team-profile", testTeam.ID, nil)
+
+ // Windows profile name conflicts with Apple's for no team
+ assertWindowsProfile("apple-global-profile.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
+ // but no conflict for team 1
+ assertWindowsProfile("apple-global-profile.xml", "./Test", testTeam.ID, nil, http.StatusOK, "")
+ // Apple profile name conflicts with Windows' for no team
+ assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
+ // but no conflict for team 1
+ assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, nil, http.StatusOK, "")
+ // Windows profile name conflicts with Apple's for team 1
+ assertWindowsProfile("apple-team-profile.xml", "./Test", testTeam.ID, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
+ // but no conflict for no-team
+ assertWindowsProfile("apple-team-profile.xml", "./Test", 0, nil, http.StatusOK, "")
+ // Apple profile name conflicts with Windows' for team 1
+ assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
+ // but no conflict for no-team
+ assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "")
+
+ // add some macOS declarations
+ createAppleDeclaration("apple-declaration", "test-declaration-ident", 0, nil)
+ // identifier must be unique, it conflicts with existing declaration
+ assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", 0, nil, http.StatusConflict, "test-declaration-ident already exists")
+ // name is pulled from filename, it conflicts with existing declaration
+ assertAppleDeclaration("apple-declaration.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "apple-declaration already exists")
+ // uniqueness is checked only within team, so it's fine to have the same name and identifier in different teams
+ assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", testTeam.ID, nil, http.StatusOK, "")
+ // name is pulled from filename, it conflicts with existing macOS config profile
+ assertAppleDeclaration("apple-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "apple-global-profile already exists")
+ // name is pulled from filename, it conflicts with existing macOS config profile
+ assertAppleDeclaration("win-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "win-global-profile already exists")
+ // windows profile name conflicts with existing declaration
+ assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
+ // macOS profile name conflicts with existing declaration
+ assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
+
+ // not an xml nor mobileconfig file
+ assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
+ assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
+ assertAppleDeclaration("foo.txt", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
+
+ // Windows-reserved LocURI
+ assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.")
+ assertWindowsProfile("updates.xml", syncml.FleetOSUpdateTargetLocURI, testTeam.ID, nil, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include Windows updates settings.")
+
+ // Fleet-reserved profiles
+ for name := range servermdm.FleetReservedProfileNames() {
+ assertAppleProfile(name+".mobileconfig", name, name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %s is not allowed`, name))
+ assertAppleDeclaration(name+".json", name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %q is not allowed`, name))
+ assertWindowsProfile(name+".xml", "./Test", 0, nil, http.StatusBadRequest, fmt.Sprintf(`Couldn't upload. Profile name %q is not allowed.`, name))
+ }
+
+ // profiles with non-existent labels
+ assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
+ assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
+ assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
+
+ // create a couple of labels
+ labelFoo := &fleet.Label{Name: "foo", Query: "select * from foo;"}
+ labelFoo, err = s.ds.NewLabel(context.Background(), labelFoo)
+ require.NoError(t, err)
+ labelBar := &fleet.Label{Name: "bar", Query: "select * from bar;"}
+ labelBar, err = s.ds.NewLabel(context.Background(), labelBar)
+ require.NoError(t, err)
+
+ // profiles mixing existent and non-existent labels
+ assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
+ assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
+ assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, "some or all the labels provided don't exist")
+
+ // profiles with valid labels
+ uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"foo"}, http.StatusOK, "")
+ uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"})
+ uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"foo", "bar"}, http.StatusOK, "")
+
+ // verify that the label associations have been created
+ // TODO: update when we have datastore methods to get this data
+ var profileLabels []fleet.ConfigurationProfileLabel
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `
+ SELECT COALESCE(apple_profile_uuid, windows_profile_uuid) as profile_uuid, label_name, COALESCE(label_id, 0) as label_id
+ FROM mdm_configuration_profile_labels
+ UNION SELECT apple_declaration_uuid as profile_uuid, label_name, COALESCE(label_id, 0) as label_id
+ FROM mdm_declaration_labels ORDER BY profile_uuid, label_name;`
+ return sqlx.SelectContext(context.Background(), q, &profileLabels, stmt)
+ })
+
+ require.NotEmpty(t, profileLabels)
+ require.Len(t, profileLabels, 4)
+ require.ElementsMatch(
+ t,
+ []fleet.ConfigurationProfileLabel{
+ {ProfileUUID: uuidAppleWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
+ {ProfileUUID: uuidAppleDDMWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
+ {ProfileUUID: uuidWindowsWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
+ {ProfileUUID: uuidWindowsWithLabel, LabelName: labelBar.Name, LabelID: labelBar.ID},
+ },
+ profileLabels,
+ )
+
+ // Windows invalid content
+ body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil)
+ res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn't upload. The file should include valid XML:")
+
+ // Apple invalid mobileconfig content
+ body, headers = generateNewProfileMultipartRequest(t,
+ "apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil)
+ res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "mobileconfig is not XML nor PKCS7 parseable")
+
+ // Apple invalid json declaration
+ body, headers = generateNewProfileMultipartRequest(t,
+ "apple.json", []byte("{"), s.token, nil)
+ res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn't upload. The file should include valid JSON:")
+
+ // get the existing profiles work
+ expectedProfiles := []fleet.MDMConfigProfilePayload{
+ {ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil},
+ {ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID},
+ {ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
+ {ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID},
+ {ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil, Labels: []fleet.ConfigurationProfileLabel{{LabelID: labelFoo.ID, LabelName: labelFoo.Name}}},
+ }
+ for _, prof := range expectedProfiles {
+ var getResp getMDMConfigProfileResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, &getResp)
+ require.NotZero(t, getResp.CreatedAt)
+ require.NotZero(t, getResp.UploadedAt)
+ if getResp.Platform == "darwin" {
+ require.Len(t, getResp.Checksum, 16)
+ } else {
+ require.Empty(t, getResp.Checksum)
+ }
+ getResp.CreatedAt, getResp.UploadedAt = time.Time{}, time.Time{}
+ getResp.Checksum = nil
+ require.Equal(t, prof, *getResp.MDMConfigProfilePayload)
+
+ resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media")
+ require.NotZero(t, resp.ContentLength)
+ require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
+ if strings.HasPrefix(prof.ProfileUUID, "a") {
+ require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
+ } else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
+ require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
+ } else {
+ require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream")
+ }
+ require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
+
+ b, err := io.ReadAll(resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, resp.ContentLength, int64(len(b)))
+ }
+
+ var getResp getMDMConfigProfileResponse
+ // get an unknown Apple profile
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp)
+ s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media")
+ // get an unknown Apple declaration
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp)
+ s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
+ // get an unknown Windows profile
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp)
+ s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media")
+
+ var deleteResp deleteMDMConfigProfileResponse
+ // delete existing Apple profiles
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp)
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp)
+ // delete non-existing Apple profile
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp)
+
+ // delete existing Apple declaration
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(),
+ `{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null}`,
+ 0,
+ )
+ // delete non-existing Apple declaration
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
+ // delete existing Windows profiles
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamWinProfUUID), nil, http.StatusOK, &deleteResp)
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamWinProfUUID), nil, http.StatusOK, &deleteResp)
+ // delete non-existing Windows profile
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &deleteResp)
+
+ // trying to create/delete profiles managed by Fleet fails
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ assertAppleProfile("foo.mobileconfig", p, p, 0, nil, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p))
+
+ // create it directly in the DB to test deletion
+ uid := "a" + uuid.NewString()
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ mc := mcBytesForTest(p, p, uuid.New().String())
+ _, err := q.ExecContext(ctx,
+ "INSERT INTO mdm_apple_configuration_profiles (profile_uuid, identifier, name, mobileconfig, checksum, team_id, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP())",
+ uid, p, p, mc, "1234", 0)
+ return err
+ })
+
+ var deleteResp deleteMDMConfigProfileResponse
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uid), nil, http.StatusBadRequest, &deleteResp)
+
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx,
+ "DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?",
+ uid)
+ return err
+ })
+ }
+ // TODO: Add tests for create/delete forbidden declaration types?
+
+ // make fleet add a FileVault profile
+ acResp := appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": { "enable_disk_encryption": true }
+ }`), http.StatusOK, &acResp)
+ assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
+ profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
+
+ // try to delete the profile
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profile.ProfileUUID), nil, http.StatusBadRequest, &deleteResp)
+
+ // make fleet add a Windows OS Updates profile
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }
+ }`), http.StatusOK, &acResp)
+ profUUID := checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1)})
+
+ // try to delete the profile
+ s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp)
+
+ // TODO: Add tests for OS updates declaration when implemented.
+}
+
+func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() {
+ t := s.T()
+ ctx := context.Background()
+
+ // create some teams
+ tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
+ require.NoError(t, err)
+ tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
+ require.NoError(t, err)
+ tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
+ require.NoError(t, err)
+
+ // create 5 profiles for no team and team 1, names are A, B, C ... for global and
+ // tA, tB, tC ... for team 1. Alternate macOS and Windows profiles.
+ for i := 0; i < 5; i++ {
+ name := string('A' + byte(i))
+ if i%2 == 0 {
+ prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil)
+ require.NoError(t, err)
+ _, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof)
+ require.NoError(t, err)
+
+ tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil)
+ require.NoError(t, err)
+ tprof.TeamID = &tm1.ID
+ _, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof)
+ require.NoError(t, err)
+ } else {
+ _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(``)})
+ require.NoError(t, err)
+ _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(``)})
+ require.NoError(t, err)
+ }
+ }
+
+ // create a couple profiles (Win and mac) for team 2, and none for team 3
+ tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil)
+ require.NoError(t, err)
+ tprof.TeamID = &tm2.ID
+ tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof)
+ require.NoError(t, err)
+ // checksum is not returned by New..., so compute it manually
+ checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test
+ tm2ProfF.Checksum = checkSum[:]
+
+ // make tm2ProfG a label-based profile
+ lblFoo, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "foo", Query: "select 1"})
+ require.NoError(t, err)
+ lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"})
+ require.NoError(t, err)
+
+ tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
+ Name: "tG",
+ TeamID: &tm2.ID,
+ SyncML: []byte(``),
+ Labels: []fleet.ConfigurationProfileLabel{
+ {LabelID: lblFoo.ID, LabelName: lblFoo.Name},
+ {LabelID: lblBar.ID, LabelName: lblBar.Name},
+ },
+ })
+ require.NoError(t, err)
+ // break lblFoo by deleting it
+ require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name))
+
+ // test that all fields are correctly returned with team 2
+ var listResp listMDMConfigProfilesResponse
+ s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID))
+ require.Len(t, listResp.Profiles, 2)
+ require.NotZero(t, listResp.Profiles[0].CreatedAt)
+ require.NotZero(t, listResp.Profiles[0].UploadedAt)
+ require.NotZero(t, listResp.Profiles[1].CreatedAt)
+ require.NotZero(t, listResp.Profiles[1].UploadedAt)
+ listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{}
+ listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{}
+ require.Equal(t, &fleet.MDMConfigProfilePayload{
+ ProfileUUID: tm2ProfF.ProfileUUID,
+ TeamID: tm2ProfF.TeamID,
+ Name: tm2ProfF.Name,
+ Platform: "darwin",
+ Identifier: tm2ProfF.Identifier,
+ Checksum: tm2ProfF.Checksum,
+ Labels: nil,
+ }, listResp.Profiles[0])
+ require.Equal(t, &fleet.MDMConfigProfilePayload{
+ ProfileUUID: tm2ProfG.ProfileUUID,
+ TeamID: tm2ProfG.TeamID,
+ Name: tm2ProfG.Name,
+ Platform: "windows",
+ // labels are ordered by name
+ Labels: []fleet.ConfigurationProfileLabel{
+ {LabelID: lblBar.ID, LabelName: lblBar.Name},
+ {LabelID: 0, LabelName: lblFoo.Name, Broken: true},
+ },
+ }, listResp.Profiles[1])
+
+ // get the specific label-based profile returns the information
+ var getProfResp getMDMConfigProfileResponse
+ s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfG.ProfileUUID, nil, http.StatusOK, &getProfResp)
+ getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
+ require.Equal(t, &fleet.MDMConfigProfilePayload{
+ ProfileUUID: tm2ProfG.ProfileUUID,
+ TeamID: tm2ProfG.TeamID,
+ Name: tm2ProfG.Name,
+ Platform: "windows",
+ // labels are ordered by name
+ Labels: []fleet.ConfigurationProfileLabel{
+ {LabelID: lblBar.ID, LabelName: lblBar.Name},
+ {LabelID: 0, LabelName: lblFoo.Name, Broken: true},
+ },
+ }, getProfResp.MDMConfigProfilePayload)
+
+ // get the non label-based profile returns no labels
+ getProfResp = getMDMConfigProfileResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfF.ProfileUUID, nil, http.StatusOK, &getProfResp)
+ getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
+ require.Equal(t, &fleet.MDMConfigProfilePayload{
+ ProfileUUID: tm2ProfF.ProfileUUID,
+ TeamID: tm2ProfF.TeamID,
+ Name: tm2ProfF.Name,
+ Platform: "darwin",
+ Identifier: tm2ProfF.Identifier,
+ Checksum: tm2ProfF.Checksum,
+ Labels: nil,
+ }, getProfResp.MDMConfigProfilePayload)
+
+ // list for a non-existing team returns 404
+ s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999")
+
+ cases := []struct {
+ queries []string // alternate query name and value
+ teamID *uint
+ wantNames []string
+ wantMeta *fleet.PaginationMetadata
+ }{
+ {
+ wantNames: []string{"A", "B", "C", "D", "E"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
+ },
+ {
+ queries: []string{"per_page", "2"},
+ wantNames: []string{"A", "B"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
+ },
+ {
+ queries: []string{"per_page", "2", "page", "1"},
+ wantNames: []string{"C", "D"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
+ },
+ {
+ queries: []string{"per_page", "2", "page", "2"},
+ wantNames: []string{"E"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
+ },
+ {
+ queries: []string{"per_page", "3"},
+ teamID: &tm1.ID,
+ wantNames: []string{"tA", "tB", "tC"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
+ },
+ {
+ queries: []string{"per_page", "3", "page", "1"},
+ teamID: &tm1.ID,
+ wantNames: []string{"tD", "tE"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
+ },
+ {
+ queries: []string{"per_page", "3", "page", "2"},
+ teamID: &tm1.ID,
+ wantNames: nil,
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
+ },
+ {
+ queries: []string{"per_page", "3"},
+ teamID: &tm2.ID,
+ wantNames: []string{"tF", "tG"},
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
+ },
+ {
+ queries: []string{"per_page", "2"},
+ teamID: &tm3.ID,
+ wantNames: nil,
+ wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
+ },
+ }
+ for _, c := range cases {
+ t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) {
+ var listResp listMDMConfigProfilesResponse
+ queryArgs := c.queries
+ if c.teamID != nil {
+ queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID))
+ }
+ s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, queryArgs...)
+
+ require.Equal(t, len(c.wantNames), len(listResp.Profiles))
+ require.Equal(t, c.wantMeta, listResp.Meta)
+
+ var gotNames []string
+ if len(listResp.Profiles) > 0 {
+ gotNames = make([]string, len(listResp.Profiles))
+ for i, p := range listResp.Profiles {
+ gotNames[i] = p.Name
+ if p.Name == "tG" {
+ require.Len(t, p.Labels, 2)
+ } else {
+ require.Nil(t, p.Labels)
+ }
+ if c.teamID == nil {
+ // we set it to 0 for global
+ require.NotNil(t, p.TeamID)
+ require.Zero(t, *p.TeamID)
+ } else {
+ require.NotNil(t, p.TeamID)
+ require.Equal(t, *c.teamID, *p.TeamID)
+ }
+ require.NotEmpty(t, p.Platform)
+ }
+ }
+ require.Equal(t, c.wantNames, gotNames)
+ })
+ }
+}
+
+func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
+ t := s.T()
+ ctx := context.Background()
+
+ err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
+ require.NoError(t, err)
+
+ globalProfiles := []string{
+ mysql.InsertWindowsProfileForTest(t, s.ds, 0),
+ mysql.InsertWindowsProfileForTest(t, s.ds, 0),
+ mysql.InsertWindowsProfileForTest(t, s.ds, 0),
+ }
+
+ // create a new team
+ tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
+ require.NoError(t, err)
+ teamProfiles := []string{
+ mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
+ mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
+ }
+
+ // create a non-Windows host
+ _, err = s.ds.NewHost(context.Background(), &fleet.Host{
+ ID: 1,
+ OsqueryHostID: ptr.String("non-windows-host"),
+ NodeKey: ptr.String("non-windows-host"),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%sfoo.local.non.windows", t.Name()),
+ Platform: "darwin",
+ })
+ require.NoError(t, err)
+
+ // create a Windows host that's not enrolled into MDM
+ _, err = s.ds.NewHost(context.Background(), &fleet.Host{
+ ID: 2,
+ OsqueryHostID: ptr.String("not-mdm-enrolled"),
+ NodeKey: ptr.String("not-mdm-enrolled"),
+ UUID: uuid.New().String(),
+ Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
+ Platform: "windows",
+ })
+ require.NoError(t, err)
+
+ verifyHostProfileStatus := func(cmds []fleet.ProtoCmdOperation, wantStatus string) {
+ for _, cmd := range cmds {
+ var gotProfile struct {
+ Status string `db:"status"`
+ Retries int `db:"retries"`
+ }
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `
+ SELECT COALESCE(status, 'pending') as status, retries
+ FROM host_mdm_windows_profiles
+ WHERE command_uuid = ?`
+ return sqlx.GetContext(context.Background(), q, &gotProfile, stmt, cmd.Cmd.CmdID.Value)
+ })
+
+ wantDeliveryStatus := fleet.WindowsResponseToDeliveryStatus(wantStatus)
+ if gotProfile.Retries <= servermdm.MaxProfileRetries && wantDeliveryStatus == fleet.MDMDeliveryFailed {
+ require.EqualValues(t, "pending", gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value)
+ } else {
+ require.EqualValues(t, wantDeliveryStatus, gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value)
+ }
+ }
+ }
+
+ verifyProfiles := func(device *mdmtest.TestWindowsMDMClient, n int, fail bool) {
+ mdmResponseStatus := syncml.CmdStatusOK
+ if fail {
+ mdmResponseStatus = syncml.CmdStatusAtomicFailed
+ }
+ s.awaitTriggerProfileSchedule(t)
+ cmds, err := device.StartManagementSession()
+ require.NoError(t, err)
+ // 2 Status + n profiles
+ require.Len(t, cmds, n+2)
+
+ var atomicCmds []fleet.ProtoCmdOperation
+ msgID, err := device.GetCurrentMsgID()
+ require.NoError(t, err)
+ for _, c := range cmds {
+ cmdID := c.Cmd.CmdID
+ status := syncml.CmdStatusOK
+ if c.Verb == "Atomic" {
+ atomicCmds = append(atomicCmds, c)
+ status = mdmResponseStatus
+ require.NotEmpty(t, c.Cmd.ReplaceCommands)
+ for _, rc := range c.Cmd.ReplaceCommands {
+ require.NotEmpty(t, rc.CmdID)
+ }
+ }
+ device.AppendResponse(fleet.SyncMLCmd{
+ XMLName: xml.Name{Local: fleet.CmdStatus},
+ MsgRef: &msgID,
+ CmdRef: &cmdID.Value,
+ Cmd: ptr.String(c.Verb),
+ Data: &status,
+ Items: nil,
+ CmdID: fleet.CmdID{Value: uuid.NewString()},
+ })
+ }
+ // TODO: verify profile contents as well
+ require.Len(t, atomicCmds, n)
+
+ // before we send the response, commands should be "pending"
+ verifyHostProfileStatus(atomicCmds, "")
+
+ cmds, err = device.SendResponse()
+ require.NoError(t, err)
+ // the ack of the message should be the only returned command
+ require.Len(t, cmds, 1)
+
+ // verify that we updated status in the db
+ verifyHostProfileStatus(atomicCmds, mdmResponseStatus)
+ }
+
+ checkHostsProfilesMatch := func(host *fleet.Host, wantUUIDs []string) {
+ var gotUUIDs []string
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `SELECT profile_uuid FROM host_mdm_windows_profiles WHERE host_uuid = ?`
+ return sqlx.SelectContext(context.Background(), q, &gotUUIDs, stmt, host.UUID)
+ })
+ require.ElementsMatch(t, wantUUIDs, gotUUIDs)
+ }
+
+ checkHostDetails := func(t *testing.T, host *fleet.Host, wantProfs []string, wantStatus fleet.MDMDeliveryStatus) {
+ var gotHostResp getHostResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &gotHostResp)
+ require.NotNil(t, gotHostResp.Host.MDM.Profiles)
+ var gotProfs []string
+ require.Len(t, *gotHostResp.Host.MDM.Profiles, len(wantProfs))
+ for _, p := range *gotHostResp.Host.MDM.Profiles {
+ gotProfs = append(gotProfs, strings.Replace(p.Name, "name-", "", 1))
+ require.NotNil(t, p.Status)
+ require.Equal(t, wantStatus, *p.Status, "profile", p.Name)
+ require.Equal(t, "windows", p.Platform)
+ // Fleet reserved profiles (e.g., OS updates) should be screened from the host details response
+ require.NotContains(t, servermdm.ListFleetReservedWindowsProfileNames(), p.Name)
+ }
+ require.ElementsMatch(t, wantProfs, gotProfs)
+ }
+
+ checkHostsFilteredByOSSettingsStatus := func(t *testing.T, wantHosts []string, wantStatus fleet.MDMDeliveryStatus, teamID *uint, labels ...*fleet.Label) {
+ var teamFilter string
+ if teamID != nil {
+ teamFilter = fmt.Sprintf("&team_id=%d", *teamID)
+ }
+ var gotHostsResp listHostsResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
+ require.NotNil(t, gotHostsResp.Hosts)
+ var gotHosts []string
+ for _, h := range gotHostsResp.Hosts {
+ gotHosts = append(gotHosts, h.Hostname)
+ }
+ require.ElementsMatch(t, wantHosts, gotHosts)
+
+ var countHostsResp countHostsResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
+ require.Equal(t, len(wantHosts), countHostsResp.Count)
+
+ for _, l := range labels {
+ gotHostsResp = listHostsResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/labels/%d/hosts?os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
+ require.NotNil(t, gotHostsResp.Hosts)
+ gotHosts = []string{}
+ for _, h := range gotHostsResp.Hosts {
+ gotHosts = append(gotHosts, h.Hostname)
+ }
+ require.ElementsMatch(t, wantHosts, gotHosts, "label", l.Name)
+
+ countHostsResp = countHostsResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?label_id=%d&os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
+ }
+ }
+
+ getProfileUUID := func(t *testing.T, profName string, teamID *uint) string {
+ var profUUID string
+ mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
+ var globalOrTeamID uint
+ if teamID != nil {
+ globalOrTeamID = *teamID
+ }
+ return sqlx.GetContext(ctx, tx, &profUUID, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, profName)
+ })
+ require.NotNil(t, profUUID)
+ return profUUID
+ }
+
+ checkHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, wantStatus fleet.MDMDeliveryStatus) {
+ var gotStatus fleet.MDMDeliveryStatus
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_uuid = ?`
+ err := sqlx.GetContext(context.Background(), q, &gotStatus, stmt, hostUUID, profUUID)
+ return err
+ })
+ require.Equal(t, wantStatus, gotStatus)
+ }
+
+ // Create a host and then enroll to MDM.
+ host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
+ // trigger a profile sync
+ verifyProfiles(mdmDevice, 3, false)
+ checkHostsProfilesMatch(host, globalProfiles)
+ checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
+
+ // can't resend a profile while it is verifying
+ res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusConflict)
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
+
+ // create new label that includes host
+ label := &fleet.Label{
+ Name: t.Name() + "foo",
+ Query: "select * from foo;",
+ }
+ label, err = s.ds.NewLabel(context.Background(), label)
+ require.NoError(t, err)
+ require.NoError(t, s.ds.RecordLabelQueryExecutions(ctx, host, map[uint]*bool{label.ID: ptr.Bool(true)}, time.Now(), false))
+
+ // simulate osquery reporting host mdm details (host_mdm.enrolled = 1 is condition for
+ // hosts filtering by os settings status and generating mdm profiles summaries)
+ require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, ""))
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
+ s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
+ Verifying: 1,
+ }, nil)
+
+ // another sync shouldn't return profiles
+ verifyProfiles(mdmDevice, 0, false)
+
+ // make fleet add a Windows OS Updates profile
+ acResp := appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &acResp)
+ osUpdatesProf := getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, nil)
+
+ // os updates is sent via a profiles commands
+ verifyProfiles(mdmDevice, 1, false)
+ checkHostsProfilesMatch(host, append(globalProfiles, osUpdatesProf))
+ // but is hidden from host details response
+ checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
+
+ // os updates profile status doesn't matter for filtered hosts results or summaries
+ checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying)
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
+ s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
+ Verifying: 1,
+ }, nil)
+ // force os updates profile to failed, doesn't impact filtered hosts results or summaries
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, osUpdatesProf)
+ return err
+ })
+ checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed)
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
+ s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
+ Verifying: 1,
+ }, nil)
+ // force another profile to failed, does impact filtered hosts results and summaries
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, globalProfiles[0])
+ return err
+ })
+ checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryFailed)
+ checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerifying, nil, label) // expect no hosts
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, nil, label) // expect host
+ s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
+ Failed: 1,
+ Verifying: 0,
+ }, nil)
+
+ // can resend a profile after it has failed
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusAccepted)
+ verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
+ checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying
+
+ // add the host to a team
+ err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
+ require.NoError(t, err)
+
+ // trigger a profile sync, device gets the team profile
+ verifyProfiles(mdmDevice, 2, false)
+ checkHostsProfilesMatch(host, teamProfiles)
+ checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
+
+ // set new team profiles (delete + addition)
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, teamProfiles[1])
+ return err
+ })
+ teamProfiles = []string{
+ teamProfiles[0],
+ mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
+ }
+
+ // trigger a profile sync, device gets the team profile
+ verifyProfiles(mdmDevice, 1, false)
+
+ // check that we deleted the old profile in the DB
+ checkHostsProfilesMatch(host, teamProfiles)
+ checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
+
+ // can't resend a profile while it is verifying
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusConflict)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
+
+ // can't resend a profile from the wrong team
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusNotFound)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Unable to match profile to host.")
+
+ // another sync shouldn't return profiles
+ verifyProfiles(mdmDevice, 0, false)
+
+ // set new team profiles (delete + addition)
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, teamProfiles[1])
+ return err
+ })
+ teamProfiles = []string{
+ teamProfiles[0],
+ mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
+ }
+ // trigger a profile sync, this time fail the delivery
+ verifyProfiles(mdmDevice, 1, true)
+
+ // check that we deleted the old profile in the DB
+ checkHostsProfilesMatch(host, teamProfiles)
+
+ // a second sync gets the profile again, because of delivery retries.
+ // Succeed that one
+ verifyProfiles(mdmDevice, 1, false)
+
+ // another sync shouldn't return profiles
+ verifyProfiles(mdmDevice, 0, false)
+
+ // make fleet add a Windows OS Updates profile
+ tmResp := teamResponse{}
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &tmResp)
+ osUpdatesProf = getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, &tm.ID)
+
+ // os updates is sent via a profiles commands
+ verifyProfiles(mdmDevice, 1, false)
+ checkHostsProfilesMatch(host, append(teamProfiles, osUpdatesProf))
+ // but is hidden from host details response
+ checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
+
+ // os updates profile status doesn't matter for filtered hosts results or summaries
+ checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying)
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
+ Verifying: 1,
+ }, nil)
+ // force os updates profile to failed, doesn't impact filtered hosts results or summaries
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, osUpdatesProf)
+ return err
+ })
+ checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed)
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label)
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
+ Verifying: 1,
+ }, nil)
+ // force another profile to failed, does impact filtered hosts results and summaries
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
+ _, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
+ return err
+ })
+ checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryFailed)
+ checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerifying, &tm.ID, label) // expect no hosts
+ checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, &tm.ID, label) // expect host
+ s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
+ Failed: 1,
+ Verifying: 0,
+ }, nil)
+
+ // can resend a profile after it has failed
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
+ verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
+ checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so back to verifying
+ s.lastActivityMatches(
+ fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
+ fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+teamProfiles[0]),
+ 0)
+
+ // add a macOS profile to the team
+ mcUUID := "a" + uuid.NewString()
+ prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID)
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
+ _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID))
+ return err
+ })
+
+ // trigger a profile sync, device doesn't get the macOS profile
+ verifyProfiles(mdmDevice, 0, false)
+
+ // can't resend a macOS profile to a Windows host
+ res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Profile is not compatible with host platform")
+}
+
+func (s *integrationMDMTestSuite) TestAppConfigMDMWindowsProfiles() {
+ t := s.T()
+
+ // set the windows custom settings fields
+ acResp := appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "windows_settings": {
+ "custom_settings": [
+ {"path": "foo", "labels": ["baz"]},
+ {"path": "bar"}
+ ]
+ }
+ }
+ }`), http.StatusOK, &acResp)
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
+
+ // check that they are returned by a GET /config
+ acResp = appConfigResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
+
+ // patch without specifying the windows custom settings fields and an unrelated
+ // field, should not remove them
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": { "enable_disk_encryption": true }
+ }`), http.StatusOK, &acResp)
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
+
+ // patch with explicitly empty windows custom settings fields, would remove
+ // them but this is a dry-run
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": { "windows_settings": { "custom_settings": null } }
+ }`), http.StatusOK, &acResp, "dry_run", "true")
+ assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
+
+ // patch with explicitly empty windows custom settings fields, removes them
+ acResp = appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": { "windows_settings": { "custom_settings": null } }
+ }`), http.StatusOK, &acResp)
+ assert.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value)
+}
+
+func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
+ t := s.T()
+
+ // create a team through the service so it initializes the agent ops
+ teamName := t.Name() + "team1"
+ team := &fleet.Team{
+ Name: teamName,
+ Description: "desc team1",
+ }
+ var createTeamResp teamResponse
+ s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
+ require.NotZero(t, createTeamResp.Team.ID)
+ team = createTeamResp.Team
+
+ rawTeamSpec := func(mdmValue string) json.RawMessage {
+ return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue))
+ }
+
+ // set the windows custom settings fields
+ var applyResp applyTeamSpecsResponse
+ s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
+ {
+ "windows_settings": {
+ "custom_settings": [
+ {"path": "foo", "labels": ["baz"]},
+ {"path": "bar"}
+ ]
+ }
+ }
+ `), http.StatusOK, &applyResp)
+ require.Len(t, applyResp.TeamIDsByName, 1)
+
+ // check that they are returned by a GET /config
+ var teamResp getTeamResponse
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.ElementsMatch(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
+
+ // patch without specifying the windows custom settings fields and an unrelated
+ // field, should not remove them
+ applyResp = applyTeamSpecsResponse{}
+ s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp)
+ require.Len(t, applyResp.TeamIDsByName, 1)
+
+ // check that they are returned by a GET /config
+ teamResp = getTeamResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.ElementsMatch(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
+
+ // patch with explicitly empty windows custom settings fields, would remove
+ // them but this is a dry-run
+ applyResp = applyTeamSpecsResponse{}
+ s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
+ { "windows_settings": { "custom_settings": null } }
+ `), http.StatusOK, &applyResp, "dry_run", "true")
+ assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
+
+ teamResp = getTeamResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.ElementsMatch(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
+
+ // patch with explicitly empty windows custom settings fields, removes them
+ applyResp = applyTeamSpecsResponse{}
+ s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
+ { "windows_settings": { "custom_settings": null } }
+ `), http.StatusOK, &applyResp)
+ require.Len(t, applyResp.TeamIDsByName, 1)
+
+ teamResp = getTeamResponse{}
+ s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
+ require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
+}
+
+func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
+ t := s.T()
+ ctx := context.Background()
+
+ // create a new team
+ tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
+ require.NoError(t, err)
+
+ // apply an empty set to no-team
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
+ `{"team_id": null, "team_name": null}`,
+ 0,
+ )
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
+ `{"team_id": null, "team_name": null}`,
+ 0,
+ )
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
+ `{"team_id": null, "team_name": null}`,
+ 0,
+ )
+
+ // apply to both team id and name
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
+ http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name)
+
+ // invalid team name
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
+ http.StatusNotFound, "team_name", uuid.New().String())
+
+ // duplicate PayloadDisplayName
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+
+ // profiles with reserved macOS identifiers
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: p, Contents: mobileconfigForTest(p, p)},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
+ }
+
+ // payloads with reserved types
+ for p := range mobileconfig.FleetPayloadTypes() {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
+ }
+
+ // payloads with reserved identifiers
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
+ }
+
+ // profiles with forbidden declaration types
+ for dt := range fleet.ForbiddenDeclTypes {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTestWithType("D1", dt)},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt)
+ }
+ // and one more for the software update declaration
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.")
+
+ // invalid JSON
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: []byte(`{"foo":}`)},
+ }}, http.StatusBadRequest, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "The file should include valid JSON")
+
+ // profiles with reserved Windows location URIs
+ // bitlocker
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: syncml.FleetBitLockerTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
+
+ // os updates
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: syncml.FleetOSUpdateTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))},
+ {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
+
+ // invalid windows tag
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N3", Contents: []byte(``)},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
+
+ // invalid xml
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N3", Contents: []byte(`foo`)},
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
+
+ // successfully apply windows and macOS a profiles for the team, but it's a dry run
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
+ }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true")
+ s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
+ s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
+
+ // successfully apply for a team and verify activities
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
+ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
+ {Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
+ {Name: "N4", Contents: declarationForTest("D1")},
+ }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
+ s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
+ s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
+ 0,
+ )
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
+ 0,
+ )
+
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
+ 0,
+ )
+
+ // names cannot be duplicated across platforms
+ declBytes := json.RawMessage(`{
+ "Type": "com.apple.configuration.decl.foo",
+ "Identifier": "com.fleet.config.foo",
+ "Payload": {
+ "ServiceType": "com.apple.bash",
+ "DataAssetReference": "com.fleet.asset.bash"
+ }}`)
+ mcBytes := mobileconfigForTest("N1", "I1")
+ winBytes := syncMLForTest("./Foo/Bar")
+
+ for _, p := range []struct {
+ payload []fleet.MDMProfileBatchPayload
+ expectErr string
+ }{
+ {
+ payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: winBytes}},
+ expectErr: "More than one configuration profile have the same name 'N1' (Windows .xml file name or macOS .mobileconfig PayloadDisplayName).",
+ },
+ {
+ payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}},
+ expectErr: "More than one configuration profile have the same name 'N1' (macOS .json file name or Windows .xml file name).",
+ },
+ {
+ payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}},
+ expectErr: "More than one configuration profile have the same name 'N1' (macOS .json file name or macOS .mobileconfig PayloadDisplayName).",
+ },
+ } {
+ // team profiles
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, p.expectErr)
+ // no team profiles
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity)
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, p.expectErr)
+ }
+}
+
+func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
+ t := s.T()
+ ctx := context.Background()
+
+ // create a new team
+ tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
+ require.NoError(t, err)
+
+ // apply an empty set to no-team
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
+ `{"team_id": null, "team_name": null}`,
+ 0,
+ )
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
+ `{"team_id": null, "team_name": null}`,
+ 0,
+ )
+
+ // apply to both team id and name
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
+ http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name)
+
+ // invalid team name
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
+ http.StatusNotFound, "team_name", uuid.New().String())
+
+ // duplicate PayloadDisplayName
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTest("N1", "I1"),
+ "N2": mobileconfigForTest("N1", "I2"),
+ "N3": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+
+ // profiles with reserved macOS identifiers
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTest("N1", "I1"),
+ p: mobileconfigForTest(p, p),
+ "N3": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
+ }
+
+ // payloads with reserved types
+ for p := range mobileconfig.FleetPayloadTypes() {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
+ "N3": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
+ }
+
+ // payloads with reserved identifiers
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTestWithContent("N1", "I1", p, "random", ""),
+ "N3": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
+ }
+
+ // profiles with reserved Windows location URIs
+ // bitlocker
+ res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTest("N1", "I1"),
+ syncml.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI)),
+ "N3": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg := extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
+
+ // os updates
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTest("N1", "I1"),
+ syncml.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI)),
+ "N3": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
+
+ // invalid windows tag
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N3": []byte(``),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
+
+ // invalid xml
+ res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N3": []byte(`foo`),
+ }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
+ errMsg = extractServerErrorText(res.Body)
+ require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
+
+ // successfully apply windows and macOS a profiles for the team, but it's a dry run
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTest("N1", "I1"),
+ "N2": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true")
+ s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
+ s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
+
+ // successfully apply for a team and verify activities
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
+ "N1": mobileconfigForTest("N1", "I1"),
+ "N2": syncMLForTest("./Foo/Bar"),
+ }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
+ s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
+ s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
+ 0,
+ )
+ s.lastActivityOfTypeMatches(
+ fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
+ fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
+ 0,
+ )
+}
+
+func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() {
+ s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/enrollment_profiles/manual")
+}
+
+func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() {
+ t := s.T()
+ ctx := context.Background()
+
+ checkMacProfs := func(teamID *uint, names ...string) {
+ var count int
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ var tid uint
+ if teamID != nil {
+ tid = *teamID
+ }
+ return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_apple_configuration_profiles WHERE team_id = ?`, tid)
+ })
+ require.Equal(t, len(names), count)
+ for _, n := range names {
+ s.assertMacOSConfigProfilesByName(teamID, n, true)
+ }
+ }
+
+ checkWinProfs := func(teamID *uint, names ...string) {
+ var count int
+ mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
+ var tid uint
+ if teamID != nil {
+ tid = *teamID
+ }
+ return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tid)
+ })
+ for _, n := range names {
+ s.assertWindowsConfigProfilesByName(teamID, n, true)
+ }
+ }
+
+ acResp := appConfigResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
+ require.True(t, acResp.MDM.EnabledAndConfigured)
+ require.True(t, acResp.MDM.WindowsEnabledAndConfigured)
+
+ // ensures that the fleetd profile is created
+ secrets, err := s.ds.GetEnrollSecrets(ctx, nil)
+ require.NoError(t, err)
+ if len(secrets) == 0 {
+ require.NoError(t, s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}))
+ }
+ signingCert, _, _, err := s.fleetCfg.MDM.AppleSCEP()
+ require.NoError(t, err)
+ require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger, signingCert))
+
+ // turn on disk encryption and os updates
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": {
+ "enable_disk_encryption": true,
+ "windows_updates": {
+ "deadline_days": 3,
+ "grace_period_days": 1
+ },
+ "macos_updates": {
+ "deadline": "2023-12-31",
+ "minimum_version": "13.3.7"
+ }
+ }
+ }`), http.StatusOK, &acResp)
+ checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
+ checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
+
+ // batch set only windows profiles doesn't remove the reserved names
+ newWinProfile := syncml.ForTestWithData(map[string]string{"l1": "d1"})
+ var testProfiles []fleet.MDMProfileBatchPayload
+ testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
+ Name: "n1",
+ Contents: newWinProfile,
+ })
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
+ checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
+
+ // batch set windows and mac profiles doesn't remove the reserved names
+ newMacProfile := mcBytesForTest("n2", "i2", uuid.NewString())
+ testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
+ Name: "n2",
+ Contents: newMacProfile,
+ })
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
+ checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
+
+ // batch set only mac profiles doesn't remove the reserved names
+ testProfiles = []fleet.MDMProfileBatchPayload{{
+ Name: "n2",
+ Contents: newMacProfile,
+ }}
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
+ checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
+ checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
+
+ // create a team
+ var tmResp teamResponse
+ s.DoJSON("POST", "/api/v1/fleet/teams", map[string]string{"Name": t.Name()}, http.StatusOK, &tmResp)
+
+ // edit team mdm config to turn on disk encryption and os updates
+ s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tmResp.Team.ID), modifyTeamRequest{
+ TeamPayload: fleet.TeamPayload{
+ Name: ptr.String(t.Name()),
+ MDM: &fleet.TeamPayloadMDM{
+ EnableDiskEncryption: optjson.SetBool(true),
+ WindowsUpdates: &fleet.WindowsUpdates{
+ DeadlineDays: optjson.SetInt(4),
+ GracePeriodDays: optjson.SetInt(1),
+ },
+ MacOSUpdates: &fleet.MacOSUpdates{
+ Deadline: optjson.SetString("2023-12-31"),
+ MinimumVersion: optjson.SetString("13.3.8"),
+ },
+ },
+ },
+ }, http.StatusOK, &teamResponse{})
+
+ s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/teams/%d", tmResp.Team.ID), nil, http.StatusOK, &tmResp)
+ require.True(t, tmResp.Team.Config.MDM.EnableDiskEncryption)
+ require.Equal(t, 4, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
+ require.Equal(t, 1, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
+ require.Equal(t, "2023-12-31", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
+ require.Equal(t, "13.3.8", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
+
+ require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger, signingCert))
+
+ checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
+ checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
+
+ // batch set only windows profiles doesn't remove the reserved names
+ var testTeamProfiles []fleet.MDMProfileBatchPayload
+ testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
+ Name: "n1",
+ Contents: newWinProfile,
+ })
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
+ checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
+ checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
+
+ // batch set windows and mac profiles doesn't remove the reserved names
+ testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
+ Name: "n2",
+ Contents: newMacProfile,
+ })
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
+ checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
+ checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
+
+ // batch set only mac profiles doesn't remove the reserved names
+ testTeamProfiles = []fleet.MDMProfileBatchPayload{{
+ Name: "n2",
+ Contents: newMacProfile,
+ }}
+ s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
+ checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
+ checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
+}
+
+func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() {
+ t := s.T()
+ ctx := context.Background()
+
+ testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
+ require.NoError(t, err)
+
+ testProfiles := make(map[string]fleet.MDMAppleConfigProfile)
+ generateTestProfile := func(name string, identifier string) {
+ i := identifier
+ if i == "" {
+ i = fmt.Sprintf("%s.SomeIdentifier", name)
+ }
+ cp := fleet.MDMAppleConfigProfile{
+ Name: name,
+ Identifier: i,
+ }
+ cp.Mobileconfig = mcBytesForTest(cp.Name, cp.Identifier, fmt.Sprintf("%s.UUID", name))
+ testProfiles[name] = cp
+ }
+ setTestProfileID := func(name string, id uint) {
+ tp := testProfiles[name]
+ tp.ProfileID = id
+ testProfiles[name] = tp
+ }
+
+ generateNewReq := func(name string, teamID *uint) (*bytes.Buffer, map[string]string) {
+ args := map[string][]string{}
+ if teamID != nil {
+ args["team_id"] = []string{fmt.Sprintf("%d", *teamID)}
+ }
+ return generateNewProfileMultipartRequest(t, "some_filename", testProfiles[name].Mobileconfig, s.token, args)
+ }
+
+ checkGetResponse := func(resp *http.Response, expected fleet.MDMAppleConfigProfile) {
+ // check expected headers
+ require.Contains(t, resp.Header["Content-Type"], "application/x-apple-aspen-config")
+ require.Contains(t, resp.Header["Content-Disposition"], fmt.Sprintf(`attachment;filename="%s_%s.%s"`, time.Now().Format("2006-01-02"), strings.ReplaceAll(expected.Name, " ", "_"), "mobileconfig"))
+ // check expected body
+ var bb bytes.Buffer
+ _, err = io.Copy(&bb, resp.Body)
+ require.NoError(t, err)
+ require.Equal(t, []byte(expected.Mobileconfig), bb.Bytes())
+ }
+
+ checkConfigProfile := func(expected fleet.MDMAppleConfigProfile, actual fleet.MDMAppleConfigProfile) {
+ require.Equal(t, expected.Name, actual.Name)
+ require.Equal(t, expected.Identifier, actual.Identifier)
+ }
+
+ // create new profile (no team)
+ generateTestProfile("TestNoTeam", "")
+ body, headers := generateNewReq("TestNoTeam", nil)
+ newResp := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
+ var newCP fleet.MDMAppleConfigProfile
+ err = json.NewDecoder(newResp.Body).Decode(&newCP)
+ require.NoError(t, err)
+ require.NotEmpty(t, newCP.ProfileID)
+ setTestProfileID("TestNoTeam", newCP.ProfileID)
+
+ // create new profile (with team id)
+ generateTestProfile("TestWithTeamID", "")
+ body, headers = generateNewReq("TestWithTeamID", &testTeam.ID)
+ newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
+ err = json.NewDecoder(newResp.Body).Decode(&newCP)
+ require.NoError(t, err)
+ require.NotEmpty(t, newCP.ProfileID)
+ setTestProfileID("TestWithTeamID", newCP.ProfileID)
+
+ // list profiles (no team)
+ expectedCP := testProfiles["TestNoTeam"]
+ var listResp listMDMAppleConfigProfilesResponse
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
+ require.Len(t, listResp.ConfigProfiles, 1)
+ respCP := listResp.ConfigProfiles[0]
+ require.Equal(t, expectedCP.Name, respCP.Name)
+ checkConfigProfile(expectedCP, *respCP)
+ require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
+ require.Empty(t, respCP.TeamID) // zero means no team
+
+ // list profiles (team 1)
+ expectedCP = testProfiles["TestWithTeamID"]
+ listResp = listMDMAppleConfigProfilesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
+ require.Len(t, listResp.ConfigProfiles, 1)
+ respCP = listResp.ConfigProfiles[0]
+ require.Equal(t, expectedCP.Name, respCP.Name)
+ checkConfigProfile(expectedCP, *respCP)
+ require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
+ require.Equal(t, testTeam.ID, *respCP.TeamID) // team 1
+
+ // get profile (no team)
+ expectedCP = testProfiles["TestNoTeam"]
+ getPath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
+ getResp := s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
+ checkGetResponse(getResp, expectedCP)
+
+ // get profile (team 1)
+ expectedCP = testProfiles["TestWithTeamID"]
+ getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
+ getResp = s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
+ checkGetResponse(getResp, expectedCP)
+
+ // delete profile (no team)
+ deletedCP := testProfiles["TestNoTeam"]
+ deletePath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
+ var deleteResp deleteMDMAppleConfigProfileResponse
+ s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
+ // confirm deleted
+ listResp = listMDMAppleConfigProfilesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{}, http.StatusOK, &listResp)
+ require.Len(t, listResp.ConfigProfiles, 0)
+ getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
+ _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
+
+ // delete profile (team 1)
+ deletedCP = testProfiles["TestWithTeamID"]
+ deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
+ deleteResp = deleteMDMAppleConfigProfileResponse{}
+ s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
+ // confirm deleted
+ listResp = listMDMAppleConfigProfilesResponse{}
+ s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
+ require.Len(t, listResp.ConfigProfiles, 0)
+ getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
+ _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
+
+ // trying to add/delete profiles with identifiers managed by Fleet fails
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ generateTestProfile("TestNoTeam", p)
+ body, headers := generateNewReq("TestNoTeam", nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+
+ generateTestProfile("TestWithTeamID", p)
+ body, headers = generateNewReq("TestWithTeamID", nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+ cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
+ require.NoError(t, err)
+ testProfiles["WithContent"] = *cp
+ body, headers = generateNewReq("WithContent", nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+ }
+
+ // trying to add profiles with identifiers managed by Fleet fails
+ for p := range mobileconfig.FleetPayloadIdentifiers() {
+ generateTestProfile("TestNoTeam", p)
+ body, headers := generateNewReq("TestNoTeam", nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+
+ generateTestProfile("TestWithTeamID", p)
+ body, headers = generateNewReq("TestWithTeamID", nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+ cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
+ require.NoError(t, err)
+ testProfiles["WithContent"] = *cp
+ body, headers = generateNewReq("WithContent", nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+ }
+
+ // trying to add profiles with names reserved by Fleet fails
+ for name := range servermdm.FleetReservedProfileNames() {
+ cp := &fleet.MDMAppleConfigProfile{
+ Name: name,
+ Identifier: "valid.identifier",
+ Mobileconfig: mcBytesForTest(name, "valid.identifier", "some-uuid"),
+ }
+ body, headers := generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+
+ body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
+ "team_id": {fmt.Sprintf("%d", testTeam.ID)},
+ })
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+
+ cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent(
+ "valid outer name",
+ "valid.outer.identifier",
+ "valid.inner.identifer",
+ "some-uuid",
+ name,
+ ), nil)
+ require.NoError(t, err)
+ body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+
+ cp.TeamID = &testTeam.ID
+ body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
+ "team_id": {fmt.Sprintf("%d", testTeam.ID)},
+ })
+
+ s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
+ }
+
+ // make fleet add a FileVault profile
+ acResp := appConfigResponse{}
+ s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
+ "mdm": { "enable_disk_encryption": true }
+ }`), http.StatusOK, &acResp)
+ assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
+ profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
+
+ // try to delete the profile
+ deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", profile.ProfileID)
+ deleteResp = deleteMDMAppleConfigProfileResponse{}
+ s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp)
+}
diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go
index 139604ad9b..90cb5fad04 100644
--- a/server/service/integration_mdm_test.go
+++ b/server/service/integration_mdm_test.go
@@ -3,7 +3,6 @@ package service
import (
"bytes"
"context"
- "crypto/md5" // nolint:gosec // used only for tests
"crypto/x509"
"database/sql"
"encoding/base64"
@@ -128,7 +127,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
pushFactory,
NewNanoMDMLogger(pushLog),
)
- mdmCommander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
+ mdmCommander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, fleetCfg.MDM)
redisPool := redistest.SetupRedis(s.T(), "zz", false, false, false)
s.withServer.lq = live_query_mock.New(s.T())
@@ -213,7 +212,9 @@ func (s *integrationMDMTestSuite) SetupSuite() {
if s.onProfileJobDone != nil {
s.onProfileJobDone()
}
- err := ReconcileAppleProfiles(ctx, ds, mdmCommander, logger)
+ signingCert, _, _, err := fleetCfg.MDM.AppleSCEP()
+ require.NoError(s.T(), err)
+ err = ReconcileAppleProfiles(ctx, ds, mdmCommander, logger, signingCert)
require.NoError(s.T(), err)
return err
}),
@@ -592,880 +593,7 @@ func (s *integrationMDMTestSuite) TestABMExpiredToken() {
require.False(t, config.MDM.AppleBMTermsExpired)
}
-func (s *integrationMDMTestSuite) TestAppleProfileManagement() {
- t := s.T()
- ctx := context.Background()
-
- err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
- require.NoError(t, err)
-
- globalProfiles := [][]byte{
- mobileconfigForTest("N1", "I1"),
- mobileconfigForTest("N2", "I2"),
- }
- wantGlobalProfiles := append(globalProfiles, setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil))
-
- // add global profiles
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
-
- // create a new team
- tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
- require.NoError(t, err)
-
- // add an enroll secret so the fleetd profiles differ
- var teamResp teamEnrollSecretsResponse
- s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm.ID),
- modifyTeamEnrollSecretsRequest{
- Secrets: []fleet.EnrollSecret{{Secret: "team1_enroll_sec"}},
- }, http.StatusOK, &teamResp)
-
- teamProfiles := [][]byte{
- mobileconfigForTest("N3", "I3"),
- }
- wantTeamProfiles := append(teamProfiles, setupExpectedFleetdProfile(t, s.server.URL, "team1_enroll_sec", &tm.ID))
- // add profiles to the team
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
-
- // create a non-macOS host
- _, err = s.ds.NewHost(context.Background(), &fleet.Host{
- ID: 1,
- OsqueryHostID: ptr.String("non-macos-host"),
- NodeKey: ptr.String("non-macos-host"),
- UUID: uuid.New().String(),
- Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()),
- Platform: "windows",
- })
- require.NoError(t, err)
-
- // create a host that's not enrolled into MDM
- _, err = s.ds.NewHost(context.Background(), &fleet.Host{
- ID: 2,
- OsqueryHostID: ptr.String("not-mdm-enrolled"),
- NodeKey: ptr.String("not-mdm-enrolled"),
- UUID: uuid.New().String(),
- Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
- Platform: "darwin",
- })
- require.NoError(t, err)
-
- // Create a host and then enroll to MDM.
- host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
- setupPusher(s, t, mdmDevice)
-
- // trigger a profile sync
- s.awaitTriggerProfileSchedule(t)
- installs, removes := checkNextPayloads(t, mdmDevice, false)
- // verify that we received all profiles
- require.ElementsMatch(t, wantGlobalProfiles, installs)
- require.Empty(t, removes)
-
- expectedNoTeamSummary := fleet.MDMProfilesSummary{
- Pending: 0,
- Failed: 0,
- Verifying: 1,
- Verified: 0,
- }
- expectedTeamSummary := fleet.MDMProfilesSummary{}
- s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary)
- s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // empty because no hosts in team
-
- // add the host to a team
- err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
- require.NoError(t, err)
-
- // trigger a profile sync
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- // verify that we should install the team profile
- require.ElementsMatch(t, wantTeamProfiles, installs)
- // verify that we should delete both profiles
- require.ElementsMatch(t, []string{"I1", "I2"}, removes)
-
- expectedNoTeamSummary = fleet.MDMProfilesSummary{}
- expectedTeamSummary = fleet.MDMProfilesSummary{
- Pending: 0,
- Failed: 0,
- Verifying: 1,
- Verified: 0,
- }
- s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
- s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host now verifying team profiles
-
- // set new team profiles (delete + addition)
- teamProfiles = [][]byte{
- mobileconfigForTest("N4", "I4"),
- mobileconfigForTest("N5", "I5"),
- }
- wantTeamProfiles = teamProfiles
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
-
- // trigger a profile sync
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- // verify that we should install the team profiles
- require.ElementsMatch(t, wantTeamProfiles, installs)
- // verify that we should delete the old team profiles
- require.ElementsMatch(t, []string{"I3"}, removes)
-
- s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
- s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
-
- // with no changes
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
-
- var hostResp getHostResponse
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
- require.NotEmpty(t, hostResp.Host.MDM.Profiles)
- resProfiles := *hostResp.Host.MDM.Profiles
- // one extra profile for the fleetd config
- require.Len(t, resProfiles, len(wantTeamProfiles)+1)
-
- s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred
- s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles
-
- // add a new profile to the team
- mcUUID := "a" + uuid.NewString()
- prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID)
- wantTeamProfiles = append(wantTeamProfiles, prof)
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
- _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID))
- return err
- })
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Len(t, installs, 1)
- require.Equal(t, prof, installs[0])
- require.Empty(t, removes)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
-
- // can't resend profile while verifying
- res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
-
- // set the profile to pending, can't resend
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID)
- return err
- })
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil)
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
-
- // set the profile to failed, can resend
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID)
- return err
- })
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil)
- _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted)
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Len(t, installs, 1)
- require.Equal(t, prof, installs[0])
- require.Empty(t, removes)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
-
- // can't resend profile while verifying
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusConflict)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
-
- // set the profile to verified, can resend
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID)
- return err
- })
- _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusAccepted)
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Len(t, installs, 1)
- require.Equal(t, prof, installs[0])
- require.Empty(t, removes)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
- s.lastActivityMatches(
- fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
- fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID),
- 0)
-
- // add a declaration to the team
- declIdent := "decl-ident-" + uuid.NewString()
- fields := map[string][]string{
- "team_id": {fmt.Sprintf("%d", tm.ID)},
- }
- body, headers := generateNewProfileMultipartRequest(
- t, "some-declaration.json", declarationForTest(declIdent), s.token, fields,
- )
- res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers)
- var resp newMDMConfigProfileResponse
- err = json.NewDecoder(res.Body).Decode(&resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.ProfileUUID)
- require.Equal(t, "d", string(resp.ProfileUUID[0]))
- declUUID := resp.ProfileUUID
-
- checkDDMSync := func(d *mdmtest.TestAppleMDMClient) {
- require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger))
- cmd, err := d.Idle()
- require.NoError(t, err)
- require.NotNil(t, cmd)
- require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType)
- cmd, err = d.Acknowledge(cmd.CommandUUID)
- require.NoError(t, err)
- require.Nil(t, cmd, fmt.Sprintf("expected no more commands, but got: %+v", cmd))
- _, err = d.DeclarativeManagement("tokens")
- require.NoError(t, err)
- }
- checkDDMSync(mdmDevice)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
-
- // can't resend declaration while verifying
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusConflict)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
-
- // set the declaration to verified, can resend
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID)
- return err
- })
- _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, declUUID), nil, http.StatusAccepted)
- checkDDMSync(mdmDevice)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil)
- s.lastActivityMatches(
- fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
- fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": "some-declaration"}`, host.ID, host.DisplayName()),
- 0)
-
- // transfer the host to the global team
- err = s.ds.AddHostsToTeam(ctx, nil, []uint{host.ID})
- require.NoError(t, err)
-
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Len(t, installs, len(wantGlobalProfiles))
- require.ElementsMatch(t, wantGlobalProfiles, installs)
- require.Len(t, removes, len(wantTeamProfiles))
- expectedNoTeamSummary = fleet.MDMProfilesSummary{Verifying: 1}
- expectedTeamSummary = fleet.MDMProfilesSummary{}
- s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // host now verifying global profiles
- s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
-
- // can't resend profile from another team
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusNotFound)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Unable to match profile to host")
-
- // add a Windows profile, resend not supported when host is macOS
- wpUUID := mysql.InsertWindowsProfileForTest(t, s.ds, 0)
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, wpUUID), nil, http.StatusUnprocessableEntity)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Profile is not compatible with host platform")
-
- // invalid profile UUID prefix should return 404
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Invalid profile UUID prefix")
-
- // set OS updates settings for no-team and team, should not change the
- // summaries as this profile is ignored.
- s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": {
- "macos_updates": {
- "deadline": "2023-12-31",
- "minimum_version": "13.3.7"
- }
- }
- }`), http.StatusOK)
- s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), fleet.TeamPayload{
- MDM: &fleet.TeamPayloadMDM{
- MacOSUpdates: &fleet.MacOSUpdates{
- Deadline: optjson.SetString("1992-01-01"),
- MinimumVersion: optjson.SetString("13.1.1"),
- },
- },
- }, http.StatusOK)
- s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary)
- s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary)
-
- // it should also not show up in the host's profiles list
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp)
- require.NotEmpty(t, hostResp.Host.MDM.Profiles)
- resProfiles = *hostResp.Host.MDM.Profiles
- // one extra profile for the fleetd config
- require.Len(t, resProfiles, len(wantGlobalProfiles)+1)
-}
-
-func (s *integrationMDMTestSuite) TestAppleProfileRetries() {
- t := s.T()
- ctx := context.Background()
-
- enrollSecret := "test-profile-retries-secret"
- err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}})
- require.NoError(t, err)
-
- testProfiles := [][]byte{
- mobileconfigForTest("N1", "I1"),
- mobileconfigForTest("N2", "I2"),
- }
- initialExpectedProfiles := append(testProfiles, setupExpectedFleetdProfile(t, s.server.URL, enrollSecret, nil))
-
- h, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t)
- setupPusher(s, t, mdmDevice)
-
- expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
- "I1": fleet.MDMDeliveryVerifying,
- "I2": fleet.MDMDeliveryVerifying,
- mobileconfig.FleetdConfigPayloadIdentifier: fleet.MDMDeliveryVerifying,
- }
- checkProfilesStatus := func(t *testing.T) {
- storedProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, h.UUID)
- require.NoError(t, err)
- require.Len(t, storedProfs, len(expectedProfileStatuses))
- for _, p := range storedProfs {
- want, ok := expectedProfileStatuses[p.Identifier]
- require.True(t, ok, "unexpected profile: %s", p.Identifier)
- require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Identifier)
- }
- }
-
- expectedRetryCounts := map[string]uint{
- "I1": 0,
- "I2": 0,
- mobileconfig.FleetdConfigPayloadIdentifier: 0,
- }
- checkRetryCounts := func(t *testing.T) {
- counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h)
- require.NoError(t, err)
- require.Len(t, counts, len(expectedRetryCounts))
- for _, c := range counts {
- want, ok := expectedRetryCounts[c.ProfileIdentifier]
- require.True(t, ok, "unexpected profile: %s", c.ProfileIdentifier)
- require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileIdentifier)
- }
- }
-
- hostProfsByIdent := map[string]*fleet.HostMacOSProfile{
- "I1": {
- Identifier: "I1",
- DisplayName: "N1",
- InstallDate: time.Now().Add(15 * time.Minute),
- },
- "I2": {
- Identifier: "I2",
- DisplayName: "N2",
- InstallDate: time.Now().Add(15 * time.Minute),
- },
- mobileconfig.FleetdConfigPayloadIdentifier: {
- Identifier: mobileconfig.FleetdConfigPayloadIdentifier,
- DisplayName: "Fleetd configuration",
- InstallDate: time.Now().Add(15 * time.Minute),
- },
- }
- reportHostProfs := func(t *testing.T, identifiers ...string) {
- report := make(map[string]*fleet.HostMacOSProfile, len(hostProfsByIdent))
- for _, ident := range identifiers {
- report[ident] = hostProfsByIdent[ident]
- }
- require.NoError(t, apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, report))
- }
-
- setProfileUploadedAt := func(t *testing.T, uploadedAt time.Time, identifiers ...interface{}) {
- bindVars := strings.TrimSuffix(strings.Repeat("?, ", len(identifiers)), ", ")
- stmt := fmt.Sprintf("UPDATE mdm_apple_configuration_profiles SET uploaded_at = ? WHERE identifier IN(%s)", bindVars)
- args := append([]interface{}{uploadedAt}, identifiers...)
- mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
- _, err := tx.ExecContext(ctx, stmt, args...)
- return err
- })
- }
-
- t.Run("retry after verifying", func(t *testing.T) {
- // upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier)
-
- // trigger initial profile sync and confirm that we received all profiles
- s.awaitTriggerProfileSchedule(t)
- installs, removes := checkNextPayloads(t, mdmDevice, false)
- require.ElementsMatch(t, initialExpectedProfiles, installs)
- require.Empty(t, removes)
-
- checkProfilesStatus(t) // all profiles verifying
- checkRetryCounts(t) // no retries yet
-
- // report osquery results with I2 missing and confirm I2 marked as pending and other profiles are marked as verified
- reportHostProfs(t, "I1", mobileconfig.FleetdConfigPayloadIdentifier)
- expectedProfileStatuses["I2"] = fleet.MDMDeliveryPending
- expectedProfileStatuses["I1"] = fleet.MDMDeliveryVerified
- expectedProfileStatuses[mobileconfig.FleetdConfigPayloadIdentifier] = fleet.MDMDeliveryVerified
- checkProfilesStatus(t)
- expectedRetryCounts["I2"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for I2 was resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.ElementsMatch(t, [][]byte{initialExpectedProfiles[1]}, installs)
- require.Empty(t, removes)
-
- // report osquery results with I2 present and confirm that all profiles are verified
- reportHostProfs(t, "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier)
- expectedProfileStatuses["I2"] = fleet.MDMDeliveryVerified
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that no profiles were sent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
- })
-
- t.Run("retry after verification", func(t *testing.T) {
- // report osquery results with I1 missing and confirm that the I1 marked as pending (initial retry)
- reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
- expectedProfileStatuses["I1"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["I1"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for I1 was resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes := checkNextPayloads(t, mdmDevice, false)
- require.ElementsMatch(t, [][]byte{initialExpectedProfiles[0]}, installs)
- require.Empty(t, removes)
-
- // report osquery results with I1 missing again and confirm that the I1 marked as failed (max retries exceeded)
- reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
- expectedProfileStatuses["I1"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for I1 was not resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
- })
-
- t.Run("retry after device error", func(t *testing.T) {
- // add another profile and set the updated_at timestamp back by 48 hours
- newProfile := mobileconfigForTest("N3", "I3")
- testProfiles = append(testProfiles, newProfile)
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3")
-
- // trigger a profile sync and confirm that the install profile command for I3 was sent and
- // simulate a device error
- s.awaitTriggerProfileSchedule(t)
- installs, removes := checkNextPayloads(t, mdmDevice, true)
- require.ElementsMatch(t, [][]byte{newProfile}, installs)
- require.Empty(t, removes)
- expectedProfileStatuses["I3"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["I3"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for I3 was sent and
- // simulate a device ack
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.ElementsMatch(t, [][]byte{newProfile}, installs)
- require.Empty(t, removes)
- expectedProfileStatuses["I3"] = fleet.MDMDeliveryVerifying
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // report osquery results with I3 missing and confirm that the I3 marked as failed (max
- // retries exceeded)
- reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
- expectedProfileStatuses["I3"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for I3 was not resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
- })
-
- t.Run("repeated device error", func(t *testing.T) {
- // add another profile and set the updated_at timestamp back by 48 hours
- newProfile := mobileconfigForTest("N4", "I4")
- testProfiles = append(testProfiles, newProfile)
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4")
-
- // trigger a profile sync and confirm that the install profile command for I3 was sent and
- // simulate a device error
- s.awaitTriggerProfileSchedule(t)
- installs, removes := checkNextPayloads(t, mdmDevice, true)
- require.ElementsMatch(t, [][]byte{newProfile}, installs)
- require.Empty(t, removes)
- expectedProfileStatuses["I4"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["I4"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for I4 was sent and
- // simulate a second device error
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, true)
- require.ElementsMatch(t, [][]byte{newProfile}, installs)
- require.Empty(t, removes)
- expectedProfileStatuses["I4"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for I3 was not resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
- })
-
- t.Run("retry count does not reset", func(t *testing.T) {
- // add another profile and set the updated_at timestamp back by 48 hours
- newProfile := mobileconfigForTest("N5", "I5")
- testProfiles = append(testProfiles, newProfile)
- hostProfsByIdent["I5"] = &fleet.HostMacOSProfile{Identifier: "I5", DisplayName: "N5", InstallDate: time.Now()}
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4", "I5")
-
- // trigger a profile sync and confirm that the install profile command for I3 was sent and
- // simulate a device error
- s.awaitTriggerProfileSchedule(t)
- installs, removes := checkNextPayloads(t, mdmDevice, true)
- require.ElementsMatch(t, [][]byte{newProfile}, installs)
- require.Empty(t, removes)
- expectedProfileStatuses["I5"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["I5"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for I5 was sent and
- // simulate a device ack
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.ElementsMatch(t, [][]byte{newProfile}, installs)
- require.Empty(t, removes)
- expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerifying
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // report osquery results with I5 found and confirm that the I5 marked as verified
- reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I5")
- expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerified
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for I5 was not resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
-
- // report osquery results again, this time I5 is missing and confirm that the I5 marked as
- // failed (max retries exceeded)
- reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier)
- expectedProfileStatuses["I5"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for I5 was not resent
- s.awaitTriggerProfileSchedule(t)
- installs, removes = checkNextPayloads(t, mdmDevice, false)
- require.Empty(t, installs)
- require.Empty(t, removes)
- })
-}
-
-func (s *integrationMDMTestSuite) TestWindowsProfileRetries() {
- t := s.T()
- ctx := context.Background()
-
- testProfiles := []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: syncml.ForTestWithData(map[string]string{"L1": "D1"})},
- {Name: "N2", Contents: syncml.ForTestWithData(map[string]string{"L2": "D2", "L3": "D3"})},
- }
-
- h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
-
- expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{
- "N1": fleet.MDMDeliveryVerifying,
- "N2": fleet.MDMDeliveryVerifying,
- }
- checkProfilesStatus := func(t *testing.T) {
- storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID)
- require.NoError(t, err)
- require.Len(t, storedProfs, len(expectedProfileStatuses))
- for _, p := range storedProfs {
- want, ok := expectedProfileStatuses[p.Name]
- require.True(t, ok, "unexpected profile: %s", p.Name)
- require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name)
- }
- }
-
- expectedRetryCounts := map[string]uint{
- "N1": 0,
- "N2": 0,
- }
- checkRetryCounts := func(t *testing.T) {
- counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h)
- require.NoError(t, err)
- require.Len(t, counts, len(expectedRetryCounts))
- for _, c := range counts {
- want, ok := expectedRetryCounts[c.ProfileName]
- require.True(t, ok, "unexpected profile: %s", c.ProfileName)
- require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileName)
- }
- }
-
- type profileData struct {
- Status string
- LocURI string
- Data string
- }
- hostProfileReports := map[string][]profileData{
- "N1": {{"200", "L1", "D1"}},
- "N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}},
- }
- reportHostProfs := func(t *testing.T, profileNames ...string) {
- var responseOps []*fleet.SyncMLCmd
- for _, profileName := range profileNames {
- report, ok := hostProfileReports[profileName]
- require.True(t, ok)
-
- for _, p := range report {
- ref := microsoft_mdm.HashLocURI(profileName, p.LocURI)
- responseOps = append(responseOps, &fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdStatus},
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- CmdRef: &ref,
- Data: ptr.String(p.Status),
- })
-
- // the protocol can respond with only a `Status`
- // command if the status failed
- if p.Status != "200" || p.Data != "" {
- responseOps = append(responseOps, &fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdResults},
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- CmdRef: &ref,
- Items: []fleet.CmdItem{
- {Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}},
- },
- })
- }
- }
- }
-
- msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps)
- require.NoError(t, err)
- out, err := xml.Marshal(msg)
- require.NoError(t, err)
- require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, out))
- }
-
- verifyCommands := func(wantProfileInstalls int, status string) {
- s.awaitTriggerProfileSchedule(t)
- cmds, err := mdmDevice.StartManagementSession()
- require.NoError(t, err)
- // profile installs + 2 protocol commands acks
- require.Len(t, cmds, wantProfileInstalls+2)
- msgID, err := mdmDevice.GetCurrentMsgID()
- require.NoError(t, err)
- atomicCmds := 0
- for _, c := range cmds {
- if c.Verb == "Atomic" {
- atomicCmds++
- }
- mdmDevice.AppendResponse(fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdStatus},
- MsgRef: &msgID,
- CmdRef: ptr.String(c.Cmd.CmdID.Value),
- Cmd: ptr.String(c.Verb),
- Data: ptr.String(status),
- Items: nil,
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- })
- }
- require.Equal(t, wantProfileInstalls, atomicCmds)
- cmds, err = mdmDevice.SendResponse()
- require.NoError(t, err)
- // the ack of the message should be the only returned command
- require.Len(t, cmds, 1)
- }
-
- t.Run("retry after verifying", func(t *testing.T) {
- // upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- // profiles to install + 2 boilerplate
- verifyCommands(len(testProfiles), syncml.CmdStatusOK)
- checkProfilesStatus(t) // all profiles verifying
- checkRetryCounts(t) // no retries yet
-
- // report osquery results with N2 missing and confirm N2 marked
- // as verifying and other profiles are marked as verified
- reportHostProfs(t, "N1")
- expectedProfileStatuses["N2"] = fleet.MDMDeliveryPending
- expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified
- checkProfilesStatus(t)
- expectedRetryCounts["N2"] = 1
- checkRetryCounts(t)
-
- // report osquery results with N2 present and confirm that all profiles are verified
- verifyCommands(1, syncml.CmdStatusOK)
- reportHostProfs(t, "N1", "N2")
- expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that no profiles were sent
- verifyCommands(0, syncml.CmdStatusOK)
- })
-
- t.Run("retry after verification", func(t *testing.T) {
- // report osquery results with N1 missing and confirm that the N1 marked as pending (initial retry)
- reportHostProfs(t, "N2")
- expectedProfileStatuses["N1"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["N1"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for N1 was resent
- verifyCommands(1, syncml.CmdStatusOK)
-
- // report osquery results with N1 missing again and confirm that the N1 marked as failed (max retries exceeded)
- reportHostProfs(t, "N2")
- expectedProfileStatuses["N1"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for N1 was not resent
- verifyCommands(0, syncml.CmdStatusOK)
- })
-
- t.Run("retry after device error", func(t *testing.T) {
- // add another profile
- newProfile := syncml.ForTestWithData(map[string]string{"L3": "D3"})
- testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
- Name: "N3",
- Contents: newProfile,
- })
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- // trigger a profile sync and confirm that the install profile command for N3 was sent and
- // simulate a device error
- verifyCommands(1, syncml.CmdStatusAtomicFailed)
- expectedProfileStatuses["N3"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["N3"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile command for N3 was sent and
- // simulate a device ack
- verifyCommands(1, syncml.CmdStatusOK)
- expectedProfileStatuses["N3"] = fleet.MDMDeliveryVerifying
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // report osquery results with N3 missing and confirm that the N3 marked as failed (max
- // retries exceeded)
- reportHostProfs(t, "N2")
- expectedProfileStatuses["N3"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for N3 was not resent
- verifyCommands(0, syncml.CmdStatusOK)
- })
-
- t.Run("repeated device error", func(t *testing.T) {
- // add another profile
- testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
- Name: "N4",
- Contents: syncml.ForTestWithData(map[string]string{"L4": "D4"}),
- })
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- // trigger a profile sync and confirm that the install profile command for N4 was sent and
- // simulate a device error
- verifyCommands(1, syncml.CmdStatusAtomicFailed)
- expectedProfileStatuses["N4"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["N4"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile
- // command for N4 was sent and simulate a second device error
- verifyCommands(1, syncml.CmdStatusAtomicFailed)
- expectedProfileStatuses["N4"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile
- // command for N4 was not resent
- verifyCommands(0, syncml.CmdStatusOK)
- })
-
- t.Run("retry count does not reset", func(t *testing.T) {
- // add another profile
- testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
- Name: "N5",
- Contents: syncml.ForTestWithData(map[string]string{"L5": "D5"}),
- })
- // hostProfsByIdent["N5"] = &fleet.HostMacOSProfile{Identifier: "N5", DisplayName: "N5", InstallDate: time.Now()}
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- // trigger a profile sync and confirm that the install profile
- // command for N5 was sent and simulate a device error
- verifyCommands(1, syncml.CmdStatusAtomicFailed)
- expectedProfileStatuses["N5"] = fleet.MDMDeliveryPending
- checkProfilesStatus(t)
- expectedRetryCounts["N5"] = 1
- checkRetryCounts(t)
-
- // trigger a profile sync and confirm that the install profile
- // command for N5 was sent and simulate a device ack
- verifyCommands(1, syncml.CmdStatusOK)
- expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerifying
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // report osquery results with N5 found and confirm that the N5 marked as verified
- hostProfileReports["N5"] = []profileData{{"200", "L5", "D5"}}
- reportHostProfs(t, "N2", "N5")
- expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerified
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for N5 was not resent
- verifyCommands(0, syncml.CmdStatusOK)
-
- // report osquery results again, this time N5 is missing and confirm that the N5 marked as
- // failed (max retries exceeded)
- reportHostProfs(t, "N2")
- expectedProfileStatuses["N5"] = fleet.MDMDeliveryFailed
- checkProfilesStatus(t)
- checkRetryCounts(t) // unchanged
-
- // trigger a profile sync and confirm that the install profile command for N5 was not resent
- verifyCommands(0, syncml.CmdStatusOK)
- })
-}
-
func checkNextPayloads(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, forceDeviceErr bool) ([][]byte, []string) {
- var cmd *mdm.Command
- var err error
installs := [][]byte{}
removes := []string{}
@@ -1473,22 +601,9 @@ func checkNextPayloads(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, forc
// ping the server via idle
// if after idle or acknowledge cmd is still nil, it
// means there aren't any commands left to run
- for {
- if cmd == nil {
- cmd, err = mdmDevice.Idle()
- } else {
- if forceDeviceErr {
- cmd, err = mdmDevice.Err(cmd.CommandUUID, []mdm.ErrorChain{})
- } else {
- cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
- }
- }
- require.NoError(t, err)
-
- if cmd == nil {
- break
- }
-
+ cmd, err := mdmDevice.Idle()
+ require.NoError(t, err)
+ for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
switch cmd.Command.RequestType {
@@ -1498,7 +613,16 @@ func checkNextPayloads(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, forc
removes = append(removes, fullCmd.Command.RemoveProfile.Identifier)
}
+
+ if forceDeviceErr {
+ cmd, err = mdmDevice.Err(cmd.CommandUUID, []mdm.ErrorChain{})
+ } else {
+ cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
+ }
+
+ require.NoError(t, err)
}
+
return installs, removes
}
@@ -1515,6 +639,20 @@ func setupExpectedFleetdProfile(t *testing.T, serverURL string, enrollSecret str
return b.Bytes()
}
+func setupExpectedCAProfile(t *testing.T, cfg config.MDMConfig) []byte {
+ cert, _, _, err := cfg.AppleSCEP()
+ require.NoError(t, err)
+ var b bytes.Buffer
+ params := mobileconfig.FleetCARootTemplateOptions{
+ PayloadName: servermdm.FleetCAConfigProfileName,
+ PayloadIdentifier: mobileconfig.FleetCARootConfigPayloadIdentifier,
+ Certificate: base64.StdEncoding.EncodeToString(cert.Certificate[0]),
+ }
+ err = mobileconfig.FleetCARootTemplate.Execute(&b, params)
+ require.NoError(t, err)
+ return b.Bytes()
+}
+
func setupPusher(s *integrationMDMTestSuite, t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient) {
origPush := s.pushProvider.PushFunc
s.pushProvider.PushFunc = func(pushes []*mdm.Push) (map[string]*push.Response, error) {
@@ -1531,646 +669,6 @@ func setupPusher(s *integrationMDMTestSuite, t *testing.T, mdmDevice *mdmtest.Te
t.Cleanup(func() { s.pushProvider.PushFunc = origPush })
}
-func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() {
- ctx := context.Background()
- t := s.T()
-
- // Use a gitops user for all Puppet actions
- u := &fleet.User{
- Name: "GitOps",
- Email: "gitops-TestPuppetMatchPreassignProfiles@example.com",
- GlobalRole: ptr.String(fleet.RoleGitOps),
- }
- require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
- _, err := s.ds.NewUser(context.Background(), u)
- require.NoError(t, err)
- s.setTokenForTest(t, "gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword)
-
- runWithAdminToken := func(cb func()) {
- s.token = s.getTestAdminToken()
- cb()
- s.token = s.getCachedUserToken("gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword)
- }
-
- // create a host enrolled in fleet
- mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
-
- // create a host that's not enrolled into MDM
- nonMDMHost, err := s.ds.NewHost(context.Background(), &fleet.Host{
- OsqueryHostID: ptr.String("not-mdm-enrolled"),
- NodeKey: ptr.String("not-mdm-enrolled"),
- UUID: uuid.New().String(),
- Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
- Platform: "darwin",
- })
- require.NoError(t, err)
-
- // create a setup assistant for no team, for this we need to:
- // 1. mock the ABM API, as it gets called to set the profile
- // 2. run the DEP schedule, as this registers the default profile
- s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
- }))
- s.runDEPSchedule()
- noTeamProf := `{"x": 1}`
- var globalAsstResp createMDMAppleSetupAssistantResponse
- s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{
- TeamID: nil,
- Name: "no-team",
- EnrollmentProfile: json.RawMessage(noTeamProf),
- }, http.StatusOK, &globalAsstResp)
-
- // set the global Enable Release Device manually setting to true,
- // will be inherited by teams created via preassign/match.
- s.Do("PATCH", "/api/latest/fleet/setup_experience",
- json.RawMessage(jsonMustMarshal(t, map[string]any{"enable_release_device_manually": true})),
- http.StatusNoContent)
-
- s.runWorker()
-
- // preassign an empty profile, fails
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "empty", HostUUID: nonMDMHost.UUID, Profile: nil}}, http.StatusUnprocessableEntity)
-
- // preassign a valid profile to the MDM host
- prof1 := mobileconfigForTest("n1", "i1")
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof1}}, http.StatusNoContent)
-
- // preassign another valid profile to the MDM host
- prof2 := mobileconfigForTest("n2", "i2")
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof2, Group: "g1"}}, http.StatusNoContent)
-
- // preassign a valid profile to the non-MDM host, still works as the host is not validated in this call
- prof3 := mobileconfigForTest("n3", "i3")
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "non-mdm", HostUUID: nonMDMHost.UUID, Profile: prof3, Group: "g2"}}, http.StatusNoContent)
-
- // match with an invalid external host id, succeeds as it is the same as if
- // there was no matching to do (no preassignment was done)
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "no-such-id"}, http.StatusNoContent)
-
- // match with the non-mdm host fails
- res := s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "non-mdm"}, http.StatusBadRequest)
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "host is not enrolled in Fleet MDM")
-
- // match with the mdm host succeeds and creates a team based on the group labels
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm1"}, http.StatusNoContent)
-
- // the host is now part of that team
- h, err := s.ds.Host(ctx, mdmHost.ID)
- require.NoError(t, err)
- require.NotNil(t, h.TeamID)
- tm1, err := s.ds.Team(ctx, *h.TeamID)
- require.NoError(t, err)
- require.Equal(t, "g1", tm1.Name)
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
- require.True(t, tm1.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
-
- runWithAdminToken(func() {
- // it create activities for the new team, the profiles assigned to it,
- // the host moved to it, and setup assistant
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeCreatedTeam{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name),
- 0)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name),
- 0)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeTransferredHostsToTeam{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q, "host_ids": [%d], "host_display_names": [%q]}`,
- tm1.ID, tm1.Name, h.ID, h.DisplayName()),
- 0)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "name": %q, "team_name": %q}`,
- tm1.ID, globalAsstResp.Name, tm1.Name),
- 0)
- })
-
- // and the team has the expected profiles (prof1 and prof2)
- profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
- require.NoError(t, err)
- require.Len(t, profs, 2)
- // order is guaranteed by profile name
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- // setup assistant settings are copyied from "no team"
- teamAsst, err := s.ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID)
- require.NoError(t, err)
- require.Equal(t, globalAsstResp.Name, teamAsst.Name)
- require.JSONEq(t, string(globalAsstResp.Profile), string(teamAsst.Profile))
-
- // trigger the schedule so profiles are set in their state
- s.awaitTriggerProfileSchedule(t)
- s.runWorker()
-
- // the mdm host has the same profiles (i1, i2, plus fleetd config and disk encryption)
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- mdmHost: {
- {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "i2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- })
-
- // create a team and set profiles to it (note that it doesn't have disk encryption enabled)
- tm2, err := s.ds.NewTeam(context.Background(), &fleet.Team{
- Name: "g1 - g4",
- Secrets: []*fleet.EnrollSecret{{Secret: "tm2secret"}},
- })
- require.NoError(t, err)
- prof4 := mobileconfigForTest("n4", "i4")
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
- prof1, prof4,
- }}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID))
- // tm2 has disk encryption and release device manually disabled
- require.False(t, tm2.Config.MDM.EnableDiskEncryption)
- require.False(t, tm2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
-
- // create another team with a superset of profiles
- tm3, err := s.ds.NewTeam(context.Background(), &fleet.Team{
- Name: "team3_" + t.Name(),
- Secrets: []*fleet.EnrollSecret{{Secret: "tm3secret"}},
- })
- require.NoError(t, err)
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
- prof1, prof2, prof4,
- }}, http.StatusNoContent, "team_id", fmt.Sprint(tm3.ID))
-
- // and yet another team with the same profiles as tm3
- tm4, err := s.ds.NewTeam(context.Background(), &fleet.Team{
- Name: "team4_" + t.Name(),
- Secrets: []*fleet.EnrollSecret{{Secret: "tm4secret"}},
- })
- require.NoError(t, err)
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{
- prof1, prof2, prof4,
- }}, http.StatusNoContent, "team_id", fmt.Sprint(tm4.ID))
-
- // preassign the MDM host to prof1 and prof4, should match existing team tm2
- //
- // additionally, use external host identifiers with different
- // suffixes to simulate real world distributed scenarios where more
- // than one puppet server might be running at the time.
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler06.test.example.com", HostUUID: mdmHost.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler01.test.example.com", HostUUID: mdmHost.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
-
- // match with the mdm host succeeds and assigns it to tm2
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler03.test.example.com"}, http.StatusNoContent)
-
- // the host is now part of that team
- h, err = s.ds.Host(ctx, mdmHost.ID)
- require.NoError(t, err)
- require.NotNil(t, h.TeamID)
- require.Equal(t, tm2.ID, *h.TeamID)
- // tm2 still has disk encryption and release device manually disabled
- tm2, err = s.ds.Team(ctx, *h.TeamID)
- require.NoError(t, err)
- require.False(t, tm2.Config.MDM.EnableDiskEncryption)
- require.False(t, tm2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value)
-
- // the host's profiles are:
- // - the same as the team's and are pending (prof1 + prof4)
- // - prof2 + old filevault are pending removal
- // - fleetd config being reinstalled (for new enroll secret)
- s.awaitTriggerProfileSchedule(t)
-
- // useful for debugging
- //mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- // mysql.DumpTable(t, q, "host_mdm_apple_profiles")
- // return nil
- //})
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- mdmHost: {
- {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- },
- })
-
- // create a new mdm host enrolled in fleet
- mdmHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
-
- // make it part of team 2
- s.Do("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{mdmHost2.ID}}, http.StatusOK)
-
- // simulate having its profiles installed
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- res, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID)
- n, _ := res.RowsAffected()
- require.Equal(t, 3, int(n))
- return err
- })
-
- // preassign the MDM host using "g1" and "g4", should match existing
- // team tm2, and nothing be done since the host is already in tm2
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent)
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent)
- s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm2"}, http.StatusNoContent)
-
- // the host is still part of tm2
- h, err = s.ds.Host(ctx, mdmHost2.ID)
- require.NoError(t, err)
- require.NotNil(t, h.TeamID)
- require.Equal(t, tm2.ID, *h.TeamID)
-
- // and its profiles have been left untouched
- s.awaitTriggerProfileSchedule(t)
-
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- mdmHost2: {
- {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-}
-
-// while s.TestPuppetMatchPreassignProfiles focuses on many edge cases/extra
-// checks around profile assignment, this test is mainly focused on
-// simulating a few puppet runs in scenarios we want to support, and ensuring that:
-//
-// - different hosts end up in the right teams
-// - teams get edited as expected
-// - commands to add/remove profiles are issued adequately
-func (s *integrationMDMTestSuite) TestPuppetRun() {
- t := s.T()
- ctx := context.Background()
-
- // define a few profiles
- prof1, prof2, prof3, prof4 := mobileconfigForTest("n1", "i1"),
- mobileconfigForTest("n2", "i2"),
- mobileconfigForTest("n3", "i3"),
- mobileconfigForTest("n4", "i4")
-
- // create three hosts
- host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
- host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
- host3, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
- s.runWorker()
-
- // Use a gitops user for all Puppet actions
- u := &fleet.User{
- Name: "GitOps",
- Email: "gitops-TestPuppetRun@example.com",
- GlobalRole: ptr.String(fleet.RoleGitOps),
- }
- require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10))
- _, err := s.ds.NewUser(context.Background(), u)
- require.NoError(t, err)
- s.setTokenForTest(t, "gitops-TestPuppetRun@example.com", test.GoodPassword)
-
- // preassignAndMatch simulates the puppet module doing all the
- // preassign/match calls for a given set of profiles.
- preassignAndMatch := func(profs []fleet.MDMApplePreassignProfilePayload) {
- require.NotEmpty(t, profs)
- for _, prof := range profs {
- s.Do(
- "POST",
- "/api/latest/fleet/mdm/apple/profiles/preassign",
- preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: prof},
- http.StatusNoContent,
- )
- }
- s.Do(
- "POST",
- "/api/latest/fleet/mdm/apple/profiles/match",
- matchMDMApplePreassignmentRequest{ExternalHostIdentifier: profs[0].ExternalHostIdentifier},
- http.StatusNoContent,
- )
- }
-
- // node default {
- // fleetdm::profile { 'n1':
- // template => template('n1.mobileconfig.erb'),
- // group => 'base',
- // }
- //
- // fleetdm::profile { 'n2':
- // template => template('n2.mobileconfig.erb'),
- // group => 'workstations',
- // }
- //
- // fleetdm::profile { 'n3':
- // template => template('n3.mobileconfig.erb'),
- // group => 'workstations',
- // }
- //
- // if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
- // fleetdm::profile { 'n4':
- // template => template('fleetdm/n4.mobileconfig.erb'),
- // group => 'kiosks',
- // }
- // }
- puppetRun := func(host *fleet.Host) {
- payload := []fleet.MDMApplePreassignProfilePayload{
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof1,
- Group: "base",
- },
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof2,
- Group: "workstations",
- },
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof3,
- Group: "workstations",
- },
- }
-
- if host.UUID == host2.UUID {
- payload = append(payload, fleet.MDMApplePreassignProfilePayload{
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof4,
- Group: "kiosks",
- })
- }
-
- preassignAndMatch(payload)
- }
-
- // host1 checks in
- puppetRun(host1)
-
- // the host now belongs to a team
- h1, err := s.ds.Host(ctx, host1.ID)
- require.NoError(t, err)
- require.NotNil(t, h1.TeamID)
-
- // the team has the right name
- tm1, err := s.ds.Team(ctx, *h1.TeamID)
- require.NoError(t, err)
- require.Equal(t, "base - workstations", tm1.Name)
- // and the right profiles
- profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
- require.NoError(t, err)
- require.Len(t, profs, 3)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
-
- // host2 checks in
- puppetRun(host2)
- // a new team is created
- h2, err := s.ds.Host(ctx, host2.ID)
- require.NoError(t, err)
- require.NotNil(t, h2.TeamID)
-
- // the team has the right name
- tm2, err := s.ds.Team(ctx, *h2.TeamID)
- require.NoError(t, err)
- require.Equal(t, "base - kiosks - workstations", tm2.Name)
- // and the right profiles
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
- require.NoError(t, err)
- require.Len(t, profs, 4)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
- require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
- require.True(t, tm2.Config.MDM.EnableDiskEncryption)
-
- // host3 checks in
- puppetRun(host3)
- // it belongs to the same team as host1
- h3, err := s.ds.Host(ctx, host3.ID)
- require.NoError(t, err)
- require.Equal(t, h1.TeamID, h3.TeamID)
-
- // prof2 is edited
- oldProf2 := prof2
- prof2 = mobileconfigForTest("n2", "i2-v2")
- // host3 checks in again
- puppetRun(host3)
- // still belongs to the same team
- h3, err = s.ds.Host(ctx, host3.ID)
- require.NoError(t, err)
- require.Equal(t, tm1.ID, *h3.TeamID)
-
- // but the team has prof2 updated
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
- require.NoError(t, err)
- require.Len(t, profs, 3)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
- require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
-
- // host2 checks in, still belongs to the same team
- puppetRun(host2)
- h2, err = s.ds.Host(ctx, host2.ID)
- require.NoError(t, err)
- require.Equal(t, tm2.ID, *h2.TeamID)
-
- // but the team has prof2 updated as well
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
- require.NoError(t, err)
- require.Len(t, profs, 4)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
- require.Equal(t, prof4, []byte(profs[3].Mobileconfig))
- require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig))
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
-
- // the puppet manifest is changed, and prof3 is removed
- // node default {
- // fleetdm::profile { 'n1':
- // template => template('n1.mobileconfig.erb'),
- // group => 'base',
- // }
- //
- // fleetdm::profile { 'n2':
- // template => template('n2.mobileconfig.erb'),
- // group => 'workstations',
- // }
- //
- // if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' {
- // fleetdm::profile { 'n4':
- // template => template('fleetdm/n4.mobileconfig.erb'),
- // group => 'kiosks',
- // }
- // }
- puppetRun = func(host *fleet.Host) {
- payload := []fleet.MDMApplePreassignProfilePayload{
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof1,
- Group: "base",
- },
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof2,
- Group: "workstations",
- },
- }
-
- if host.UUID == host2.UUID {
- payload = append(payload, fleet.MDMApplePreassignProfilePayload{
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof4,
- Group: "kiosks",
- })
- }
-
- preassignAndMatch(payload)
- }
-
- // host1 checks in again
- puppetRun(host1)
- // still belongs to the same team
- h1, err = s.ds.Host(ctx, host1.ID)
- require.NoError(t, err)
- require.Equal(t, tm1.ID, *h1.TeamID)
-
- // but the team doesn't have prof3 anymore
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
- require.NoError(t, err)
- require.Len(t, profs, 2)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
-
- // same for host2
- puppetRun(host2)
- h2, err = s.ds.Host(ctx, host2.ID)
- require.NoError(t, err)
- require.Equal(t, tm2.ID, *h2.TeamID)
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID)
- require.NoError(t, err)
- require.Len(t, profs, 3)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.Equal(t, prof4, []byte(profs[2].Mobileconfig))
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
-
- // The puppet manifest is drastically updated, this time to use exclusions on host3:
- //
- // node default {
- // fleetdm::profile { 'n1':
- // template => template('n1.mobileconfig.erb'),
- // group => 'base',
- // }
- //
- // fleetdm::profile { 'n2':
- // template => template('n2.mobileconfig.erb'),
- // group => 'workstations',
- // }
- //
- // if $facts['system_profiler']['hardware_uuid'] == 'host_3_uuid' {
- // fleetdm::profile { 'n3':
- // template => template('fleetdm/n3.mobileconfig.erb'),
- // group => 'no-nudge',
- // }
- // } else {
- // fleetdm::profile { 'n3':
- // ensure => absent,
- // template => template('fleetdm/n3.mobileconfig.erb'),
- // group => 'workstations',
- // }
- // }
- // }
- puppetRun = func(host *fleet.Host) {
- manifest := []fleet.MDMApplePreassignProfilePayload{
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof1,
- Group: "base",
- },
- {
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof2,
- Group: "workstations",
- },
- }
-
- if host.UUID == host3.UUID {
- manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof3,
- Group: "no-nudge",
- Exclude: true,
- })
- } else {
- manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{
- ExternalHostIdentifier: host.Hostname,
- HostUUID: host.UUID,
- Profile: prof3,
- Group: "workstations",
- })
- }
-
- preassignAndMatch(manifest)
- }
-
- // host1 checks in
- puppetRun(host1)
-
- // the host belongs to the same team
- h1, err = s.ds.Host(ctx, host1.ID)
- require.NoError(t, err)
- require.Equal(t, tm1.ID, *h1.TeamID)
-
- // the team has the right profiles
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID)
- require.NoError(t, err)
- require.Len(t, profs, 3)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.Equal(t, prof3, []byte(profs[2].Mobileconfig))
- require.True(t, tm1.Config.MDM.EnableDiskEncryption)
-
- // host2 checks in
- puppetRun(host2)
- // it is assigned to tm1
- h2, err = s.ds.Host(ctx, host2.ID)
- require.NoError(t, err)
- require.Equal(t, tm1.ID, *h2.TeamID)
-
- // host3 checks in
- puppetRun(host3)
-
- // it is assigned to a new team
- h3, err = s.ds.Host(ctx, host3.ID)
- require.NoError(t, err)
- require.NotNil(t, h3.TeamID)
- require.NotEqual(t, tm1.ID, *h3.TeamID)
- require.NotEqual(t, tm2.ID, *h3.TeamID)
-
- // a new team is created
- tm3, err := s.ds.Team(ctx, *h3.TeamID)
- require.NoError(t, err)
- require.Equal(t, "base - no-nudge - workstations", tm3.Name)
- // and the right profiles
- profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm3.ID)
- require.NoError(t, err)
- require.Len(t, profs, 2)
- require.Equal(t, prof1, []byte(profs[0].Mobileconfig))
- require.Equal(t, prof2, []byte(profs[1].Mobileconfig))
- require.True(t, tm3.Config.MDM.EnableDiskEncryption)
-}
-
func createHostThenEnrollMDM(ds fleet.Datastore, fleetServerURL string, t *testing.T) (*fleet.Host, *mdmtest.TestAppleMDMClient) {
desktopToken := uuid.New().String()
mdmDevice := mdmtest.NewTestMDMClientAppleDesktopManual(fleetServerURL, desktopToken)
@@ -2444,8 +942,8 @@ func (s *integrationMDMTestSuite) TestMDMAppleUnenroll() {
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)
+ // 3 profiles added + 1 profile with fleetd configuration + 1 root CA config
+ require.Len(t, *hostResp.Host.MDM.Profiles, 5)
// try to unenroll the host, fails since the host doesn't respond
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", h.ID), nil, http.StatusGatewayTimeout)
@@ -3098,351 +1596,6 @@ func (s *integrationMDMTestSuite) TestWindowsMDMGetEncryptionKey() {
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/encryption_key", host.ID), nil, http.StatusNotFound, &resp)
}
-func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() {
- t := s.T()
- ctx := context.Background()
-
- testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
- require.NoError(t, err)
-
- mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t)
- s.runWorker()
-
- t.Run("no profiles", func(t *testing.T) {
- var listResp listMDMAppleConfigProfilesResponse
- s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
- require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil
- require.Len(t, listResp.ConfigProfiles, 0)
-
- listResp = listMDMAppleConfigProfilesResponse{}
- s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &listResp)
- require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil
- require.Len(t, listResp.ConfigProfiles, 0)
-
- var hostProfilesResp getHostProfilesResponse
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
- require.NotNil(t, hostProfilesResp.Profiles) // expect empty slice instead of nil
- require.Len(t, hostProfilesResp.Profiles, 0)
- require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
- })
-
- t.Run("with profiles", func(t *testing.T) {
- p1, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p1", "p1.identifier", "p1.uuid"), nil)
- require.NoError(t, err)
- _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p1)
- require.NoError(t, err)
-
- p2, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p2", "p2.identifier", "p2.uuid"), &testTeam.ID)
- require.NoError(t, err)
- _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p2)
- require.NoError(t, err)
-
- var resp listMDMAppleConfigProfilesResponse
- s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: 0}, http.StatusOK, &resp)
- require.NotNil(t, resp.ConfigProfiles)
- require.Len(t, resp.ConfigProfiles, 1)
- require.Equal(t, p1.Name, resp.ConfigProfiles[0].Name)
- require.Equal(t, p1.Identifier, resp.ConfigProfiles[0].Identifier)
-
- resp = listMDMAppleConfigProfilesResponse{}
- s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
- require.NotNil(t, resp.ConfigProfiles)
- require.Len(t, resp.ConfigProfiles, 1)
- require.Equal(t, p2.Name, resp.ConfigProfiles[0].Name)
- require.Equal(t, p2.Identifier, resp.ConfigProfiles[0].Identifier)
-
- p3, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p3", "p3.identifier", "p3.uuid"), &testTeam.ID)
- require.NoError(t, err)
- _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p3)
- require.NoError(t, err)
-
- resp = listMDMAppleConfigProfilesResponse{}
- s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp)
- require.NotNil(t, resp.ConfigProfiles)
- require.Len(t, resp.ConfigProfiles, 2)
- for _, p := range resp.ConfigProfiles {
- if p.Name == p2.Name {
- require.Equal(t, p2.Identifier, p.Identifier)
- } else if p.Name == p3.Name {
- require.Equal(t, p3.Identifier, p.Identifier)
- } else {
- require.Fail(t, "unexpected profile name")
- }
- }
-
- var hostProfilesResp getHostProfilesResponse
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
- require.NotNil(t, hostProfilesResp.Profiles)
- require.Len(t, hostProfilesResp.Profiles, 1)
- require.Equal(t, p1.Name, hostProfilesResp.Profiles[0].Name)
- require.Equal(t, p1.Identifier, hostProfilesResp.Profiles[0].Identifier)
- require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
-
- // add the host to a team
- err = s.ds.AddHostsToTeam(ctx, &testTeam.ID, []uint{mdmHost.ID})
- require.NoError(t, err)
-
- hostProfilesResp = getHostProfilesResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp)
- require.NotNil(t, hostProfilesResp.Profiles)
- require.Len(t, hostProfilesResp.Profiles, 2)
- require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID)
- })
-}
-
-func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() {
- t := s.T()
- ctx := context.Background()
-
- testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"})
- require.NoError(t, err)
-
- testProfiles := make(map[string]fleet.MDMAppleConfigProfile)
- generateTestProfile := func(name string, identifier string) {
- i := identifier
- if i == "" {
- i = fmt.Sprintf("%s.SomeIdentifier", name)
- }
- cp := fleet.MDMAppleConfigProfile{
- Name: name,
- Identifier: i,
- }
- cp.Mobileconfig = mcBytesForTest(cp.Name, cp.Identifier, fmt.Sprintf("%s.UUID", name))
- testProfiles[name] = cp
- }
- setTestProfileID := func(name string, id uint) {
- tp := testProfiles[name]
- tp.ProfileID = id
- testProfiles[name] = tp
- }
-
- generateNewReq := func(name string, teamID *uint) (*bytes.Buffer, map[string]string) {
- args := map[string][]string{}
- if teamID != nil {
- args["team_id"] = []string{fmt.Sprintf("%d", *teamID)}
- }
- return generateNewProfileMultipartRequest(t, "some_filename", testProfiles[name].Mobileconfig, s.token, args)
- }
-
- checkGetResponse := func(resp *http.Response, expected fleet.MDMAppleConfigProfile) {
- // check expected headers
- require.Contains(t, resp.Header["Content-Type"], "application/x-apple-aspen-config")
- require.Contains(t, resp.Header["Content-Disposition"], fmt.Sprintf(`attachment;filename="%s_%s.%s"`, time.Now().Format("2006-01-02"), strings.ReplaceAll(expected.Name, " ", "_"), "mobileconfig"))
- // check expected body
- var bb bytes.Buffer
- _, err = io.Copy(&bb, resp.Body)
- require.NoError(t, err)
- require.Equal(t, []byte(expected.Mobileconfig), bb.Bytes())
- }
-
- checkConfigProfile := func(expected fleet.MDMAppleConfigProfile, actual fleet.MDMAppleConfigProfile) {
- require.Equal(t, expected.Name, actual.Name)
- require.Equal(t, expected.Identifier, actual.Identifier)
- }
-
- // create new profile (no team)
- generateTestProfile("TestNoTeam", "")
- body, headers := generateNewReq("TestNoTeam", nil)
- newResp := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
- var newCP fleet.MDMAppleConfigProfile
- err = json.NewDecoder(newResp.Body).Decode(&newCP)
- require.NoError(t, err)
- require.NotEmpty(t, newCP.ProfileID)
- setTestProfileID("TestNoTeam", newCP.ProfileID)
-
- // create new profile (with team id)
- generateTestProfile("TestWithTeamID", "")
- body, headers = generateNewReq("TestWithTeamID", &testTeam.ID)
- newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
- err = json.NewDecoder(newResp.Body).Decode(&newCP)
- require.NoError(t, err)
- require.NotEmpty(t, newCP.ProfileID)
- setTestProfileID("TestWithTeamID", newCP.ProfileID)
-
- // list profiles (no team)
- expectedCP := testProfiles["TestNoTeam"]
- var listResp listMDMAppleConfigProfilesResponse
- s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp)
- require.Len(t, listResp.ConfigProfiles, 1)
- respCP := listResp.ConfigProfiles[0]
- require.Equal(t, expectedCP.Name, respCP.Name)
- checkConfigProfile(expectedCP, *respCP)
- require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
- require.Empty(t, respCP.TeamID) // zero means no team
-
- // list profiles (team 1)
- expectedCP = testProfiles["TestWithTeamID"]
- listResp = listMDMAppleConfigProfilesResponse{}
- s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
- require.Len(t, listResp.ConfigProfiles, 1)
- respCP = listResp.ConfigProfiles[0]
- require.Equal(t, expectedCP.Name, respCP.Name)
- checkConfigProfile(expectedCP, *respCP)
- require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes
- require.Equal(t, testTeam.ID, *respCP.TeamID) // team 1
-
- // get profile (no team)
- expectedCP = testProfiles["TestNoTeam"]
- getPath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
- getResp := s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
- checkGetResponse(getResp, expectedCP)
-
- // get profile (team 1)
- expectedCP = testProfiles["TestWithTeamID"]
- getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID)
- getResp = s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
- checkGetResponse(getResp, expectedCP)
-
- // delete profile (no team)
- deletedCP := testProfiles["TestNoTeam"]
- deletePath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
- var deleteResp deleteMDMAppleConfigProfileResponse
- s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
- // confirm deleted
- listResp = listMDMAppleConfigProfilesResponse{}
- s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{}, http.StatusOK, &listResp)
- require.Len(t, listResp.ConfigProfiles, 0)
- getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
- _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
-
- // delete profile (team 1)
- deletedCP = testProfiles["TestWithTeamID"]
- deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
- deleteResp = deleteMDMAppleConfigProfileResponse{}
- s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp)
- // confirm deleted
- listResp = listMDMAppleConfigProfilesResponse{}
- s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp)
- require.Len(t, listResp.ConfigProfiles, 0)
- getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID)
- _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)})
-
- // trying to add/delete profiles with identifiers managed by Fleet fails
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- generateTestProfile("TestNoTeam", p)
- body, headers := generateNewReq("TestNoTeam", nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
-
- generateTestProfile("TestWithTeamID", p)
- body, headers = generateNewReq("TestWithTeamID", nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
- cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
- require.NoError(t, err)
- testProfiles["WithContent"] = *cp
- body, headers = generateNewReq("WithContent", nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
- }
-
- // trying to add profiles with identifiers managed by Fleet fails
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- generateTestProfile("TestNoTeam", p)
- body, headers := generateNewReq("TestNoTeam", nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
-
- generateTestProfile("TestWithTeamID", p)
- body, headers = generateNewReq("TestWithTeamID", nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
- cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil)
- require.NoError(t, err)
- testProfiles["WithContent"] = *cp
- body, headers = generateNewReq("WithContent", nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
- }
-
- // trying to add profiles with names reserved by Fleet fails
- for name := range servermdm.FleetReservedProfileNames() {
- cp := &fleet.MDMAppleConfigProfile{
- Name: name,
- Identifier: "valid.identifier",
- Mobileconfig: mcBytesForTest(name, "valid.identifier", "some-uuid"),
- }
- body, headers := generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
-
- body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
- "team_id": {fmt.Sprintf("%d", testTeam.ID)},
- })
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
-
- cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent(
- "valid outer name",
- "valid.outer.identifier",
- "valid.inner.identifer",
- "some-uuid",
- name,
- ), nil)
- require.NoError(t, err)
- body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
-
- cp.TeamID = &testTeam.ID
- body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{
- "team_id": {fmt.Sprintf("%d", testTeam.ID)},
- })
-
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers)
- }
-
- // make fleet add a FileVault profile
- acResp := appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "enable_disk_encryption": true }
- }`), http.StatusOK, &acResp)
- assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
- profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
-
- // try to delete the profile
- deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", profile.ProfileID)
- deleteResp = deleteMDMAppleConfigProfileResponse{}
- s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp)
-}
-
-func (s *integrationMDMTestSuite) 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": [
- {"path": "foo", "labels": ["baz"]},
- {"path": "bar"}
- ]
- }
- }
- }`), http.StatusOK, &acResp)
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings)
-
- // check that they are returned by a GET /config
- acResp = appConfigResponse{}
- s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "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": { "enable_disk_encryption": true }
- }`), http.StatusOK, &acResp)
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "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, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "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()
@@ -3717,97 +1870,6 @@ func (s *integrationMDMTestSuite) TestMDMAppleDiskEncryptionAggregate() {
s.checkMDMProfilesSummaries(t, nil, expectedNoTeamProfilesSummary, &expectedNoTeamProfilesSummary) // no change
}
-func (s *integrationMDMTestSuite) TestApplyTeamsMDMAppleProfiles() {
- t := s.T()
-
- // create a team through the service so it initializes the agent ops
- teamName := t.Name() + "team1"
- team := &fleet.Team{
- Name: teamName,
- Description: "desc team1",
- }
- var createTeamResp teamResponse
- s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
- require.NotZero(t, createTeamResp.Team.ID)
- team = createTeamResp.Team
-
- // apply with custom macos settings
- teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
- Name: teamName,
- MDM: fleet.TeamSpecMDM{
- MacOSSettings: map[string]interface{}{
- "custom_settings": []map[string]interface{}{{"path": "foo"}, {"path": "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, []fleet.MDMProfileSpec{{Path: "foo"}, {Path: "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 MDMProfileSpecs but got bool`)
-
- // apply without custom macos settings specified and unrelated field, should
- // not replace existing settings
- teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{
- Name: teamName,
- MDM: fleet.TeamSpecMDM{
- EnableDiskEncryption: optjson.SetBool(false),
- },
- }}}
- s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
- teamResp = getTeamResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.Equal(t, []fleet.MDMProfileSpec{{Path: "foo"}, {Path: "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": []map[string]interface{}{}},
- },
- }}}
- s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true")
- teamResp = getTeamResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.Equal(t, []fleet.MDMProfileSpec{{Path: "foo"}, {Path: "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": []map[string]interface{}{}},
- },
- }}}
- s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK)
- teamResp = getTeamResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.Equal(t, []fleet.MDMProfileSpec{}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings)
-}
-
func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
t := s.T()
@@ -4005,75 +2067,6 @@ func (s *integrationMDMTestSuite) TestTeamsMDMAppleDiskEncryption() {
require.True(t, teamResp.Team.Config.MDM.EnableDiskEncryption)
}
-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()
@@ -4171,571 +2164,6 @@ func (s *integrationMDMTestSuite) TestDiskEncryptionRotation() {
require.False(t, resp.Notifications.RotateDiskEncryptionKey)
}
-func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() {
- t := s.T()
- ctx := context.Background()
-
- createManualMDMEnrollWithOrbit := func(secret string) *fleet.Host {
- // orbit enrollment happens before mdm enrollment, otherwise the host would
- // always receive the "no team" profiles on mdm enrollment since it would
- // not be part of any team yet (team assignment is done when it enrolls
- // with orbit).
- mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{
- SCEPChallenge: s.fleetCfg.MDM.AppleSCEPChallenge,
- SCEPURL: s.server.URL + apple_mdm.SCEPPath,
- MDMURL: s.server.URL + apple_mdm.MDMPath,
- })
-
- // enroll the device with orbit
- var resp EnrollOrbitResponse
- s.DoJSON("POST", "/api/fleet/orbit/enroll", EnrollOrbitRequest{
- EnrollSecret: secret,
- HardwareUUID: mdmDevice.UUID, // will not match any existing host
- HardwareSerial: mdmDevice.SerialNumber,
- }, http.StatusOK, &resp)
- require.NotEmpty(t, resp.OrbitNodeKey)
- orbitNodeKey := resp.OrbitNodeKey
- h, err := s.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey)
- require.NoError(t, err)
- h.OrbitNodeKey = &orbitNodeKey
- h.Platform = "darwin"
-
- err = mdmDevice.Enroll()
- require.NoError(t, err)
-
- return h
- }
-
- triggerReconcileProfiles := func() {
- s.awaitTriggerProfileSchedule(t)
- // this will only mark them as "pending", as the response to confirm
- // profile deployment is asynchronous, so we simulate it here by
- // updating any "pending" (not NULL) profiles to "verifying"
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending)
- return err
- })
- }
-
- assignHostToTeam := func(h *fleet.Host, teamID *uint) {
- var moveHostResp addHostsToTeamResponse
- s.DoJSON("POST", "/api/v1/fleet/hosts/transfer",
- addHostsToTeamRequest{TeamID: teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &moveHostResp)
-
- h.TeamID = teamID
- }
-
- // add a couple global profiles
- globalProfiles := [][]byte{
- mobileconfigForTest("G1", "G1"),
- mobileconfigForTest("G2", "G2"),
- }
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
- batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
- // create the no-team enroll secret
- var applyResp applyEnrollSecretSpecResponse
- globalEnrollSec := "global_enroll_sec"
- s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret",
- applyEnrollSecretSpecRequest{
- Spec: &fleet.EnrollSecretSpec{
- Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}},
- },
- }, http.StatusOK, &applyResp)
-
- // create a team with a couple profiles
- tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_1"})
- require.NoError(t, err)
- tm1Profiles := [][]byte{
- mobileconfigForTest("T1.1", "T1.1"),
- mobileconfigForTest("T1.2", "T1.2"),
- }
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
- batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent,
- "team_id", strconv.Itoa(int(tm1.ID)))
- // create the team 1 enroll secret
- var teamResp teamEnrollSecretsResponse
- tm1EnrollSec := "team1_enroll_sec"
- s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1.ID),
- modifyTeamEnrollSecretsRequest{
- Secrets: []fleet.EnrollSecret{{Secret: tm1EnrollSec}},
- }, http.StatusOK, &teamResp)
-
- // create another team with different profiles
- tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_2"})
- require.NoError(t, err)
- tm2Profiles := [][]byte{
- mobileconfigForTest("T2.1", "T2.1"),
- }
- s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch",
- batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent,
- "team_id", strconv.Itoa(int(tm2.ID)))
-
- // enroll a couple hosts in no team
- h1 := createManualMDMEnrollWithOrbit(globalEnrollSec)
- require.Nil(t, h1.TeamID)
- h2 := createManualMDMEnrollWithOrbit(globalEnrollSec)
- require.Nil(t, h2.TeamID)
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h1: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- h2: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- })
-
- // enroll a couple hosts in team 1
- h3 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
- require.NotNil(t, h3.TeamID)
- require.Equal(t, tm1.ID, *h3.TeamID)
- h4 := createManualMDMEnrollWithOrbit(tm1EnrollSec)
- require.NotNil(t, h4.TeamID)
- require.Equal(t, tm1.ID, *h4.TeamID)
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h3: {
- {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- h4: {
- {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- })
-
- // 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: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h2: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- h4: {
- {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- h4: {
- {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- })
-
- // apply the pending profiles
- triggerReconcileProfiles()
-
- // add a profile to no team (h2 and h4 are now part of no team)
- body, headers := generateNewProfileMultipartRequest(t,
- "some_name", mobileconfigForTest("G3", "G3"), s.token, nil)
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h2: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- },
- h4: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // add a profile to team 2 (h1 and h3 are now part of team 2)
- body, headers = generateNewProfileMultipartRequest(t,
- "some_name", mobileconfigForTest("T2.2", "T2.2"), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm2.ID)}})
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h1: {
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h2: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // 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: {
- {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // apply the pending profiles
- triggerReconcileProfiles()
-
- // all profiles now verifying
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h1: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h2: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // h1 verified one of the profiles
- require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
- "G2b": {Identifier: "G2b", DisplayName: "G2b", InstallDate: time.Now()},
- }))
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h1: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h2: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- // switch a team host (h1) to another team (tm1)
- assignHostToTeam(h1, &tm1.ID)
-
- // Create a new profile that will be labeled
- body, headers = generateNewProfileMultipartRequest(
- t,
- "label_prof",
- mobileconfigForTest("label_prof", "label_prof"),
- s.token,
- map[string][]string{"team_id": {fmt.Sprintf("%d", tm1.ID)}},
- )
- s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers)
-
- var uid string
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "label_prof")
- })
-
- label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "test label 1", Query: "select 1;"})
- require.NoError(t, err)
-
- // Update label with host membership
- mysql.ExecAdhocSQL(
- t, s.ds, func(db sqlx.ExtContext) error {
- _, err := db.ExecContext(
- context.Background(),
- "INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)",
- h1.ID,
- label.ID,
- )
- return err
- },
- )
-
- // Update profile <-> label mapping
- mysql.ExecAdhocSQL(
- t, s.ds, func(db sqlx.ExtContext) error {
- _, err := db.ExecContext(
- context.Background(),
- "INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)",
- uid,
- label.Name,
- label.ID,
- )
- return err
- },
- )
-
- triggerReconcileProfiles()
-
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h1: {
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h2: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-
- require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{
- "label_prof": {Identifier: "label_prof", DisplayName: "label_prof", InstallDate: time.Now()},
- }))
-
- s.assertHostConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{
- h1: {
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "T1.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified},
- },
- h2: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h3: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- h4: {
- {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying},
- },
- })
-}
-
func (s *integrationMDMTestSuite) TestFleetdConfiguration() {
t := s.T()
s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetdConfigPayloadIdentifier, false)
@@ -7337,6 +4765,16 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string
func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enrollmentRef string) *enrollmentProfile {
t := s.T()
var profile enrollmentProfile
+
+ if !bytes.HasPrefix(bytes.TrimSpace(rawProfile), []byte(" 0 {
- fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
- }
- body, headers := generateNewProfileMultipartRequest(
- t, filename, mobileconfigForTest(name, ident), s.token, fields,
- )
- res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
-
- if wantErrMsg != "" {
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, wantErrMsg)
- return ""
- }
-
- var resp newMDMConfigProfileResponse
- err := json.NewDecoder(res.Body).Decode(&resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.ProfileUUID)
- require.Equal(t, "a", string(resp.ProfileUUID[0]))
- return resp.ProfileUUID
- }
- assertAppleDeclaration := func(filename, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
- fields := map[string][]string{
- "labels": labelNames,
- }
- if teamID > 0 {
- fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
- }
-
- bytes := []byte(fmt.Sprintf(`{
- "Type": "com.apple.configuration.foo",
- "Payload": {
- "Echo": "f1337"
- },
- "Identifier": "%s"
-}`, ident))
-
- body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields)
- res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
-
- if wantErrMsg != "" {
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, wantErrMsg)
- return ""
- }
-
- var resp newMDMConfigProfileResponse
- err := json.NewDecoder(res.Body).Decode(&resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.ProfileUUID)
- require.Equal(t, fleet.MDMAppleDeclarationUUIDPrefix, string(resp.ProfileUUID[0]))
- return resp.ProfileUUID
- }
-
- createAppleProfile := func(name, ident string, teamID uint, labelNames []string) string {
- uid := assertAppleProfile(name+".mobileconfig", name, ident, teamID, labelNames, http.StatusOK, "")
-
- var wantJSON string
- if teamID == 0 {
- wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "profile_identifier": %q}`, name, ident)
- } else {
- wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "profile_identifier": %q}`, teamID, testTeam.Name, name, ident)
- }
- s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedMacosProfile{}.ActivityName(), wantJSON, 0)
-
- return uid
- }
-
- createAppleDeclaration := func(name, ident string, teamID uint, labelNames []string) string {
- uid := assertAppleDeclaration(name+".json", ident, teamID, labelNames, http.StatusOK, "")
-
- var wantJSON string
- if teamID == 0 {
- wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "identifier": %q}`, name, ident)
- } else {
- wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "identifier": %q}`, teamID, testTeam.Name, name, ident)
- }
- s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedDeclarationProfile{}.ActivityName(), wantJSON, 0)
-
- return uid
- }
-
- assertWindowsProfile := func(filename, locURI string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string {
- fields := map[string][]string{
- "labels": labelNames,
- }
- if teamID > 0 {
- fields["team_id"] = []string{fmt.Sprintf("%d", teamID)}
- }
- body, headers := generateNewProfileMultipartRequest(
- t,
- filename,
- []byte(fmt.Sprintf(`- %s
- %s
`, locURI, locURI)),
- s.token,
- fields,
- )
- res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers)
-
- if wantErrMsg != "" {
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, wantErrMsg)
- return ""
- }
-
- var resp newMDMConfigProfileResponse
- err := json.NewDecoder(res.Body).Decode(&resp)
- require.NoError(t, err)
- require.NotEmpty(t, resp.ProfileUUID)
- require.Equal(t, "w", string(resp.ProfileUUID[0]))
- return resp.ProfileUUID
- }
- createWindowsProfile := func(name string, teamID uint, labels []string) string {
- uid := assertWindowsProfile(name+".xml", "./Test", teamID, labels, http.StatusOK, "")
-
- var wantJSON string
- if teamID == 0 {
- wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name)
- } else {
- wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name)
- }
- s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedWindowsProfile{}.ActivityName(), wantJSON, 0)
-
- return uid
- }
-
- // create a couple Apple profiles for no-team and team
- noTeamAppleProfUUID := createAppleProfile("apple-global-profile", "test-global-ident", 0, nil)
- teamAppleProfUUID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID, nil)
- // create a couple Windows profiles for no-team and team
- noTeamWinProfUUID := createWindowsProfile("win-global-profile", 0, nil)
- teamWinProfUUID := createWindowsProfile("win-team-profile", testTeam.ID, nil)
-
- // Windows profile name conflicts with Apple's for no team
- assertWindowsProfile("apple-global-profile.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
- // but no conflict for team 1
- assertWindowsProfile("apple-global-profile.xml", "./Test", testTeam.ID, nil, http.StatusOK, "")
- // Apple profile name conflicts with Windows' for no team
- assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
- // but no conflict for team 1
- assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, nil, http.StatusOK, "")
- // Windows profile name conflicts with Apple's for team 1
- assertWindowsProfile("apple-team-profile.xml", "./Test", testTeam.ID, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
- // but no conflict for no-team
- assertWindowsProfile("apple-team-profile.xml", "./Test", 0, nil, http.StatusOK, "")
- // Apple profile name conflicts with Windows' for team 1
- assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
- // but no conflict for no-team
- assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "")
-
- // add some macOS declarations
- createAppleDeclaration("apple-declaration", "test-declaration-ident", 0, nil)
- // identifier must be unique, it conflicts with existing declaration
- assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", 0, nil, http.StatusConflict, "test-declaration-ident already exists")
- // name is pulled from filename, it conflicts with existing declaration
- assertAppleDeclaration("apple-declaration.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "apple-declaration already exists")
- // uniqueness is checked only within team, so it's fine to have the same name and identifier in different teams
- assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", testTeam.ID, nil, http.StatusOK, "")
- // name is pulled from filename, it conflicts with existing macOS config profile
- assertAppleDeclaration("apple-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "apple-global-profile already exists")
- // name is pulled from filename, it conflicts with existing macOS config profile
- assertAppleDeclaration("win-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, "win-global-profile already exists")
- // windows profile name conflicts with existing declaration
- assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
- // macOS profile name conflicts with existing declaration
- assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, "Couldn't upload. A configuration profile with this name already exists.")
-
- // not an xml nor mobileconfig file
- assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
- assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
- assertAppleDeclaration("foo.txt", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.")
-
- // Windows-reserved LocURI
- assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include BitLocker settings.")
- assertWindowsProfile("updates.xml", syncml.FleetOSUpdateTargetLocURI, testTeam.ID, nil, http.StatusBadRequest, "Couldn't upload. Custom configuration profiles can't include Windows updates settings.")
-
- // Fleet-reserved profiles
- for name := range servermdm.FleetReservedProfileNames() {
- assertAppleProfile(name+".mobileconfig", name, name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %s is not allowed`, name))
- assertAppleDeclaration(name+".json", name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %q is not allowed`, name))
- assertWindowsProfile(name+".xml", "./Test", 0, nil, http.StatusBadRequest, fmt.Sprintf(`Couldn't upload. Profile name %q is not allowed.`, name))
- }
-
- // profiles with non-existent labels
- assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
- assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
- assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist"}, http.StatusBadRequest, "some or all the labels provided don't exist")
-
- // create a couple of labels
- labelFoo := &fleet.Label{Name: "foo", Query: "select * from foo;"}
- labelFoo, err = s.ds.NewLabel(context.Background(), labelFoo)
- require.NoError(t, err)
- labelBar := &fleet.Label{Name: "bar", Query: "select * from bar;"}
- labelBar, err = s.ds.NewLabel(context.Background(), labelBar)
- require.NoError(t, err)
-
- // profiles mixing existent and non-existent labels
- assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
- assertAppleDeclaration("apple-declaration-with-labels.json", "ident-with-labels", 0, []string{"does-not-exist", "foo"}, http.StatusBadRequest, "some or all the labels provided don't exist")
- assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, "some or all the labels provided don't exist")
-
- // profiles with valid labels
- uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"foo"}, http.StatusOK, "")
- uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"})
- uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"foo", "bar"}, http.StatusOK, "")
-
- // verify that the label associations have been created
- // TODO: update when we have datastore methods to get this data
- var profileLabels []fleet.ConfigurationProfileLabel
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `
- SELECT COALESCE(apple_profile_uuid, windows_profile_uuid) as profile_uuid, label_name, COALESCE(label_id, 0) as label_id
- FROM mdm_configuration_profile_labels
- UNION SELECT apple_declaration_uuid as profile_uuid, label_name, COALESCE(label_id, 0) as label_id
- FROM mdm_declaration_labels ORDER BY profile_uuid, label_name;`
- return sqlx.SelectContext(context.Background(), q, &profileLabels, stmt)
- })
-
- require.NotEmpty(t, profileLabels)
- require.Len(t, profileLabels, 4)
- require.ElementsMatch(
- t,
- []fleet.ConfigurationProfileLabel{
- {ProfileUUID: uuidAppleWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
- {ProfileUUID: uuidAppleDDMWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
- {ProfileUUID: uuidWindowsWithLabel, LabelName: labelFoo.Name, LabelID: labelFoo.ID},
- {ProfileUUID: uuidWindowsWithLabel, LabelName: labelBar.Name, LabelID: labelBar.ID},
- },
- profileLabels,
- )
-
- // Windows invalid content
- body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil)
- res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn't upload. The file should include valid XML:")
-
- // Apple invalid mobileconfig content
- body, headers = generateNewProfileMultipartRequest(t,
- "apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil)
- res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "mobileconfig is not XML nor PKCS7 parseable")
-
- // Apple invalid json declaration
- body, headers = generateNewProfileMultipartRequest(t,
- "apple.json", []byte("{"), s.token, nil)
- res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn't upload. The file should include valid JSON:")
-
- // get the existing profiles work
- expectedProfiles := []fleet.MDMConfigProfilePayload{
- {ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil},
- {ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID},
- {ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil},
- {ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID},
- {ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil, Labels: []fleet.ConfigurationProfileLabel{{LabelID: labelFoo.ID, LabelName: labelFoo.Name}}},
- }
- for _, prof := range expectedProfiles {
- var getResp getMDMConfigProfileResponse
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, &getResp)
- require.NotZero(t, getResp.CreatedAt)
- require.NotZero(t, getResp.UploadedAt)
- if getResp.Platform == "darwin" {
- require.Len(t, getResp.Checksum, 16)
- } else {
- require.Empty(t, getResp.Checksum)
- }
- getResp.CreatedAt, getResp.UploadedAt = time.Time{}, time.Time{}
- getResp.Checksum = nil
- require.Equal(t, prof, *getResp.MDMConfigProfilePayload)
-
- resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media")
- require.NotZero(t, resp.ContentLength)
- require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;")
- if strings.HasPrefix(prof.ProfileUUID, "a") {
- require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config")
- } else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) {
- require.Contains(t, resp.Header.Get("Content-Type"), "application/json")
- } else {
- require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream")
- }
- require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff")
-
- b, err := io.ReadAll(resp.Body)
- require.NoError(t, err)
- require.Equal(t, resp.ContentLength, int64(len(b)))
- }
-
- var getResp getMDMConfigProfileResponse
- // get an unknown Apple profile
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp)
- s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media")
- // get an unknown Apple declaration
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp)
- s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media")
- // get an unknown Windows profile
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp)
- s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media")
-
- var deleteResp deleteMDMConfigProfileResponse
- // delete existing Apple profiles
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp)
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp)
- // delete non-existing Apple profile
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp)
-
- // delete existing Apple declaration
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(),
- `{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null}`,
- 0,
- )
- // delete non-existing Apple declaration
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &deleteResp)
- // delete existing Windows profiles
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamWinProfUUID), nil, http.StatusOK, &deleteResp)
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamWinProfUUID), nil, http.StatusOK, &deleteResp)
- // delete non-existing Windows profile
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &deleteResp)
-
- // trying to create/delete profiles managed by Fleet fails
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- assertAppleProfile("foo.mobileconfig", p, p, 0, nil, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p))
-
- // create it directly in the DB to test deletion
- uid := "a" + uuid.NewString()
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- mc := mcBytesForTest(p, p, uuid.New().String())
- _, err := q.ExecContext(ctx,
- "INSERT INTO mdm_apple_configuration_profiles (profile_uuid, identifier, name, mobileconfig, checksum, team_id, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP())",
- uid, p, p, mc, "1234", 0)
- return err
- })
-
- var deleteResp deleteMDMConfigProfileResponse
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uid), nil, http.StatusBadRequest, &deleteResp)
-
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- _, err := q.ExecContext(ctx,
- "DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?",
- uid)
- return err
- })
- }
- // TODO: Add tests for create/delete forbidden declaration types?
-
- // make fleet add a FileVault profile
- acResp := appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "enable_disk_encryption": true }
- }`), http.StatusOK, &acResp)
- assert.True(t, acResp.MDM.EnableDiskEncryption.Value)
- profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true)
-
- // try to delete the profile
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profile.ProfileUUID), nil, http.StatusBadRequest, &deleteResp)
-
- // make fleet add a Windows OS Updates profile
- acResp = appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }
- }`), http.StatusOK, &acResp)
- profUUID := checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1)})
-
- // try to delete the profile
- s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp)
-
- // TODO: Add tests for OS updates declaration when implemented.
-}
-
-func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() {
- t := s.T()
- ctx := context.Background()
-
- // create some teams
- tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"})
- require.NoError(t, err)
- tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"})
- require.NoError(t, err)
- tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"})
- require.NoError(t, err)
-
- // set OS Updates settings for team 1 for both macOS and Windows, should not
- // be returned by the list profiles endpoint.
- var tmResp teamResponse
- s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm1.ID), fleet.TeamPayload{
- MDM: &fleet.TeamPayloadMDM{
- MacOSUpdates: &fleet.MacOSUpdates{
- Deadline: optjson.SetString("1992-01-01"),
- MinimumVersion: optjson.SetString("13.1.1"),
- },
- WindowsUpdates: &fleet.WindowsUpdates{
- DeadlineDays: optjson.SetInt(5),
- GracePeriodDays: optjson.SetInt(2),
- },
- },
- }, http.StatusOK, &tmResp)
-
- // create 5 profiles for no team and team 1, names are A, B, C ... for global and
- // tA, tB, tC ... for team 1. Alternate macOS and Windows profiles.
- for i := 0; i < 5; i++ {
- name := string('A' + byte(i))
- if i%2 == 0 {
- prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil)
- require.NoError(t, err)
- _, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof)
- require.NoError(t, err)
-
- tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil)
- require.NoError(t, err)
- tprof.TeamID = &tm1.ID
- _, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof)
- require.NoError(t, err)
- } else {
- _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(``)})
- require.NoError(t, err)
- _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(``)})
- require.NoError(t, err)
- }
- }
-
- // create a couple profiles (Win and mac) for team 2, and none for team 3
- tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil)
- require.NoError(t, err)
- tprof.TeamID = &tm2.ID
- tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof)
- require.NoError(t, err)
- // checksum is not returned by New..., so compute it manually
- checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test
- tm2ProfF.Checksum = checkSum[:]
-
- // make tm2ProfG a label-based profile
- lblFoo, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "foo", Query: "select 1"})
- require.NoError(t, err)
- lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"})
- require.NoError(t, err)
-
- tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{
- Name: "tG",
- TeamID: &tm2.ID,
- SyncML: []byte(``),
- Labels: []fleet.ConfigurationProfileLabel{
- {LabelID: lblFoo.ID, LabelName: lblFoo.Name},
- {LabelID: lblBar.ID, LabelName: lblBar.Name},
- },
- })
- require.NoError(t, err)
- // break lblFoo by deleting it
- require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name))
-
- // test that all fields are correctly returned with team 2
- var listResp listMDMConfigProfilesResponse
- s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID))
- require.Len(t, listResp.Profiles, 2)
- require.NotZero(t, listResp.Profiles[0].CreatedAt)
- require.NotZero(t, listResp.Profiles[0].UploadedAt)
- require.NotZero(t, listResp.Profiles[1].CreatedAt)
- require.NotZero(t, listResp.Profiles[1].UploadedAt)
- listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{}
- listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{}
- require.Equal(t, &fleet.MDMConfigProfilePayload{
- ProfileUUID: tm2ProfF.ProfileUUID,
- TeamID: tm2ProfF.TeamID,
- Name: tm2ProfF.Name,
- Platform: "darwin",
- Identifier: tm2ProfF.Identifier,
- Checksum: tm2ProfF.Checksum,
- Labels: nil,
- }, listResp.Profiles[0])
- require.Equal(t, &fleet.MDMConfigProfilePayload{
- ProfileUUID: tm2ProfG.ProfileUUID,
- TeamID: tm2ProfG.TeamID,
- Name: tm2ProfG.Name,
- Platform: "windows",
- // labels are ordered by name
- Labels: []fleet.ConfigurationProfileLabel{
- {LabelID: lblBar.ID, LabelName: lblBar.Name},
- {LabelID: 0, LabelName: lblFoo.Name, Broken: true},
- },
- }, listResp.Profiles[1])
-
- // get the specific label-based profile returns the information
- var getProfResp getMDMConfigProfileResponse
- s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfG.ProfileUUID, nil, http.StatusOK, &getProfResp)
- getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
- require.Equal(t, &fleet.MDMConfigProfilePayload{
- ProfileUUID: tm2ProfG.ProfileUUID,
- TeamID: tm2ProfG.TeamID,
- Name: tm2ProfG.Name,
- Platform: "windows",
- // labels are ordered by name
- Labels: []fleet.ConfigurationProfileLabel{
- {LabelID: lblBar.ID, LabelName: lblBar.Name},
- {LabelID: 0, LabelName: lblFoo.Name, Broken: true},
- },
- }, getProfResp.MDMConfigProfilePayload)
-
- // get the non label-based profile returns no labels
- getProfResp = getMDMConfigProfileResponse{}
- s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfF.ProfileUUID, nil, http.StatusOK, &getProfResp)
- getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{}
- require.Equal(t, &fleet.MDMConfigProfilePayload{
- ProfileUUID: tm2ProfF.ProfileUUID,
- TeamID: tm2ProfF.TeamID,
- Name: tm2ProfF.Name,
- Platform: "darwin",
- Identifier: tm2ProfF.Identifier,
- Checksum: tm2ProfF.Checksum,
- Labels: nil,
- }, getProfResp.MDMConfigProfilePayload)
-
- // list for a non-existing team returns 404
- s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999")
-
- cases := []struct {
- queries []string // alternate query name and value
- teamID *uint
- wantNames []string
- wantMeta *fleet.PaginationMetadata
- }{
- {
- wantNames: []string{"A", "B", "C", "D", "E"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
- },
- {
- queries: []string{"per_page", "2"},
- wantNames: []string{"A", "B"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
- },
- {
- queries: []string{"per_page", "2", "page", "1"},
- wantNames: []string{"C", "D"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true},
- },
- {
- queries: []string{"per_page", "2", "page", "2"},
- wantNames: []string{"E"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
- },
- {
- queries: []string{"per_page", "3"},
- teamID: &tm1.ID,
- wantNames: []string{"tA", "tB", "tC"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false},
- },
- {
- queries: []string{"per_page", "3", "page", "1"},
- teamID: &tm1.ID,
- wantNames: []string{"tD", "tE"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
- },
- {
- queries: []string{"per_page", "3", "page", "2"},
- teamID: &tm1.ID,
- wantNames: nil,
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true},
- },
- {
- queries: []string{"per_page", "3"},
- teamID: &tm2.ID,
- wantNames: []string{"tF", "tG"},
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
- },
- {
- queries: []string{"per_page", "2"},
- teamID: &tm3.ID,
- wantNames: nil,
- wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false},
- },
- }
- for _, c := range cases {
- t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) {
- var listResp listMDMConfigProfilesResponse
- queryArgs := c.queries
- if c.teamID != nil {
- queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID))
- }
- s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, queryArgs...)
-
- require.Equal(t, len(c.wantNames), len(listResp.Profiles))
- require.Equal(t, c.wantMeta, listResp.Meta)
-
- var gotNames []string
- if len(listResp.Profiles) > 0 {
- gotNames = make([]string, len(listResp.Profiles))
- for i, p := range listResp.Profiles {
- gotNames[i] = p.Name
- if p.Name == "tG" {
- require.Len(t, p.Labels, 2)
- } else {
- require.Nil(t, p.Labels)
- }
- if c.teamID == nil {
- // we set it to 0 for global
- require.NotNil(t, p.TeamID)
- require.Zero(t, *p.TeamID)
- } else {
- require.NotNil(t, p.TeamID)
- require.Equal(t, *c.teamID, *p.TeamID)
- }
- require.NotEmpty(t, p.Platform)
- }
- }
- require.Equal(t, c.wantNames, gotNames)
- })
- }
-}
-
// ///////////////////////////////////////////////////////////////////////////
// Common MDM config test
@@ -10708,18 +7525,19 @@ func (s *integrationMDMTestSuite) checkMDMProfilesSummaries(t *testing.T, teamID
if expectedAppleSummary != nil {
var apple getMDMAppleProfilesSummaryResponse
s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles/summary", getMDMAppleProfilesSummaryRequest{}, http.StatusOK, &apple, queryParams...)
- require.Equal(t, expectedSummary.Failed, apple.Failed)
- require.Equal(t, expectedSummary.Pending, apple.Pending)
- require.Equal(t, expectedSummary.Verifying, apple.Verifying)
- require.Equal(t, expectedSummary.Verified, apple.Verified)
+ fmt.Println(expectedSummary, apple)
+ require.Equal(t, expectedSummary.Failed, apple.Failed, "failed summary count doesn't match")
+ require.Equal(t, expectedSummary.Pending, apple.Pending, "pending summary count doesn't match")
+ require.Equal(t, expectedSummary.Verifying, apple.Verifying, "verifying summary count doesn't match")
+ require.Equal(t, expectedSummary.Verified, apple.Verified, "verified summary count doesn't match")
}
var combined getMDMProfilesSummaryResponse
s.DoJSON("GET", "/api/v1/fleet/configuration_profiles/summary", getMDMProfilesSummaryRequest{}, http.StatusOK, &combined, queryParams...)
- require.Equal(t, expectedSummary.Failed, combined.Failed)
- require.Equal(t, expectedSummary.Pending, combined.Pending)
- require.Equal(t, expectedSummary.Verifying, combined.Verifying)
- require.Equal(t, expectedSummary.Verified, combined.Verified)
+ require.Equal(t, expectedSummary.Failed, combined.Failed, "failed summary count doesn't match")
+ require.Equal(t, expectedSummary.Pending, combined.Pending, "pending summary count doesn't match")
+ require.Equal(t, expectedSummary.Verifying, combined.Verifying, "verifying summary count doesn't match")
+ require.Equal(t, expectedSummary.Verified, combined.Verified, "verified summary count doesn't match")
}
func (s *integrationMDMTestSuite) checkMDMDiskEncryptionSummaries(t *testing.T, teamID *uint, expectedSummary fleet.MDMDiskEncryptionSummary, checkFileVaultSummary bool) {
@@ -10749,870 +7567,6 @@ func (s *integrationMDMTestSuite) checkMDMDiskEncryptionSummaries(t *testing.T,
require.Equal(t, expectedSummary.RemovingEnforcement, combined.RemovingEnforcement)
}
-func (s *integrationMDMTestSuite) TestWindowsProfileManagement() {
- t := s.T()
- ctx := context.Background()
-
- err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
- require.NoError(t, err)
-
- globalProfiles := []string{
- mysql.InsertWindowsProfileForTest(t, s.ds, 0),
- mysql.InsertWindowsProfileForTest(t, s.ds, 0),
- mysql.InsertWindowsProfileForTest(t, s.ds, 0),
- }
-
- // create a new team
- tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
- require.NoError(t, err)
- teamProfiles := []string{
- mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
- mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
- }
-
- // create a non-Windows host
- _, err = s.ds.NewHost(context.Background(), &fleet.Host{
- ID: 1,
- OsqueryHostID: ptr.String("non-windows-host"),
- NodeKey: ptr.String("non-windows-host"),
- UUID: uuid.New().String(),
- Hostname: fmt.Sprintf("%sfoo.local.non.windows", t.Name()),
- Platform: "darwin",
- })
- require.NoError(t, err)
-
- // create a Windows host that's not enrolled into MDM
- _, err = s.ds.NewHost(context.Background(), &fleet.Host{
- ID: 2,
- OsqueryHostID: ptr.String("not-mdm-enrolled"),
- NodeKey: ptr.String("not-mdm-enrolled"),
- UUID: uuid.New().String(),
- Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()),
- Platform: "windows",
- })
- require.NoError(t, err)
-
- verifyHostProfileStatus := func(cmds []fleet.ProtoCmdOperation, wantStatus string) {
- for _, cmd := range cmds {
- var gotProfile struct {
- Status string `db:"status"`
- Retries int `db:"retries"`
- }
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `
- SELECT COALESCE(status, 'pending') as status, retries
- FROM host_mdm_windows_profiles
- WHERE command_uuid = ?`
- return sqlx.GetContext(context.Background(), q, &gotProfile, stmt, cmd.Cmd.CmdID.Value)
- })
-
- wantDeliveryStatus := fleet.WindowsResponseToDeliveryStatus(wantStatus)
- if gotProfile.Retries <= servermdm.MaxProfileRetries && wantDeliveryStatus == fleet.MDMDeliveryFailed {
- require.EqualValues(t, "pending", gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value)
- } else {
- require.EqualValues(t, wantDeliveryStatus, gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value)
- }
- }
- }
-
- verifyProfiles := func(device *mdmtest.TestWindowsMDMClient, n int, fail bool) {
- mdmResponseStatus := syncml.CmdStatusOK
- if fail {
- mdmResponseStatus = syncml.CmdStatusAtomicFailed
- }
- s.awaitTriggerProfileSchedule(t)
- cmds, err := device.StartManagementSession()
- require.NoError(t, err)
- // 2 Status + n profiles
- require.Len(t, cmds, n+2)
-
- var atomicCmds []fleet.ProtoCmdOperation
- msgID, err := device.GetCurrentMsgID()
- require.NoError(t, err)
- for _, c := range cmds {
- cmdID := c.Cmd.CmdID
- status := syncml.CmdStatusOK
- if c.Verb == "Atomic" {
- atomicCmds = append(atomicCmds, c)
- status = mdmResponseStatus
- require.NotEmpty(t, c.Cmd.ReplaceCommands)
- for _, rc := range c.Cmd.ReplaceCommands {
- require.NotEmpty(t, rc.CmdID)
- }
- }
- device.AppendResponse(fleet.SyncMLCmd{
- XMLName: xml.Name{Local: fleet.CmdStatus},
- MsgRef: &msgID,
- CmdRef: &cmdID.Value,
- Cmd: ptr.String(c.Verb),
- Data: &status,
- Items: nil,
- CmdID: fleet.CmdID{Value: uuid.NewString()},
- })
- }
- // TODO: verify profile contents as well
- require.Len(t, atomicCmds, n)
-
- // before we send the response, commands should be "pending"
- verifyHostProfileStatus(atomicCmds, "")
-
- cmds, err = device.SendResponse()
- require.NoError(t, err)
- // the ack of the message should be the only returned command
- require.Len(t, cmds, 1)
-
- // verify that we updated status in the db
- verifyHostProfileStatus(atomicCmds, mdmResponseStatus)
- }
-
- checkHostsProfilesMatch := func(host *fleet.Host, wantUUIDs []string) {
- var gotUUIDs []string
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `SELECT profile_uuid FROM host_mdm_windows_profiles WHERE host_uuid = ?`
- return sqlx.SelectContext(context.Background(), q, &gotUUIDs, stmt, host.UUID)
- })
- require.ElementsMatch(t, wantUUIDs, gotUUIDs)
- }
-
- checkHostDetails := func(t *testing.T, host *fleet.Host, wantProfs []string, wantStatus fleet.MDMDeliveryStatus) {
- var gotHostResp getHostResponse
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &gotHostResp)
- require.NotNil(t, gotHostResp.Host.MDM.Profiles)
- var gotProfs []string
- require.Len(t, *gotHostResp.Host.MDM.Profiles, len(wantProfs))
- for _, p := range *gotHostResp.Host.MDM.Profiles {
- gotProfs = append(gotProfs, strings.Replace(p.Name, "name-", "", 1))
- require.NotNil(t, p.Status)
- require.Equal(t, wantStatus, *p.Status, "profile", p.Name)
- require.Equal(t, "windows", p.Platform)
- // Fleet reserved profiles (e.g., OS updates) should be screened from the host details response
- require.NotContains(t, servermdm.ListFleetReservedWindowsProfileNames(), p.Name)
- }
- require.ElementsMatch(t, wantProfs, gotProfs)
- }
-
- checkHostsFilteredByOSSettingsStatus := func(t *testing.T, wantHosts []string, wantStatus fleet.MDMDeliveryStatus, teamID *uint, labels ...*fleet.Label) {
- var teamFilter string
- if teamID != nil {
- teamFilter = fmt.Sprintf("&team_id=%d", *teamID)
- }
- var gotHostsResp listHostsResponse
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
- require.NotNil(t, gotHostsResp.Hosts)
- var gotHosts []string
- for _, h := range gotHostsResp.Hosts {
- gotHosts = append(gotHosts, h.Hostname)
- }
- require.ElementsMatch(t, wantHosts, gotHosts)
-
- var countHostsResp countHostsResponse
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
- require.Equal(t, len(wantHosts), countHostsResp.Count)
-
- for _, l := range labels {
- gotHostsResp = listHostsResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/labels/%d/hosts?os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp)
- require.NotNil(t, gotHostsResp.Hosts)
- gotHosts = []string{}
- for _, h := range gotHostsResp.Hosts {
- gotHosts = append(gotHosts, h.Hostname)
- }
- require.ElementsMatch(t, wantHosts, gotHosts, "label", l.Name)
-
- countHostsResp = countHostsResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?label_id=%d&os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp)
- }
- }
-
- getProfileUUID := func(t *testing.T, profName string, teamID *uint) string {
- var profUUID string
- mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error {
- var globalOrTeamID uint
- if teamID != nil {
- globalOrTeamID = *teamID
- }
- return sqlx.GetContext(ctx, tx, &profUUID, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, profName)
- })
- require.NotNil(t, profUUID)
- return profUUID
- }
-
- checkHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, wantStatus fleet.MDMDeliveryStatus) {
- var gotStatus fleet.MDMDeliveryStatus
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_uuid = ?`
- err := sqlx.GetContext(context.Background(), q, &gotStatus, stmt, hostUUID, profUUID)
- return err
- })
- require.Equal(t, wantStatus, gotStatus)
- }
-
- // Create a host and then enroll to MDM.
- host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
- // trigger a profile sync
- verifyProfiles(mdmDevice, 3, false)
- checkHostsProfilesMatch(host, globalProfiles)
- checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
-
- // can't resend a profile while it is verifying
- res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusConflict)
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
-
- // create new label that includes host
- label := &fleet.Label{
- Name: t.Name() + "foo",
- Query: "select * from foo;",
- }
- label, err = s.ds.NewLabel(context.Background(), label)
- require.NoError(t, err)
- require.NoError(t, s.ds.RecordLabelQueryExecutions(ctx, host, map[uint]*bool{label.ID: ptr.Bool(true)}, time.Now(), false))
-
- // simulate osquery reporting host mdm details (host_mdm.enrolled = 1 is condition for
- // hosts filtering by os settings status and generating mdm profiles summaries)
- require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, ""))
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
- s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
- Verifying: 1,
- }, nil)
-
- // another sync shouldn't return profiles
- verifyProfiles(mdmDevice, 0, false)
-
- // make fleet add a Windows OS Updates profile
- acResp := appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &acResp)
- osUpdatesProf := getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, nil)
-
- // os updates is sent via a profiles commands
- verifyProfiles(mdmDevice, 1, false)
- checkHostsProfilesMatch(host, append(globalProfiles, osUpdatesProf))
- // but is hidden from host details response
- checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying)
-
- // os updates profile status doesn't matter for filtered hosts results or summaries
- checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying)
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
- s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
- Verifying: 1,
- }, nil)
- // force os updates profile to failed, doesn't impact filtered hosts results or summaries
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, osUpdatesProf)
- return err
- })
- checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed)
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label)
- s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
- Verifying: 1,
- }, nil)
- // force another profile to failed, does impact filtered hosts results and summaries
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, globalProfiles[0])
- return err
- })
- checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryFailed)
- checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerifying, nil, label) // expect no hosts
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, nil, label) // expect host
- s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{
- Failed: 1,
- Verifying: 0,
- }, nil)
-
- // can resend a profile after it has failed
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusAccepted)
- verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
- checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so it back to verifying
-
- // add the host to a team
- err = s.ds.AddHostsToTeam(ctx, &tm.ID, []uint{host.ID})
- require.NoError(t, err)
-
- // trigger a profile sync, device gets the team profile
- verifyProfiles(mdmDevice, 2, false)
- checkHostsProfilesMatch(host, teamProfiles)
- checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
-
- // set new team profiles (delete + addition)
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, teamProfiles[1])
- return err
- })
- teamProfiles = []string{
- teamProfiles[0],
- mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
- }
-
- // trigger a profile sync, device gets the team profile
- verifyProfiles(mdmDevice, 1, false)
-
- // check that we deleted the old profile in the DB
- checkHostsProfilesMatch(host, teamProfiles)
- checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
-
- // can't resend a profile while it is verifying
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusConflict)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.")
-
- // can't resend a profile from the wrong team
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, globalProfiles[0]), nil, http.StatusNotFound)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Unable to match profile to host.")
-
- // another sync shouldn't return profiles
- verifyProfiles(mdmDevice, 0, false)
-
- // set new team profiles (delete + addition)
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, teamProfiles[1])
- return err
- })
- teamProfiles = []string{
- teamProfiles[0],
- mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID),
- }
- // trigger a profile sync, this time fail the delivery
- verifyProfiles(mdmDevice, 1, true)
-
- // check that we deleted the old profile in the DB
- checkHostsProfilesMatch(host, teamProfiles)
-
- // a second sync gets the profile again, because of delivery retries.
- // Succeed that one
- verifyProfiles(mdmDevice, 1, false)
-
- // another sync shouldn't return profiles
- verifyProfiles(mdmDevice, 0, false)
-
- // make fleet add a Windows OS Updates profile
- tmResp := teamResponse{}
- s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &tmResp)
- osUpdatesProf = getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, &tm.ID)
-
- // os updates is sent via a profiles commands
- verifyProfiles(mdmDevice, 1, false)
- checkHostsProfilesMatch(host, append(teamProfiles, osUpdatesProf))
- // but is hidden from host details response
- checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying)
-
- // os updates profile status doesn't matter for filtered hosts results or summaries
- checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying)
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
- Verifying: 1,
- }, nil)
- // force os updates profile to failed, doesn't impact filtered hosts results or summaries
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, osUpdatesProf)
- return err
- })
- checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed)
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label)
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
- Verifying: 1,
- }, nil)
- // force another profile to failed, does impact filtered hosts results and summaries
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?`
- _, err := q.ExecContext(context.Background(), stmt, teamProfiles[0])
- return err
- })
- checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryFailed)
- checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerifying, &tm.ID, label) // expect no hosts
- checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, &tm.ID, label) // expect host
- s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{
- Failed: 1,
- Verifying: 0,
- }, nil)
-
- // can resend a profile after it has failed
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, teamProfiles[0]), nil, http.StatusAccepted)
- verifyProfiles(mdmDevice, 1, false) // trigger a profile sync, device gets the profile resent
- checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryVerifying) // profile was resent, so back to verifying
- s.lastActivityMatches(
- fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(),
- fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+teamProfiles[0]),
- 0)
-
- // add a macOS profile to the team
- mcUUID := "a" + uuid.NewString()
- prof := mcBytesForTest("name-"+mcUUID, "idenfifer-"+mcUUID, mcUUID)
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`
- _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, []byte("checksum-"+mcUUID))
- return err
- })
-
- // trigger a profile sync, device doesn't get the macOS profile
- verifyProfiles(mdmDevice, 0, false)
-
- // can't resend a macOS profile to a Windows host
- res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/resend/%s", host.ID, mcUUID), nil, http.StatusUnprocessableEntity)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Profile is not compatible with host platform")
-}
-
-func (s *integrationMDMTestSuite) TestAppConfigMDMWindowsProfiles() {
- t := s.T()
-
- // set the windows custom settings fields
- acResp := appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": {
- "windows_settings": {
- "custom_settings": [
- {"path": "foo", "labels": ["baz"]},
- {"path": "bar"}
- ]
- }
- }
- }`), http.StatusOK, &acResp)
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
-
- // check that they are returned by a GET /config
- acResp = appConfigResponse{}
- s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
-
- // patch without specifying the windows custom settings fields and an unrelated
- // field, should not remove them
- acResp = appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "enable_disk_encryption": true }
- }`), http.StatusOK, &acResp)
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
-
- // patch with explicitly empty windows custom settings fields, would remove
- // them but this is a dry-run
- acResp = appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "windows_settings": { "custom_settings": null } }
- }`), http.StatusOK, &acResp, "dry_run", "true")
- assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.WindowsSettings.CustomSettings.Value)
-
- // patch with explicitly empty windows custom settings fields, removes them
- acResp = appConfigResponse{}
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": { "windows_settings": { "custom_settings": null } }
- }`), http.StatusOK, &acResp)
- assert.Empty(t, acResp.MDM.WindowsSettings.CustomSettings.Value)
-}
-
-func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() {
- t := s.T()
-
- // create a team through the service so it initializes the agent ops
- teamName := t.Name() + "team1"
- team := &fleet.Team{
- Name: teamName,
- Description: "desc team1",
- }
- var createTeamResp teamResponse
- s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp)
- require.NotZero(t, createTeamResp.Team.ID)
- team = createTeamResp.Team
-
- rawTeamSpec := func(mdmValue string) json.RawMessage {
- return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue))
- }
-
- // set the windows custom settings fields
- var applyResp applyTeamSpecsResponse
- s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
- {
- "windows_settings": {
- "custom_settings": [
- {"path": "foo", "labels": ["baz"]},
- {"path": "bar"}
- ]
- }
- }
- `), http.StatusOK, &applyResp)
- require.Len(t, applyResp.TeamIDsByName, 1)
-
- // check that they are returned by a GET /config
- var teamResp getTeamResponse
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.ElementsMatch(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
-
- // patch without specifying the windows custom settings fields and an unrelated
- // field, should not remove them
- applyResp = applyTeamSpecsResponse{}
- s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp)
- require.Len(t, applyResp.TeamIDsByName, 1)
-
- // check that they are returned by a GET /config
- teamResp = getTeamResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.ElementsMatch(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
-
- // patch with explicitly empty windows custom settings fields, would remove
- // them but this is a dry-run
- applyResp = applyTeamSpecsResponse{}
- s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
- { "windows_settings": { "custom_settings": null } }
- `), http.StatusOK, &applyResp, "dry_run", "true")
- assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName)
-
- teamResp = getTeamResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.ElementsMatch(t, []fleet.MDMProfileSpec{{Path: "foo", Labels: []string{"baz"}}, {Path: "bar"}}, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
-
- // patch with explicitly empty windows custom settings fields, removes them
- applyResp = applyTeamSpecsResponse{}
- s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`
- { "windows_settings": { "custom_settings": null } }
- `), http.StatusOK, &applyResp)
- require.Len(t, applyResp.TeamIDsByName, 1)
-
- teamResp = getTeamResponse{}
- s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp)
- require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value)
-}
-
-func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() {
- t := s.T()
- ctx := context.Background()
-
- // create a new team
- tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
- require.NoError(t, err)
-
- // apply an empty set to no-team
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
- `{"team_id": null, "team_name": null}`,
- 0,
- )
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
- `{"team_id": null, "team_name": null}`,
- 0,
- )
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
- `{"team_id": null, "team_name": null}`,
- 0,
- )
-
- // apply to both team id and name
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
- http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name)
-
- // invalid team name
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil},
- http.StatusNotFound, "team_name", uuid.New().String())
-
- // duplicate PayloadDisplayName
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: "N2", Contents: mobileconfigForTest("N1", "I2")},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTest("D1")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
-
- // profiles with reserved macOS identifiers
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: p, Contents: mobileconfigForTest(p, p)},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTest("D1")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
- }
-
- // payloads with reserved types
- for p := range mobileconfig.FleetPayloadTypes() {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTest("D1")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
- }
-
- // payloads with reserved identifiers
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", p, "random", "")},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTest("D1")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
- }
-
- // profiles with forbidden declaration types
- for dt := range fleet.ForbiddenDeclTypes {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTestWithType("D1", dt)},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt)
- }
- // and one more for the software update declaration
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.")
-
- // invalid JSON
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: []byte(`{"foo":}`)},
- }}, http.StatusBadRequest, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "The file should include valid JSON")
-
- // profiles with reserved Windows location URIs
- // bitlocker
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: syncml.FleetBitLockerTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
-
- // os updates
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: syncml.FleetOSUpdateTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))},
- {Name: "N3", Contents: syncMLForTest("./Foo/Bar")},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
-
- // invalid windows tag
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N3", Contents: []byte(``)},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
-
- // invalid xml
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N3", Contents: []byte(`foo`)},
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
-
- // successfully apply windows and macOS a profiles for the team, but it's a dry run
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTest("D1")},
- }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true")
- s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
- s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
-
- // successfully apply for a team and verify activities
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{
- {Name: "N1", Contents: mobileconfigForTest("N1", "I1")},
- {Name: "N2", Contents: syncMLForTest("./Foo/Bar")},
- {Name: "N4", Contents: declarationForTest("D1")},
- }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
- s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
- s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
- 0,
- )
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
- 0,
- )
-
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
- 0,
- )
-
- // names cannot be duplicated across platforms
- declBytes := json.RawMessage(`{
- "Type": "com.apple.configuration.decl.foo",
- "Identifier": "com.fleet.config.foo",
- "Payload": {
- "ServiceType": "com.apple.bash",
- "DataAssetReference": "com.fleet.asset.bash"
- }}`)
- mcBytes := mobileconfigForTest("N1", "I1")
- winBytes := syncMLForTest("./Foo/Bar")
-
- for _, p := range []struct {
- payload []fleet.MDMProfileBatchPayload
- expectErr string
- }{
- {
- payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: winBytes}},
- expectErr: "More than one configuration profile have the same name 'N1' (Windows .xml file name or macOS .mobileconfig PayloadDisplayName).",
- },
- {
- payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}},
- expectErr: "More than one configuration profile have the same name 'N1' (macOS .json file name or Windows .xml file name).",
- },
- {
- payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}},
- expectErr: "More than one configuration profile have the same name 'N1' (macOS .json file name or macOS .mobileconfig PayloadDisplayName).",
- },
- } {
- // team profiles
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, p.expectErr)
- // no team profiles
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity)
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, p.expectErr)
- }
-}
-
-func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() {
- t := s.T()
- ctx := context.Background()
-
- // create a new team
- tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"})
- require.NoError(t, err)
-
- // apply an empty set to no-team
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
- `{"team_id": null, "team_name": null}`,
- 0,
- )
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
- `{"team_id": null, "team_name": null}`,
- 0,
- )
-
- // apply to both team id and name
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
- http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)), "team_name", tm.Name)
-
- // invalid team name
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil},
- http.StatusNotFound, "team_name", uuid.New().String())
-
- // duplicate PayloadDisplayName
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTest("N1", "I1"),
- "N2": mobileconfigForTest("N1", "I2"),
- "N3": syncMLForTest("./Foo/Bar"),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
-
- // profiles with reserved macOS identifiers
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTest("N1", "I1"),
- p: mobileconfigForTest(p, p),
- "N3": syncMLForTest("./Foo/Bar"),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p))
- }
-
- // payloads with reserved types
- for p := range mobileconfig.FleetPayloadTypes() {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTestWithContent("N1", "I1", "II1", p, ""),
- "N3": syncMLForTest("./Foo/Bar"),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p))
- }
-
- // payloads with reserved identifiers
- for p := range mobileconfig.FleetPayloadIdentifiers() {
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTestWithContent("N1", "I1", p, "random", ""),
- "N3": syncMLForTest("./Foo/Bar"),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p))
- }
-
- // profiles with reserved Windows location URIs
- // bitlocker
- res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTest("N1", "I1"),
- syncml.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI)),
- "N3": syncMLForTest("./Foo/Bar"),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg := extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Custom configuration profiles can't include BitLocker settings. To control these settings, use the mdm.enable_disk_encryption option.")
-
- // os updates
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTest("N1", "I1"),
- syncml.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI)),
- "N3": syncMLForTest("./Foo/Bar"),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.")
-
- // invalid windows tag
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N3": []byte(``),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
-
- // invalid xml
- res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N3": []byte(`foo`),
- }}, http.StatusUnprocessableEntity, "team_id", strconv.Itoa(int(tm.ID)))
- errMsg = extractServerErrorText(res.Body)
- require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.")
-
- // successfully apply windows and macOS a profiles for the team, but it's a dry run
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTest("N1", "I1"),
- "N2": syncMLForTest("./Foo/Bar"),
- }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)), "dry_run", "true")
- s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false)
- s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false)
-
- // successfully apply for a team and verify activities
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{
- "N1": mobileconfigForTest("N1", "I1"),
- "N2": syncMLForTest("./Foo/Bar"),
- }}, http.StatusNoContent, "team_id", strconv.Itoa(int(tm.ID)))
- s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true)
- s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true)
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedMacosProfile{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
- 0,
- )
- s.lastActivityOfTypeMatches(
- fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(),
- fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name),
- 0,
- )
-}
-
func (s *integrationMDMTestSuite) TestWindowsFreshEnrollEmptyQuery() {
t := s.T()
host, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
@@ -12280,10 +8234,6 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
s.DoJSON("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity, &applyResp)
}
-func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() {
- s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/enrollment_profiles/manual")
-}
-
func (s *integrationMDMTestSuite) TestDontIgnoreAnyProfileErrors() {
t := s.T()
ctx := context.Background()
@@ -12299,10 +8249,10 @@ func (s *integrationMDMTestSuite) TestDontIgnoreAnyProfileErrors() {
s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent)
s.awaitTriggerProfileSchedule(t)
- // The profiles should be associated with the host we made + the standard fleet config
+ // The profiles should be associated with the host we made + the standard fleet configs
profs, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
require.NoError(t, err)
- require.Len(t, profs, 3)
+ require.Len(t, profs, 4)
// Acknowledge the profiles so we can mark them as verified
cmd, err := mdmDevice.Idle()
@@ -12315,7 +8265,8 @@ func (s *integrationMDMTestSuite) TestDontIgnoreAnyProfileErrors() {
require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, host, map[string]*fleet.HostMacOSProfile{
"I1": {Identifier: "I1", DisplayName: "I1", InstallDate: time.Now()},
"I2": {Identifier: "I2", DisplayName: "I2", InstallDate: time.Now()},
- mobileconfig.FleetdConfigPayloadIdentifier: {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, DisplayName: "I2", InstallDate: time.Now()},
+ mobileconfig.FleetdConfigPayloadIdentifier: {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, DisplayName: "I2", InstallDate: time.Now()},
+ mobileconfig.FleetCARootConfigPayloadIdentifier: {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, DisplayName: "I2", InstallDate: time.Now()},
}))
// Check that the profile is marked as verified when fetching the host
@@ -12607,202 +8558,3 @@ func (s *integrationMDMTestSuite) TestIsServerBitlockerStatus() {
require.NotNil(t, hr.Host.MDM.OSSettings.DiskEncryption.Status)
require.Equal(t, fleet.DiskEncryptionEnforcing, *hr.Host.MDM.OSSettings.DiskEncryption.Status)
}
-
-func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() {
- t := s.T()
- ctx := context.Background()
-
- checkMacProfs := func(teamID *uint, names ...string) {
- var count int
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- var tid uint
- if teamID != nil {
- tid = *teamID
- }
- return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_apple_configuration_profiles WHERE team_id = ?`, tid)
- })
- require.Equal(t, len(names), count)
- for _, n := range names {
- s.assertMacOSConfigProfilesByName(teamID, n, true)
- }
- }
-
- checkMacDecls := func(teamID *uint, names ...string) {
- var count int
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- var tid uint
- if teamID != nil {
- tid = *teamID
- }
- return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_apple_declarations WHERE team_id = ?`, tid)
- })
- require.Equal(t, len(names), count)
- for _, n := range names {
- s.assertMacOSDeclarationsByName(teamID, n, true)
- }
- }
-
- checkWinProfs := func(teamID *uint, names ...string) {
- var count int
- mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
- var tid uint
- if teamID != nil {
- tid = *teamID
- }
- return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tid)
- })
- require.Equal(t, len(names), count)
- for _, n := range names {
- s.assertWindowsConfigProfilesByName(teamID, n, true)
- }
- }
-
- acResp := appConfigResponse{}
- s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp)
- require.True(t, acResp.MDM.EnabledAndConfigured)
- require.True(t, acResp.MDM.WindowsEnabledAndConfigured)
-
- // ensures that the fleetd profile is created
- secrets, err := s.ds.GetEnrollSecrets(ctx, nil)
- require.NoError(t, err)
- if len(secrets) == 0 {
- require.NoError(t, s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}))
- }
- require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger))
-
- // turn on disk encryption and os updates
- s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
- "mdm": {
- "enable_disk_encryption": true,
- "windows_updates": {
- "deadline_days": 3,
- "grace_period_days": 1
- },
- "macos_updates": {
- "deadline": "2023-12-31",
- "minimum_version": "13.3.6"
- }
- }
- }`), http.StatusOK, &acResp)
- checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
- checkMacDecls(nil, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
-
- // batch set only windows profiles doesn't remove the reserved names
- newWinProfile := syncml.ForTestWithData(map[string]string{"l1": "d1"})
- var testProfiles []fleet.MDMProfileBatchPayload
- testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
- Name: "n1",
- Contents: newWinProfile,
- })
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...)
- checkMacDecls(nil, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
-
- // batch set windows and mac profiles doesn't remove the reserved names
- newMacProfile := mcBytesForTest("n2", "i2", uuid.NewString())
- testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{
- Name: "n2",
- Contents: newMacProfile,
- })
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
- checkMacDecls(nil, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
-
- // batch set only mac profiles and declaration doesn't remove the reserved names
- newMacDecl := []byte(fmt.Sprintf(`{
- "Type": "com.apple.configuration.foo",
- "Payload": {
- "Echo": "f1337"
- },
- "Identifier": "%s"
-}`, uuid.NewString()))
- testProfiles = []fleet.MDMProfileBatchPayload{{
- Name: "n2",
- Contents: newMacProfile,
- }, {
- Name: "n3",
- Contents: newMacDecl,
- }}
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent)
- checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
- checkMacDecls(nil, append(servermdm.ListFleetReservedMacOSDeclarationNames(), "n3")...)
- checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...)
-
- // create a team
- var tmResp teamResponse
- s.DoJSON("POST", "/api/v1/fleet/teams", map[string]string{"Name": t.Name()}, http.StatusOK, &tmResp)
-
- // edit team mdm config to turn on disk encryption and os updates
- s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tmResp.Team.ID), modifyTeamRequest{
- TeamPayload: fleet.TeamPayload{
- Name: ptr.String(t.Name()),
- MDM: &fleet.TeamPayloadMDM{
- EnableDiskEncryption: optjson.SetBool(true),
- WindowsUpdates: &fleet.WindowsUpdates{
- DeadlineDays: optjson.SetInt(4),
- GracePeriodDays: optjson.SetInt(1),
- },
- MacOSUpdates: &fleet.MacOSUpdates{
- Deadline: optjson.SetString("2023-12-31"),
- MinimumVersion: optjson.SetString("13.3.9"),
- },
- },
- },
- }, http.StatusOK, &teamResponse{})
-
- s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/teams/%d", tmResp.Team.ID), nil, http.StatusOK, &tmResp)
- require.True(t, tmResp.Team.Config.MDM.EnableDiskEncryption)
- require.Equal(t, 4, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value)
- require.Equal(t, 1, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value)
- require.Equal(t, "2023-12-31", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value)
- require.Equal(t, "13.3.9", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value)
-
- require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger))
-
- checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
- checkMacDecls(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
-
- // batch set only windows profiles doesn't remove the reserved names
- var testTeamProfiles []fleet.MDMProfileBatchPayload
- testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
- Name: "n1",
- Contents: newWinProfile,
- })
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
- checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
- checkMacDecls(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
-
- // batch set windows and mac profiles doesn't remove the reserved names
- testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{
- Name: "n2",
- Contents: newMacProfile,
- })
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
- checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
- checkMacDecls(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...)
-
- // batch set only mac profiles and declaration doesn't remove the reserved names
- testTeamProfiles = []fleet.MDMProfileBatchPayload{{
- Name: "n2",
- Contents: newMacProfile,
- }, {
- Name: "n3",
- Contents: newMacDecl,
- }}
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
- checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...)
- checkMacDecls(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSDeclarationNames(), "n3")...)
- checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
-
- // batch set with an empty set still doesn't remove the Fleet-controlled profiles
- s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{}, http.StatusNoContent, "team_id", strconv.Itoa(int(tmResp.Team.ID)))
- checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...)
- checkMacDecls(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSDeclarationNames()...)
- checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...)
-}
diff --git a/server/service/service.go b/server/service/service.go
index 5f3d0c0d47..78cc77a3d8 100644
--- a/server/service/service.go
+++ b/server/service/service.go
@@ -141,7 +141,7 @@ func NewService(
mdmStorage: mdmStorage,
mdmPushService: mdmPushService,
mdmPushCertTopic: mdmPushCertTopic,
- mdmAppleCommander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService),
+ mdmAppleCommander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService, config.MDM),
cronSchedulesService: cronSchedulesService,
wstepCertManager: wstepCertManager,
}
diff --git a/server/service/testing_utils.go b/server/service/testing_utils.go
index b42987b0a6..56aeb0df0c 100644
--- a/server/service/testing_utils.go
+++ b/server/service/testing_utils.go
@@ -182,7 +182,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
mailer,
c,
depStorage,
- apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher),
+ apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher, fleetConfig.MDM),
"",
ssoStore,
profMatcher,
@@ -344,7 +344,7 @@ func RunServerForTestsWithDS(t *testing.T, ds fleet.Datastore, opts ...*TestServ
logger,
&MDMAppleCheckinAndCommandService{
ds: ds,
- commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher),
+ commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPusher, cfg.MDM),
logger: kitlog.NewNopLogger(),
},
&MDMAppleDDMService{
diff --git a/server/worker/apple_mdm_test.go b/server/worker/apple_mdm_test.go
index 0f597102b2..fd42b97027 100644
--- a/server/worker/apple_mdm_test.go
+++ b/server/worker/apple_mdm_test.go
@@ -8,6 +8,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/pkg/optjson"
+ "github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
@@ -151,7 +152,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -184,7 +185,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -221,7 +222,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -262,7 +263,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -313,7 +314,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -365,7 +366,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -402,7 +403,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -444,7 +445,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -499,7 +500,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)
@@ -535,7 +536,7 @@ func TestAppleMDM(t *testing.T) {
mdmWorker := &AppleMDM{
Datastore: ds,
Log: nopLog,
- Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}),
+ Commander: apple_mdm.NewMDMAppleCommander(mdmStorage, mockPusher{}, config.MDMConfig{}),
}
w := NewWorker(ds, nopLog)
w.Register(mdmWorker)