From e39f5bce3b52133affcbaeaebc42db1c0bfa8216 Mon Sep 17 00:00:00 2001 From: Roberto Dip Date: Thu, 18 Apr 2024 18:01:37 -0300 Subject: [PATCH] implement logic to sign apple profiles (#16490) #10418 --- cmd/fleet/cron.go | 13 +- cmd/fleet/serve.go | 11 +- ee/server/service/mdm.go | 6 + pkg/mdm/mdmtest/apple.go | 18 +- server/datastore/mysql/apple_mdm.go | 8 +- server/datastore/mysql/apple_mdm_test.go | 3 +- server/fleet/apple_mdm_test.go | 5 + server/mdm/apple/commander.go | 14 +- server/mdm/apple/commander_test.go | 16 +- server/mdm/apple/mobileconfig/mobileconfig.go | 32 +- .../apple/mobileconfig/mobileconfig_test.go | 110 + server/mdm/apple/mobileconfig/profiles.go | 49 + server/mdm/mdm.go | 7 +- server/service/apple_mdm.go | 43 +- server/service/apple_mdm_test.go | 102 +- server/service/devices.go | 13 +- ...dm_test.go => integration_mdm_ddm_test.go} | 0 server/service/integration_mdm_dep_test.go | 11 +- .../service/integration_mdm_profiles_test.go | 4323 ++++++++++++++++ server/service/integration_mdm_test.go | 4358 +---------------- server/service/service.go | 2 +- server/service/testing_utils.go | 4 +- server/worker/apple_mdm_test.go | 21 +- 23 files changed, 4791 insertions(+), 4378 deletions(-) create mode 100644 server/mdm/apple/mobileconfig/mobileconfig_test.go rename server/service/{integration_ddm_test.go => integration_mdm_ddm_test.go} (100%) create mode 100644 server/service/integration_mdm_profiles_test.go 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)