diff --git a/changes/28762-batch-resend-profile-to-hosts b/changes/28762-batch-resend-profile-to-hosts new file mode 100644 index 0000000000..2f07eedcc0 --- /dev/null +++ b/changes/28762-batch-resend-profile-to-hosts @@ -0,0 +1 @@ +* Added the endpoint `POST /api/v1/fleet/configuration_profiles/resend/batch` to resend a profile to all hosts that satisfy the filter. diff --git a/docs/Contributing/Audit-logs.md b/docs/Contributing/Audit-logs.md index dc2e5f83ba..b0a26086f2 100644 --- a/docs/Contributing/Audit-logs.md +++ b/docs/Contributing/Audit-logs.md @@ -1185,7 +1185,7 @@ This activity contains the following fields: ## resent_configuration_profile -Generated when a user resends an MDM configuration profile to a host. +Generated when a user resends a configuration profile to a host. This activity contains the following fields: - "host_id": The ID of the host. @@ -1202,6 +1202,23 @@ This activity contains the following fields: } ``` +## resent_configuration_profile_batch + +Generated when a user resends a configuration profile to a batch of hosts. + +This activity contains the following fields: +- "profile_name": The name of the configuration profile. +- "host_count": Number of hosts in the batch. + +#### Example + +```json +{ + "profile_name": "Passcode requirements", + "host_count": 3 +} +``` + ## installed_software Generated when a Fleet-maintained app or custom package is installed on a host. diff --git a/server/datastore/mysql/mdm.go b/server/datastore/mysql/mdm.go index 54db2b32c5..dec20185fb 100644 --- a/server/datastore/mysql/mdm.go +++ b/server/datastore/mysql/mdm.go @@ -1615,3 +1615,24 @@ func batchSetProfileVariableAssociationsDB( } return nil } + +func (ds *Datastore) BatchResendMDMProfileToHosts(ctx context.Context, profileUUID string, filters fleet.BatchResendMDMProfileFilters) (int64, error) { + table, column, err := getTableAndColumnNameForHostMDMProfileUUID(profileUUID) + if err != nil { + return 0, ctxerr.Wrap(ctx, err, "getting table and column") + } + + // update the status to NULL to trigger resending on the next cron run + updateStmt := fmt.Sprintf(`UPDATE %s SET status = NULL WHERE %s = ? AND status = ?`, table, column) + + var count int64 + err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + res, err := tx.ExecContext(ctx, updateStmt, profileUUID, filters.ProfileStatus) + if err != nil { + return ctxerr.Wrap(ctx, err, "resending MDM profile on hosts") + } + count, _ = res.RowsAffected() + return nil + }) + return count, err +} diff --git a/server/datastore/mysql/mdm_test.go b/server/datastore/mysql/mdm_test.go index ea501de744..3b2af93a86 100644 --- a/server/datastore/mysql/mdm_test.go +++ b/server/datastore/mysql/mdm_test.go @@ -49,6 +49,7 @@ func TestMDMShared(t *testing.T) { {"TestAreHostsConnectedToFleetMDM", testAreHostsConnectedToFleetMDM}, {"TestBulkSetPendingMDMHostProfilesExcludeAny", testBulkSetPendingMDMHostProfilesExcludeAny}, {"TestBulkSetPendingMDMHostProfilesLotsOfHosts", testBulkSetPendingMDMWindowsHostProfilesLotsOfHosts}, + {"TestBatchResendProfileToHosts", testBatchResendProfileToHosts}, } for _, c := range cases { @@ -7707,3 +7708,170 @@ func testBulkSetPendingMDMWindowsHostProfilesLotsOfHosts(t *testing.T, ds *Datas _, err := ds.bulkSetPendingMDMWindowsHostProfilesDB(ctx, ds.writer(ctx), hostUUIDs, nil) require.NoError(t, err) } + +func testBatchResendProfileToHosts(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // create some hosts and some profiles + host1 := test.NewHost(t, ds, "host1", "1", "h1key", "host1uuid", time.Now()) + host2 := test.NewHost(t, ds, "host2", "2", "h2key", "host2uuid", time.Now()) + host3 := test.NewHost(t, ds, "host3", "3", "h3key", "host3uuid", time.Now()) + host4 := test.NewHost(t, ds, "host4", "4", "h4key", "host4uuid", time.Now()) + + // create a team and make host4 part of that team + team, err := ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) + require.NoError(t, err) + err = ds.AddHostsToTeam(ctx, &team.ID, []uint{host4.ID}) + require.NoError(t, err) + + // create some profiles , a and b for no team, c for team + profA, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("a", "a", 0), nil) + require.NoError(t, err) + profB, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("b", "b", 0), nil) + require.NoError(t, err) + profC, err := ds.NewMDMAppleConfigProfile(ctx, *generateCP("c", "c", team.ID), nil) + require.NoError(t, err) + + t.Logf("profA=%s, profB=%s, profC=%s", profA.ProfileUUID, profB.ProfileUUID, profC.ProfileUUID) + + assertHostProfileStatus(t, ds, host1.UUID) + assertHostProfileStatus(t, ds, host2.UUID) + assertHostProfileStatus(t, ds, host3.UUID) + assertHostProfileStatus(t, ds, host4.UUID) + + // make profile A installed for all no team + forceSetHostProfileStatus(t, ds, host1.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + forceSetHostProfileStatus(t, ds, host2.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + forceSetHostProfileStatus(t, ds, host3.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) + + // batch-resend profile A, does not impact any host + n, err := ds.BatchResendMDMProfileToHosts(ctx, profA.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 0, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying}) + assertHostProfileStatus(t, ds, host4.UUID) + + // batch-resend profile B, does not impact any host as it's not delievered to any yet + n, err = ds.BatchResendMDMProfileToHosts(ctx, profB.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 0, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerifying}) + assertHostProfileStatus(t, ds, host4.UUID) + + // make profile A failed on a couple hosts, verified on the other + forceSetHostProfileStatus(t, ds, host1.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) + forceSetHostProfileStatus(t, ds, host2.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) + forceSetHostProfileStatus(t, ds, host3.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) + + // batch-resend profile A, impacts host 1 and 2 + n, err = ds.BatchResendMDMProfileToHosts(ctx, profA.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 2, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host4.UUID) + + // batch-resend profile A again, no change as it is already pending on the impacted hosts + n, err = ds.BatchResendMDMProfileToHosts(ctx, profA.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 0, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host4.UUID) + + // make profile B failed on all hosts + forceSetHostProfileStatus(t, ds, host1.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) + forceSetHostProfileStatus(t, ds, host2.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) + forceSetHostProfileStatus(t, ds, host3.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryFailed}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryFailed}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryFailed}) + assertHostProfileStatus(t, ds, host4.UUID) + + // batch-resend profile B, all hosts affected + n, err = ds.BatchResendMDMProfileToHosts(ctx, profB.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 3, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryPending}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryPending}) + assertHostProfileStatus(t, ds, host4.UUID) + + // make profile C failed on host 4, other profiles Verified + forceSetHostProfileStatus(t, ds, host4.UUID, profC, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) + forceSetHostProfileStatus(t, ds, host1.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) + forceSetHostProfileStatus(t, ds, host2.UUID, profA, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) + forceSetHostProfileStatus(t, ds, host1.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) + forceSetHostProfileStatus(t, ds, host2.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) + forceSetHostProfileStatus(t, ds, host3.UUID, profB, fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) + + // batch-resend profile C, host 4 affected + n, err = ds.BatchResendMDMProfileToHosts(ctx, profC.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 1, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host4.UUID, + hostProfileStatus{profC.ProfileUUID, fleet.MDMDeliveryPending}) + + // batch-resend profile C again, no change + n, err = ds.BatchResendMDMProfileToHosts(ctx, profC.ProfileUUID, fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + require.NoError(t, err) + require.EqualValues(t, 0, n) + + assertHostProfileStatus(t, ds, host1.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host2.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host3.UUID, + hostProfileStatus{profA.ProfileUUID, fleet.MDMDeliveryVerified}, + hostProfileStatus{profB.ProfileUUID, fleet.MDMDeliveryVerified}) + assertHostProfileStatus(t, ds, host4.UUID, + hostProfileStatus{profC.ProfileUUID, fleet.MDMDeliveryPending}) +} diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 3cfa02a5e0..7b627befec 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -173,6 +173,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeEditedDeclarationProfile{}, ActivityTypeResentConfigurationProfile{}, + ActivityTypeResentConfigurationProfileBatch{}, ActivityTypeInstalledSoftware{}, ActivityTypeUninstalledSoftware{}, @@ -1694,7 +1695,7 @@ func (a ActivityTypeResentConfigurationProfile) ActivityName() string { } func (a ActivityTypeResentConfigurationProfile) Documentation() (activity string, details string, detailsExample string) { - return `Generated when a user resends an MDM configuration profile to a host.`, + return `Generated when a user resends a configuration profile to a host.`, `This activity contains the following fields: - "host_id": The ID of the host. - "host_display_name": The display name of the host. @@ -1705,6 +1706,25 @@ func (a ActivityTypeResentConfigurationProfile) Documentation() (activity string }` } +type ActivityTypeResentConfigurationProfileBatch struct { + ProfileName string `json:"profile_name"` + HostCount int64 `json:"host_count"` +} + +func (a ActivityTypeResentConfigurationProfileBatch) ActivityName() string { + return "resent_configuration_profile_batch" +} + +func (a ActivityTypeResentConfigurationProfileBatch) Documentation() (activity string, details string, detailsExample string) { + return `Generated when a user resends a configuration profile to a batch of hosts.`, + `This activity contains the following fields: +- "profile_name": The name of the configuration profile. +- "host_count": Number of hosts in the batch.`, `{ + "profile_name": "Passcode requirements", + "host_count": 3 +}` +} + type ActivityTypeInstalledSoftware struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index cf7c50630e..8bd090aad8 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1586,6 +1586,11 @@ type Datastore interface { // to be resent upon the next cron run. ResendHostMDMProfile(ctx context.Context, hostUUID string, profileUUID string) error + // BatchResendMDMProfileToHosts updates the profile status to NULL for the + // matching hosts that satisfy the filter, thereby triggering the profile to + // be resent upon the next cron run. + BatchResendMDMProfileToHosts(ctx context.Context, profileUUID string, filters BatchResendMDMProfileFilters) (int64, error) + // GetHostMDMProfileInstallStatus returns the status of the profile for the host. GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (MDMDeliveryStatus, error) diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index e7102b693c..b1316f16be 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -988,3 +988,9 @@ type MDMProfileIdentifierFleetVariables struct { // findFleetVariables). FleetVariables []string } + +// BatchResendMDMProfileFilters represents the filters to apply to hosts for +// batch-redelivery of an MDM profile. +type BatchResendMDMProfileFilters struct { + ProfileStatus MDMDeliveryStatus +} diff --git a/server/fleet/service.go b/server/fleet/service.go index c932adf80c..08a6db8ee2 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1107,6 +1107,10 @@ type Service interface { // ResendHostMDMProfile resends the MDM profile to the host. ResendHostMDMProfile(ctx context.Context, hostID uint, profileUUID string) error + // BatchResendMDMProfileToHosts resends an MDM profile to the hosts that + // satisfy the specified filters. + BatchResendMDMProfileToHosts(ctx context.Context, profileUUID string, filters BatchResendMDMProfileFilters) error + /////////////////////////////////////////////////////////////////////////////// // Host Script Execution diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index c14fe9b7ff..6de1b98938 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1046,6 +1046,8 @@ type ListMDMConfigProfilesFunc func(ctx context.Context, teamID *uint, opt fleet type ResendHostMDMProfileFunc func(ctx context.Context, hostUUID string, profileUUID string) error +type BatchResendMDMProfileToHostsFunc func(ctx context.Context, profileUUID string, filters fleet.BatchResendMDMProfileFilters) (int64, error) + type GetHostMDMProfileInstallStatusFunc func(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error) type GetLinuxDiskEncryptionSummaryFunc func(ctx context.Context, teamID *uint) (fleet.MDMLinuxDiskEncryptionSummary, error) @@ -2875,6 +2877,9 @@ type DataStore struct { ResendHostMDMProfileFunc ResendHostMDMProfileFunc ResendHostMDMProfileFuncInvoked bool + BatchResendMDMProfileToHostsFunc BatchResendMDMProfileToHostsFunc + BatchResendMDMProfileToHostsFuncInvoked bool + GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFunc GetHostMDMProfileInstallStatusFuncInvoked bool @@ -6900,6 +6905,13 @@ func (s *DataStore) ResendHostMDMProfile(ctx context.Context, hostUUID string, p return s.ResendHostMDMProfileFunc(ctx, hostUUID, profileUUID) } +func (s *DataStore) BatchResendMDMProfileToHosts(ctx context.Context, profileUUID string, filters fleet.BatchResendMDMProfileFilters) (int64, error) { + s.mu.Lock() + s.BatchResendMDMProfileToHostsFuncInvoked = true + s.mu.Unlock() + return s.BatchResendMDMProfileToHostsFunc(ctx, profileUUID, filters) +} + func (s *DataStore) GetHostMDMProfileInstallStatus(ctx context.Context, hostUUID string, profileUUID string) (fleet.MDMDeliveryStatus, error) { s.mu.Lock() s.GetHostMDMProfileInstallStatusFuncInvoked = true diff --git a/server/service/handler.go b/server/service/handler.go index 4923833765..2fb6fab07e 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -707,6 +707,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // POST /hosts/{host_id:[0-9]+}/configuration_profiles/{profile_uuid}/resend endpoint. mdmAnyMW.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/configuration_profiles/resend/{profile_uuid}", resendHostMDMProfileEndpoint, resendHostMDMProfileRequest{}) mdmAnyMW.POST("/api/_version_/fleet/hosts/{host_id:[0-9]+}/configuration_profiles/{profile_uuid}/resend", resendHostMDMProfileEndpoint, resendHostMDMProfileRequest{}) + mdmAnyMW.POST("/api/_version_/fleet/configuration_profiles/resend/batch", batchResendMDMProfileToHostsEndpoint, batchResendMDMProfileToHostsRequest{}) // Deprecated: PATCH /mdm/apple/settings is deprecated, replaced by POST /disk_encryption. // It was only used to set disk encryption. diff --git a/server/service/integration_mdm_profiles_test.go b/server/service/integration_mdm_profiles_test.go index 3a55710489..213e32dfab 100644 --- a/server/service/integration_mdm_profiles_test.go +++ b/server/service/integration_mdm_profiles_test.go @@ -5909,3 +5909,191 @@ func (s *integrationMDMTestSuite) TestAppleProfileDeletion() { require.NoError(t, err) assert.Len(t, profiles, 3) } + +func (s *integrationMDMTestSuite) TestBatchResendMDMProfiles() { + t := s.T() + s.setSkipWorkerJobs(t) + + // create a few hosts + host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) + host3, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + + forceSetAppleHostProfileStatus := func(hostUUID string, profile *fleet.MDMConfigProfilePayload, profileContent []byte, status fleet.MDMDeliveryStatus) { + ctx := t.Context() + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_profiles + (profile_identifier, host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid) + VALUES + (?, ?, ?, ?, ?, ?, UNHEX(MD5(?)), ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + operation_type = VALUES(operation_type) + `, + profile.Identifier, hostUUID, status, "install", uuid.NewString(), profile.Name, profileContent, profile.ProfileUUID) + return err + }) + } + + forceSetWindowsHostProfileStatus := func(hostUUID string, profile *fleet.MDMConfigProfilePayload, profileContent []byte, status fleet.MDMDeliveryStatus) { + ctx := t.Context() + mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { + _, err := q.ExecContext(ctx, `INSERT INTO host_mdm_windows_profiles + (host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid) + VALUES + (?, ?, ?, ?, ?, UNHEX(MD5(?)), ?) + ON DUPLICATE KEY UPDATE + status = VALUES(status), + operation_type = VALUES(operation_type) + `, + hostUUID, status, "install", uuid.NewString(), profile.Name, profileContent, profile.ProfileUUID) + return err + }) + } + + // register a couple profiles for Apple and one for Windows + profN1 := mobileconfigForTest("N1", "I1") + profN2 := mobileconfigForTest("N2", "I2") + profN3 := syncMLForTest("./Foo/N3") + declN4 := declarationForTest("N4") + batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "N1", Contents: profN1}, + {Name: "N2", Contents: profN2}, + {Name: "N3", Contents: profN3}, + {Name: "N4", Contents: declN4}, + }} + s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent) + + // list the profiles to get the UUIDs + var listResp listMDMConfigProfilesResponse + s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) + profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload) + for _, prof := range listResp.Profiles { + profNameToPayload[prof.Name] = prof + } + + // try to batch-resend a non-existing profile + batchReq := batchResendMDMProfileToHostsRequest{ProfileUUID: "zzzz"} // not a known prefix + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound) + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "azzzz"} // unknown Apple profile + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound) + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "wzzzz"} // unknown Windows profile + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound) + + // batch-resend with an invalid filter + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID} + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryPending) + res := s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest) + msg := extractServerErrorText(res.Body) + require.Contains(t, msg, "Invalid profile_status filter value, only 'failed' is currently supported.") + + // batch-resend with an Apple DDM + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N4"].ProfileUUID} + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + res = s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest) + msg = extractServerErrorText(res.Body) + require.Contains(t, msg, "Can't resend declaration (DDM) profiles.") + + // batch-resend an Apple and a Windows profile, does nothing as it is not delivered yet + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID} + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID} + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) + + forceSetAppleHostProfileStatus(host1.UUID, profNameToPayload["N1"], profN1, fleet.MDMDeliveryPending) + forceSetAppleHostProfileStatus(host1.UUID, profNameToPayload["N2"], profN2, fleet.MDMDeliveryPending) + forceSetAppleHostProfileStatus(host2.UUID, profNameToPayload["N1"], profN1, fleet.MDMDeliveryPending) + forceSetAppleHostProfileStatus(host2.UUID, profNameToPayload["N2"], profN2, fleet.MDMDeliveryPending) + forceSetWindowsHostProfileStatus(host3.UUID, profNameToPayload["N3"], profN3, fleet.MDMDeliveryPending) + + s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ + host1: { + {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + host2: { + {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + }) + s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ + host3: { + {Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + }) + + // acknowledge the Apple profiles, failing I2 on both hosts, and fail the Windows one + forceSetAppleHostProfileStatus(host1.UUID, profNameToPayload["N1"], profN1, fleet.MDMDeliveryVerifying) + forceSetAppleHostProfileStatus(host1.UUID, profNameToPayload["N2"], profN2, fleet.MDMDeliveryFailed) + forceSetAppleHostProfileStatus(host2.UUID, profNameToPayload["N1"], profN1, fleet.MDMDeliveryVerifying) + forceSetAppleHostProfileStatus(host2.UUID, profNameToPayload["N2"], profN2, fleet.MDMDeliveryFailed) + forceSetWindowsHostProfileStatus(host3.UUID, profNameToPayload["N3"], profN3, fleet.MDMDeliveryFailed) + + // batch-resend N2 profile + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N2"].ProfileUUID} + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) + s.lastActivityOfTypeMatches( + fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(), + fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N2", 2), + 0, + ) + + s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ + host1: { + {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + host2: { + {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + }) + s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ + host3: { + {Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryFailed}, + }, + }) + + // set I2/N2 as verifying + forceSetAppleHostProfileStatus(host1.UUID, profNameToPayload["N2"], profN2, fleet.MDMDeliveryVerifying) + forceSetAppleHostProfileStatus(host2.UUID, profNameToPayload["N2"], profN2, fleet.MDMDeliveryVerifying) + + // batch-resend N3 profile + batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID} + batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) + s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) + + s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ + host1: { + {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + host2: { + {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, + {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + }) + s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ + host3: { + {Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, + }, + }) + + s.lastActivityOfTypeMatches( + fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(), + fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N3", 1), + 0, + ) +} diff --git a/server/service/mdm.go b/server/service/mdm.go index ae2f6db52b..ea1781ad10 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -2805,3 +2805,100 @@ func (svc *Service) DeleteMDMAppleAPNSCert(ctx context.Context) error { return svc.ds.SaveAppConfig(ctx, appCfg) } + +//////////////////////////////////////////////////////////////////////////////// +// POST /configuration_profiles/resend/batch +//////////////////////////////////////////////////////////////////////////////// + +type batchResendMDMProfileToHostsRequest struct { + ProfileUUID string `json:"profile_uuid"` + Filters struct { + ProfileStatus string `json:"profile_status"` + } `json:"filters"` +} + +type batchResendMDMProfileToHostsResponse struct { + Err error `json:"error,omitempty"` +} + +func (r batchResendMDMProfileToHostsResponse) Error() error { return r.Err } + +func (r batchResendMDMProfileToHostsResponse) Status() int { return http.StatusAccepted } + +func batchResendMDMProfileToHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*batchResendMDMProfileToHostsRequest) + + if err := svc.BatchResendMDMProfileToHosts(ctx, req.ProfileUUID, fleet.BatchResendMDMProfileFilters{ + ProfileStatus: fleet.MDMDeliveryStatus(req.Filters.ProfileStatus), + }); err != nil { + return batchResendMDMProfileToHostsResponse{Err: err}, nil + } + return batchResendMDMProfileToHostsResponse{}, nil +} + +func (svc *Service) BatchResendMDMProfileToHosts(ctx context.Context, profileUUID string, filters fleet.BatchResendMDMProfileFilters) error { + // do a basic authz check that before we can make a more specific check based + // on the team of the profile (just to ensure the user has _some_ + // authorization before loading the profile). + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil { + return ctxerr.Wrap(ctx, err) + } + + switch filters.ProfileStatus { + case fleet.MDMDeliveryFailed: + // ok, only supported filter for now + default: + return &fleet.BadRequestError{ + Message: "Invalid profile_status filter value, only 'failed' is currently supported.", + } + } + + // get the profile to get the team it belongs to + var ( + teamID *uint + profileName string + ) + switch { + case strings.HasPrefix(profileUUID, fleet.MDMAppleProfileUUIDPrefix): + prof, err := svc.ds.GetMDMAppleConfigProfile(ctx, profileUUID) + if err != nil { + return err + } + teamID = prof.TeamID + profileName = prof.Name + case strings.HasPrefix(profileUUID, fleet.MDMWindowsProfileUUIDPrefix): + prof, err := svc.ds.GetMDMWindowsConfigProfile(ctx, profileUUID) + if err != nil { + return err + } + teamID = prof.TeamID + profileName = prof.Name + case strings.HasPrefix(profileUUID, fleet.MDMAppleDeclarationUUIDPrefix): + return &fleet.BadRequestError{ + Message: "Can't resend declaration (DDM) profiles. Unlike configuration profiles (.mobileconfig), the host automatically checks in to get the latest DDM profiles.", + } + default: + return fleet.NewInvalidArgumentError("profile_uuid", "unknown profile").WithStatus(http.StatusNotFound) + } + + // now we can do a write authz check based on team id of the host before proceeding + if err := svc.authz.Authorize(ctx, &fleet.MDMConfigProfileAuthz{TeamID: teamID}, fleet.ActionWrite); err != nil { + return ctxerr.Wrap(ctx, err) + } + + count, err := svc.ds.BatchResendMDMProfileToHosts(ctx, profileUUID, filters) + if err != nil { + return ctxerr.Wrap(ctx, err) + } + + if count > 0 { + if err := svc.NewActivity( + ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeResentConfigurationProfileBatch{ + ProfileName: profileName, + HostCount: count, + }); err != nil { + return ctxerr.Wrap(ctx, err, "logging activity for batch-resend of profile") + } + } + return nil +} diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index a2a385a61a..6e9626ff34 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1998,6 +1998,9 @@ func TestMDMResendConfigProfileAuthz(t *testing.T) { ds.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails, []byte, time.Time) error { return nil } + ds.BatchResendMDMProfileToHostsFunc = func(ctx context.Context, profUUID string, filters fleet.BatchResendMDMProfileFilters) (int64, error) { + return 0, nil + } checkShouldFail := func(t *testing.T, err error, shouldFail bool) { if !shouldFail { @@ -2016,10 +2019,14 @@ func TestMDMResendConfigProfileAuthz(t *testing.T) { // test authz resend config profile (no team) err := svc.ResendHostMDMProfile(ctx, 1337, "a-no-team-profile") checkShouldFail(t, err, tt.shouldFailGlobalWrite) + err = svc.BatchResendMDMProfileToHosts(ctx, "a-no-team-profile", fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + checkShouldFail(t, err, tt.shouldFailGlobalWrite) // test authz resend config profile (team 1) err = svc.ResendHostMDMProfile(ctx, 1, "a-team-1-profile") checkShouldFail(t, err, tt.shouldFailTeamWrite) + err = svc.BatchResendMDMProfileToHosts(ctx, "a-team-1-profile", fleet.BatchResendMDMProfileFilters{ProfileStatus: fleet.MDMDeliveryFailed}) + checkShouldFail(t, err, tt.shouldFailTeamWrite) }) } }