package service import ( "bytes" "context" "crypto/md5" // nolint:gosec // used only for tests "crypto/x509" "database/sql" _ "embed" "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/url" "sort" "strings" "testing" "time" shared_mdm "github.com/fleetdm/fleet/v4/pkg/mdm" "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" "github.com/fleetdm/fleet/v4/pkg/optjson" "github.com/fleetdm/fleet/v4/server/datastore/mysql" "github.com/fleetdm/fleet/v4/server/fleet" servermdm "github.com/fleetdm/fleet/v4/server/mdm" apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple" "github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig" microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft" "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/service/contract" "github.com/fleetdm/fleet/v4/server/service/integrationtest/scep_server" "github.com/fleetdm/fleet/v4/server/test" "github.com/google/uuid" "github.com/jmoiron/sqlx" micromdm "github.com/micromdm/micromdm/mdm/mdm" "github.com/micromdm/plist" "github.com/smallstep/pkcs7" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func (s *integrationMDMTestSuite) signedProfilesMatch(want, got [][]byte) { t := s.T() rootCA := x509.NewCertPool() assets, err := s.ds.GetAllMDMConfigAssetsByName(context.Background(), []fleet.MDMAssetName{ fleet.MDMAssetCACert, }, nil) require.NoError(t, err) require.True(t, rootCA.AppendCertsFromPEM(assets[fleet.MDMAssetCACert].Value)) // verify that all the profiles were signed usign the SCEP certificate, // and grab their contents signedContents := [][]byte{} for _, prof := range got { p7, err := pkcs7.Parse(prof) require.NoError(t, err) require.NoError(t, p7.VerifyWithChain(rootCA)) signedContents = append(signedContents, p7.Content) } // verify that contents match require.ElementsMatch(t, want, signedContents) } func (s *integrationMDMTestSuite) TestAppleProfileManagement() { t := s.T() ctx := context.Background() err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) require.NoError(t, err) globalProfiles := [][]byte{ mobileconfigForTest("N1", "I1"), mobileconfigForTest("N2", "I2"), } wantGlobalProfiles := globalProfiles wantGlobalProfiles = append( wantGlobalProfiles, setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil), ) // add global profiles s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // invalid secrets invalidSecretsProfile := []byte(` PayloadContent PayloadDisplayName My profile PayloadIdentifier $FLEET_SECRET_INVALID PayloadType Configuration PayloadUUID 601E0B42-0989-4FAD-A61B-18656BA3670E PayloadVersion 1 `) res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "$FLEET_SECRET_INVALID") invalidSecretsProfile = []byte(` PayloadContent PayloadDisplayName $FLEET_SECRET_INVALID PayloadIdentifier N3 PayloadType Configuration PayloadUUID 601E0B42-0989-4FAD-A61B-18656BA3670E PayloadVersion 1 `) res = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{invalidSecretsProfile}}, http.StatusUnprocessableEntity) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "PayloadDisplayName cannot contain FLEET_SECRET variables") // create a new team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) require.NoError(t, err) // add an enroll secret so the fleetd profiles differ var teamResp teamEnrollSecretsResponse s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm.ID), modifyTeamEnrollSecretsRequest{ Secrets: []fleet.EnrollSecret{{Secret: "team1_enroll_sec"}}, }, http.StatusOK, &teamResp) teamProfiles := [][]byte{ mobileconfigForTest("N3", "I3"), } wantTeamProfiles := teamProfiles wantTeamProfiles = append( wantTeamProfiles, setupExpectedFleetdProfile(t, s.server.URL, "team1_enroll_sec", &tm.ID), ) // add profiles to the team s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) // create a non-macOS host _, err = s.ds.NewHost(context.Background(), &fleet.Host{ ID: 1, OsqueryHostID: ptr.String("non-macos-host"), NodeKey: ptr.String("non-macos-host"), UUID: uuid.New().String(), Hostname: fmt.Sprintf("%sfoo.local.non.macos", t.Name()), Platform: "windows", }) require.NoError(t, err) // create a host that's not enrolled into MDM _, err = s.ds.NewHost(context.Background(), &fleet.Host{ ID: 2, OsqueryHostID: ptr.String("not-mdm-enrolled"), NodeKey: ptr.String("not-mdm-enrolled"), UUID: uuid.New().String(), Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()), Platform: "darwin", }) require.NoError(t, err) // Create a host and then enroll to MDM. host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) setupPusher(s, t, mdmDevice) // trigger a profile sync s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, false) // verify that we received all profiles s.signedProfilesMatch( append(wantGlobalProfiles, setupExpectedCAProfile(t, s.ds)), installs, ) require.Empty(t, removes) expectedNoTeamSummary := fleet.MDMProfilesSummary{ Pending: 0, Failed: 0, Verifying: 1, Verified: 0, } expectedTeamSummary := fleet.MDMProfilesSummary{} s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // empty because no hosts in team // add the host to a team err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID})) require.NoError(t, err) // trigger a profile sync s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) // verify that we should install the team profile s.signedProfilesMatch(wantTeamProfiles, installs) // verify that we should delete both profiles require.ElementsMatch(t, []string{"I1", "I2"}, removes) expectedNoTeamSummary = fleet.MDMProfilesSummary{} expectedTeamSummary = fleet.MDMProfilesSummary{ Pending: 0, Failed: 0, Verifying: 1, Verified: 0, } s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host now verifying team profiles // Use secret variables in a profile secretIdentifier := "secret-identifier-1" secretType := "secret.type.1" secretProfile := string(mobileconfigForTest("NS1", "IS1")) req := createSecretVariablesRequest{ SecretVariables: []fleet.SecretVariable{ { Name: "FLEET_SECRET_IDENTIFIER", Value: secretIdentifier, }, { Name: "FLEET_SECRET_TYPE", Value: secretType, }, { Name: "FLEET_SECRET_PROFILE", Value: secretProfile, }, }, } secretResp := createSecretVariablesResponse{} s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp) // set new team profiles (delete + addition) teamProfiles = [][]byte{ mobileconfigForTest("N4", "I4"), mobileconfigForTestWithContent("N5", "I5", "$FLEET_SECRET_IDENTIFIER", "${FLEET_SECRET_TYPE}", "InnerName5"), // The whole profile is one big secret. []byte("$FLEET_SECRET_PROFILE"), } // We deep copy one of the team profiles because we will modify the slice in place, and we want to keep the originals for later. wantTeamProfiles = [][]byte{ teamProfiles[0], make([]byte, len(teamProfiles[1])), {}, } copy(wantTeamProfiles[1], teamProfiles[1]) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) // trigger a profile sync s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) // Manually replace the expected secret variables in the profile wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier)) wantTeamProfiles[1] = []byte(strings.ReplaceAll(string(wantTeamProfiles[1]), "${FLEET_SECRET_TYPE}", secretType)) wantTeamProfiles[2] = []byte(secretProfile) // verify that we should install the team profiles s.signedProfilesMatch(wantTeamProfiles, installs) // verify that we should delete the old team profiles require.ElementsMatch(t, []string{"I3"}, removes) s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles // Upload the same profiles again. No changes expected. s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) // Change the secret variable and upload the profiles again. We should see the profile with updated secret installed. secretType = "new.secret.type.1" req = createSecretVariablesRequest{ SecretVariables: []fleet.SecretVariable{ { Name: "FLEET_SECRET_IDENTIFIER", Value: secretIdentifier, // did not change }, { Name: "FLEET_SECRET_TYPE", Value: secretType, // changed }, { Name: "FLEET_SECRET_PROFILE", Value: secretProfile, // did not change }, }, } s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: teamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) // Manually replace the expected secret variables in the profile wantTeamProfilesChanged := [][]byte{ teamProfiles[1], } wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier)) wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType)) // verify that we should install the team profiles s.signedProfilesMatch(wantTeamProfilesChanged, installs) wantTeamProfiles[1] = wantTeamProfilesChanged[0] // No profiles should be deleted assert.Empty(t, removes) // Clear the profiles using the new (non-deprecated) endpoint. s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true") s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "false") s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) assert.Len(t, removes, 3) // And reapply the same profiles using the new (non-deprecated) endpoint. batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N4", Contents: teamProfiles[0]}, {Name: "N5", Contents: teamProfiles[1]}, {Name: "NS1", Contents: teamProfiles[2]}, }} s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true") s.assertConfigProfilesByIdentifier(&tm.ID, "I4", false) s.assertConfigProfilesByIdentifier(&tm.ID, "I5", false) s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", false) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.assertConfigProfilesByIdentifier(&tm.ID, "IS1", true) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) assert.Empty(t, removes) // verify that we should install the team profiles s.signedProfilesMatch(wantTeamProfiles, installs) // Upload the same profiles again. No changes expected. s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true") s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) // Change the secret variable and upload the profiles again. We should see the profile with updated secret installed. secretType = "new2.secret.type.1" req = createSecretVariablesRequest{ SecretVariables: []fleet.SecretVariable{ { Name: "FLEET_SECRET_IDENTIFIER", Value: secretIdentifier, // did not change }, { Name: "FLEET_SECRET_TYPE", Value: secretType, // changed }, { Name: "FLEET_SECRET_PROFILE", Value: secretProfile, // did not change }, }, } s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true") s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) // Manually replace the expected secret variables in the profile wantTeamProfilesChanged = [][]byte{ teamProfiles[1], } wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "$FLEET_SECRET_IDENTIFIER", secretIdentifier)) wantTeamProfilesChanged[0] = []byte(strings.ReplaceAll(string(wantTeamProfilesChanged[0]), "${FLEET_SECRET_TYPE}", secretType)) // verify that we should install the team profiles s.signedProfilesMatch(wantTeamProfilesChanged, installs) wantTeamProfiles[1] = wantTeamProfilesChanged[0] // No profiles should be deleted assert.Empty(t, removes) var hostResp getHostResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp) require.NotEmpty(t, hostResp.Host.MDM.Profiles) resProfiles := *hostResp.Host.MDM.Profiles // two extra profiles: fleetd config and root CA require.Len(t, resProfiles, len(wantTeamProfiles)+2) s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // empty because host was transferred s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // host still verifying team profiles // add a new profile to the team mcUUID := "a" + uuid.NewString() prof := mcBytesForTest("name-"+mcUUID, "identifier-"+mcUUID, mcUUID) wantTeamProfiles = append(wantTeamProfiles, prof) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);` _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, test.MakeTestBytes()) return err }) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Len(t, installs, 1) s.signedProfilesMatch([][]byte{prof}, installs) require.Empty(t, removes) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) // can't resend profile while verifying res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") // set the profile to pending, can't resend mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryPending, mcUUID, host.UUID) return err }) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Pending: 1}, nil) res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") // set the profile to failed, can resend mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID) return err }) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil) _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusAccepted) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Len(t, installs, 1) s.signedProfilesMatch([][]byte{prof}, installs) require.Empty(t, removes) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) // set the profile to failed, can resend from device endpoint token := "good_token" updateDeviceTokenForHost(t, s.ds, host.ID, token) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryFailed, mcUUID, host.UUID) return err }) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Failed: 1}, nil) _ = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", token, mcUUID), nil, http.StatusAccepted) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Len(t, installs, 1) s.signedProfilesMatch([][]byte{prof}, installs) require.Empty(t, removes) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) // can't resend profile while verifying res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusConflict) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn’t resend. Configuration profiles with “pending” or “verifying” status can’t be resent.") // set the profile to verified, can resend mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_apple_profiles SET status = ? WHERE profile_uuid = ? AND host_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, mcUUID, host.UUID) return err }) _ = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusAccepted) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Len(t, installs, 1) s.signedProfilesMatch([][]byte{prof}, installs) require.Empty(t, removes) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) s.lastActivityMatches( fleet.ActivityTypeResentConfigurationProfile{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "profile_name": %q}`, host.ID, host.DisplayName(), "name-"+mcUUID), 0) // add a declaration to the team declIdent := "decl-ident-" + uuid.NewString() fields := map[string][]string{ "team_id": {fmt.Sprintf("%d", tm.ID)}, } body, headers := generateNewProfileMultipartRequest( t, "some-declaration.json", declarationForTest(declIdent), s.token, fields, ) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers) var resp newMDMConfigProfileResponse err = json.NewDecoder(res.Body).Decode(&resp) require.NoError(t, err) require.NotEmpty(t, resp.ProfileUUID) require.Equal(t, "d", string(resp.ProfileUUID[0])) declUUID := resp.ProfileUUID checkDDMSync := func(d *mdmtest.TestAppleMDMClient) { require.NoError(t, ReconcileAppleDeclarations(ctx, s.ds, s.mdmCommander, s.logger)) cmd, err := d.Idle() require.NoError(t, err) require.NotNil(t, cmd) require.Equal(t, "DeclarativeManagement", cmd.Command.RequestType) cmd, err = d.Acknowledge(cmd.CommandUUID) require.NoError(t, err) require.Nil(t, cmd, fmt.Sprintf("expected no more commands, but got: %+v", cmd)) _, err = d.DeclarativeManagement("tokens") require.NoError(t, err) } checkDDMSync(mdmDevice) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{Verifying: 1}, nil) // can not resend declarations as admin or from device endpoint res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, declUUID), nil, http.StatusBadRequest) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, fleet.CantResendAppleDeclarationProfilesMessage) res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", token, declUUID), nil, http.StatusBadRequest) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, fleet.CantResendAppleDeclarationProfilesMessage) // set the declaration to verified mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_apple_declarations SET status = ? WHERE declaration_uuid = ? AND host_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, fleet.MDMDeliveryVerified, declUUID, host.UUID) return err }) // transfer the host to the global team err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(nil, []uint{host.ID})) require.NoError(t, err) s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Len(t, installs, len(wantGlobalProfiles)) s.signedProfilesMatch(wantGlobalProfiles, installs) require.Len(t, removes, len(wantTeamProfiles)) expectedNoTeamSummary = fleet.MDMProfilesSummary{Pending: 1} expectedTeamSummary = fleet.MDMProfilesSummary{} s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) // host now removing team profiles s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // can't resend profile from another team res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusNotFound) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Unable to match profile to host") // invalid profile UUID prefix should return 404 res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, "z"+uuid.NewString()), nil, http.StatusNotFound) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Invalid profile UUID prefix") // set OS updates settings for no-team and team, should not change the // summaries as this profile is ignored. s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_updates": { "deadline": "2023-12-31", "minimum_version": "13.3.7" } } }`), http.StatusOK) s.Do("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), fleet.TeamPayload{ MDM: &fleet.TeamPayloadMDM{ MacOSUpdates: &fleet.AppleOSUpdateSettings{ Deadline: optjson.SetString("1992-01-01"), MinimumVersion: optjson.SetString("13.1.1"), }, }, }, http.StatusOK) s.checkMDMProfilesSummaries(t, nil, expectedNoTeamSummary, &expectedNoTeamSummary) s.checkMDMProfilesSummaries(t, &tm.ID, expectedTeamSummary, &expectedTeamSummary) // it should also not show up in the host's profiles list s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), getHostRequest{}, http.StatusOK, &hostResp) require.NotEmpty(t, hostResp.Host.MDM.Profiles) resProfiles = *hostResp.Host.MDM.Profiles // two extra profiles: fleetd config and root CA require.Len(t, resProfiles, len(wantGlobalProfiles)+2) } func (s *integrationMDMTestSuite) TestAppleProfileRetries() { t := s.T() ctx := context.Background() enrollSecret := "test-profile-retries-secret" err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: enrollSecret}}) require.NoError(t, err) testProfiles := [][]byte{ mobileconfigForTest("N1", "I1"), mobileconfigForTest("N2", "I2"), } initialExpectedProfiles := testProfiles initialExpectedProfiles = append( initialExpectedProfiles, setupExpectedFleetdProfile(t, s.server.URL, enrollSecret, nil), setupExpectedCAProfile(t, s.ds), ) h, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) setupPusher(s, t, mdmDevice) expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{ "I1": fleet.MDMDeliveryVerifying, "I2": fleet.MDMDeliveryVerifying, mobileconfig.FleetdConfigPayloadIdentifier: fleet.MDMDeliveryVerifying, mobileconfig.FleetCARootConfigPayloadIdentifier: fleet.MDMDeliveryVerifying, } checkProfilesStatus := func(t *testing.T) { storedProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, h.UUID) require.NoError(t, err) require.Len(t, storedProfs, len(expectedProfileStatuses)) for _, p := range storedProfs { want, ok := expectedProfileStatuses[p.Identifier] require.True(t, ok, "unexpected profile: %s", p.Identifier) require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Identifier) } } expectedRetryCounts := map[string]uint{ "I1": 0, "I2": 0, mobileconfig.FleetdConfigPayloadIdentifier: 0, mobileconfig.FleetCARootConfigPayloadIdentifier: 0, } checkRetryCounts := func(t *testing.T) { counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h) require.NoError(t, err) require.Len(t, counts, len(expectedRetryCounts)) for _, c := range counts { want, ok := expectedRetryCounts[c.ProfileIdentifier] require.True(t, ok, "unexpected profile: %s", c.ProfileIdentifier) require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileIdentifier) } } hostProfsByIdent := map[string]*fleet.HostMacOSProfile{ "I1": { Identifier: "I1", DisplayName: "N1", InstallDate: time.Now().Add(15 * time.Minute), }, "I2": { Identifier: "I2", DisplayName: "N2", InstallDate: time.Now().Add(15 * time.Minute), }, mobileconfig.FleetdConfigPayloadIdentifier: { Identifier: mobileconfig.FleetdConfigPayloadIdentifier, DisplayName: "Fleetd configuration", InstallDate: time.Now().Add(15 * time.Minute), }, } reportHostProfs := func(t *testing.T, identifiers ...string) { report := make(map[string]*fleet.HostMacOSProfile, len(hostProfsByIdent)) for _, ident := range identifiers { report[ident] = hostProfsByIdent[ident] } require.NoError(t, apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, h, report)) } setProfileUploadedAt := func(t *testing.T, uploadedAt time.Time, identifiers ...interface{}) { bindVars := strings.TrimSuffix(strings.Repeat("?, ", len(identifiers)), ", ") stmt := fmt.Sprintf("UPDATE mdm_apple_configuration_profiles SET uploaded_at = ? WHERE identifier IN(%s)", bindVars) args := append([]interface{}{uploadedAt}, identifiers...) mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { _, err := tx.ExecContext(ctx, stmt, args...) return err }) } t.Run("retry after verifying", func(t *testing.T) { // upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier) // trigger initial profile sync and confirm that we received all profiles s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, false) s.signedProfilesMatch(initialExpectedProfiles, installs) require.Empty(t, removes) checkProfilesStatus(t) // all profiles verifying checkRetryCounts(t) // no retries yet // report osquery results with I2 missing and confirm I2 marked as pending and other profiles are marked as verified reportHostProfs(t, "I1", mobileconfig.FleetdConfigPayloadIdentifier) expectedProfileStatuses["I2"] = fleet.MDMDeliveryPending expectedProfileStatuses["I1"] = fleet.MDMDeliveryVerified expectedProfileStatuses[mobileconfig.FleetdConfigPayloadIdentifier] = fleet.MDMDeliveryVerified checkProfilesStatus(t) expectedRetryCounts["I2"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for I2 was resent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) s.signedProfilesMatch([][]byte{initialExpectedProfiles[1]}, installs) require.Empty(t, removes) // report osquery results with I2 present and confirm that all profiles are verified reportHostProfs(t, "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier) expectedProfileStatuses["I2"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that no profiles were sent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) }) t.Run("retry after verification", func(t *testing.T) { // report osquery results with I1 missing and confirm that the I1 marked as pending (initial retry) reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier) expectedProfileStatuses["I1"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["I1"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for I1 was resent s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, false) s.signedProfilesMatch([][]byte{initialExpectedProfiles[0]}, installs) require.Empty(t, removes) // report osquery results with I1 missing again and confirm that the I1 marked as failed (max retries exceeded) reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier) expectedProfileStatuses["I1"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for I1 was not resent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) }) t.Run("retry after device error", func(t *testing.T) { // add another profile and set the updated_at timestamp back by 48 hours newProfile := mobileconfigForTest("N3", "I3") testProfiles = append(testProfiles, newProfile) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3") // trigger a profile sync and confirm that the install profile command for I3 was sent and // simulate a device error s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, true) s.signedProfilesMatch([][]byte{newProfile}, installs) require.Empty(t, removes) expectedProfileStatuses["I3"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["I3"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for I3 was sent and // simulate a device ack s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) s.signedProfilesMatch([][]byte{newProfile}, installs) require.Empty(t, removes) expectedProfileStatuses["I3"] = fleet.MDMDeliveryVerifying checkProfilesStatus(t) checkRetryCounts(t) // unchanged // report osquery results with I3 missing and confirm that the I3 marked as failed (max // retries exceeded) reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier) expectedProfileStatuses["I3"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for I3 was not resent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) }) t.Run("repeated device error", func(t *testing.T) { // add another profile and set the updated_at timestamp back by 48 hours newProfile := mobileconfigForTest("N4", "I4") testProfiles = append(testProfiles, newProfile) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4") // trigger a profile sync and confirm that the install profile command for I3 was sent and // simulate a device error s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, true) s.signedProfilesMatch([][]byte{newProfile}, installs) require.Empty(t, removes) expectedProfileStatuses["I4"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["I4"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for I4 was sent and // simulate a second device error s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, true) s.signedProfilesMatch([][]byte{newProfile}, installs) require.Empty(t, removes) expectedProfileStatuses["I4"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for I3 was not resent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) }) t.Run("retry count does not reset", func(t *testing.T) { // add another profile and set the updated_at timestamp back by 48 hours newProfile := mobileconfigForTest("N5", "I5") testProfiles = append(testProfiles, newProfile) hostProfsByIdent["I5"] = &fleet.HostMacOSProfile{Identifier: "I5", DisplayName: "N5", InstallDate: time.Now()} s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) setProfileUploadedAt(t, time.Now().Add(-48*time.Hour), "I1", "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I3", "I4", "I5") // trigger a profile sync and confirm that the install profile command for I3 was sent and // simulate a device error s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, true) s.signedProfilesMatch([][]byte{newProfile}, installs) require.Empty(t, removes) expectedProfileStatuses["I5"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["I5"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for I5 was sent and // simulate a device ack s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) s.signedProfilesMatch([][]byte{newProfile}, installs) require.Empty(t, removes) expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerifying checkProfilesStatus(t) checkRetryCounts(t) // unchanged // report osquery results with I5 found and confirm that the I5 marked as verified reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier, "I5") expectedProfileStatuses["I5"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for I5 was not resent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) // report osquery results again, this time I5 is missing and confirm that the I5 marked as // failed (max retries exceeded) reportHostProfs(t, "I2", mobileconfig.FleetdConfigPayloadIdentifier) expectedProfileStatuses["I5"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for I5 was not resent s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice, false) require.Empty(t, installs) require.Empty(t, removes) }) } type profileData struct { Status string LocURI string Data string } // reportWindowsOSQueryProfiles simulates a Windows host reporting the status of MDM profiles from OSQuery results. func (s *integrationMDMTestSuite) reportWindowsOSQueryProfiles(ctx context.Context, t *testing.T, host *fleet.Host, hostProfileReports map[string][]profileData) { var responseOps []*fleet.SyncMLCmd for profileName, report := range hostProfileReports { for _, p := range report { ref := microsoft_mdm.HashLocURI(profileName, p.LocURI) responseOps = append(responseOps, &fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, CmdID: fleet.CmdID{Value: uuid.NewString()}, CmdRef: &ref, Data: ptr.String(p.Status), }) // the protocol can respond with only a `Status` // command if the status failed if p.Status != "200" || p.Data != "" { responseOps = append(responseOps, &fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdResults}, CmdID: fleet.CmdID{Value: uuid.NewString()}, CmdRef: &ref, Items: []fleet.CmdItem{ {Target: ptr.String(p.LocURI), Data: &fleet.RawXmlData{Content: p.Data}}, }, }) } } } msg, err := createSyncMLMessage("2", "2", "foo", "bar", responseOps) require.NoError(t, err) out, err := xml.Marshal(msg) require.NoError(t, err) require.NoError(t, microsoft_mdm.VerifyHostMDMProfiles(ctx, s.logger, s.ds, host, out)) } func (s *integrationMDMTestSuite) TestWindowsProfileRetries() { t := s.T() ctx := context.Background() testProfiles := []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1"}})}, {Name: "N2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L2", Data: "D2"}, {Verb: "Add", LocURI: "L3", Data: "D3"}})}, } h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{ "N1": fleet.MDMDeliveryVerifying, "N2": fleet.MDMDeliveryVerifying, } checkProfilesStatus := func(t *testing.T) { storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID) require.NoError(t, err) require.Len(t, storedProfs, len(expectedProfileStatuses)) for _, p := range storedProfs { want, ok := expectedProfileStatuses[p.Name] require.True(t, ok, "unexpected profile: %s", p.Name) require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name) } } expectedRetryCounts := map[string]uint{ "N1": 0, "N2": 0, } checkRetryCounts := func(t *testing.T) { counts, err := s.ds.GetHostMDMProfilesRetryCounts(ctx, h) require.NoError(t, err) require.Len(t, counts, len(expectedRetryCounts)) for _, c := range counts { want, ok := expectedRetryCounts[c.ProfileName] require.True(t, ok, "unexpected profile: %s", c.ProfileName) require.Equal(t, want, c.Retries, "expected retry count %d but got %d for profile: %s", want, c.Retries, c.ProfileName) } } hostProfileReports := map[string][]profileData{ "N1": {{"200", "L1", "D1"}}, "N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}}, } reportHostProfs := func(profileNames ...string) { selectedReports := make(map[string][]profileData) for _, name := range profileNames { if reports, exists := hostProfileReports[name]; exists { selectedReports[name] = reports } } s.reportWindowsOSQueryProfiles(ctx, t, h, selectedReports) } verifyCommands := func(wantProfileInstalls int, status string) { s.awaitTriggerProfileSchedule(t) cmds, err := mdmDevice.StartManagementSession() require.NoError(t, err) // profile installs + 2 protocol commands acks require.Len(t, cmds, wantProfileInstalls+2) msgID, err := mdmDevice.GetCurrentMsgID() require.NoError(t, err) atomicCmds := 0 for _, c := range cmds { if c.Verb == "Atomic" { atomicCmds++ } mdmDevice.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: ptr.String(c.Cmd.CmdID.Value), Cmd: ptr.String(c.Verb), Data: ptr.String(status), Items: nil, CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } require.Equal(t, wantProfileInstalls, atomicCmds) cmds, err = mdmDevice.SendResponse() require.NoError(t, err) // the ack of the message should be the only returned command require.Len(t, cmds, 1) } t.Run("retry after verifying", func(t *testing.T) { // upload test profiles then simulate expired grace period by setting updated_at timestamp of profiles back by 48 hours s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // profiles to install + 2 boilerplate verifyCommands(len(testProfiles), syncml.CmdStatusOK) checkProfilesStatus(t) // all profiles verifying checkRetryCounts(t) // no retries yet // report osquery results with N2 missing and confirm N2 marked // as verifying and other profiles are marked as verified reportHostProfs("N1") expectedProfileStatuses["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("N1", "N2") expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that no profiles were sent verifyCommands(0, syncml.CmdStatusOK) }) t.Run("retry after verification", func(t *testing.T) { // report osquery results with N1 missing and confirm that the N1 marked as pending (initial retry) reportHostProfs("N2") expectedProfileStatuses["N1"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["N1"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for N1 was resent verifyCommands(1, syncml.CmdStatusOK) // report osquery results with N1 missing again and confirm that the N1 marked as failed (max retries exceeded) reportHostProfs("N2") expectedProfileStatuses["N1"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for N1 was not resent verifyCommands(0, syncml.CmdStatusOK) }) t.Run("retry after device error", func(t *testing.T) { // add another profile newProfile := syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L3", Data: "D3"}}) testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{ Name: "N3", Contents: newProfile, }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // trigger a profile sync and confirm that the install profile command for N3 was sent and // simulate a device error verifyCommands(1, syncml.CmdStatusAtomicFailed) expectedProfileStatuses["N3"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["N3"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile command for N3 was sent and // simulate a device ack verifyCommands(1, syncml.CmdStatusOK) expectedProfileStatuses["N3"] = fleet.MDMDeliveryVerifying checkProfilesStatus(t) checkRetryCounts(t) // unchanged // report osquery results with N3 missing and confirm that the N3 marked as failed (max // retries exceeded) reportHostProfs("N2") expectedProfileStatuses["N3"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for N3 was not resent verifyCommands(0, syncml.CmdStatusOK) }) t.Run("repeated device error", func(t *testing.T) { // add another profile testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{ Name: "N4", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L4", Data: "D4"}}), }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // trigger a profile sync and confirm that the install profile command for N4 was sent and // simulate a device error verifyCommands(1, syncml.CmdStatusAtomicFailed) expectedProfileStatuses["N4"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["N4"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile // command for N4 was sent and simulate a second device error verifyCommands(1, syncml.CmdStatusAtomicFailed) expectedProfileStatuses["N4"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile // command for N4 was not resent verifyCommands(0, syncml.CmdStatusOK) }) t.Run("retry count does not reset", func(t *testing.T) { // add another profile testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{ Name: "N5", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L5", Data: "D5"}}), }) // hostProfsByIdent["N5"] = &fleet.HostMacOSProfile{Identifier: "N5", DisplayName: "N5", InstallDate: time.Now()} s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // trigger a profile sync and confirm that the install profile // command for N5 was sent and simulate a device error verifyCommands(1, syncml.CmdStatusAtomicFailed) expectedProfileStatuses["N5"] = fleet.MDMDeliveryPending checkProfilesStatus(t) expectedRetryCounts["N5"] = 1 checkRetryCounts(t) // trigger a profile sync and confirm that the install profile // command for N5 was sent and simulate a device ack verifyCommands(1, syncml.CmdStatusOK) expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerifying checkProfilesStatus(t) checkRetryCounts(t) // unchanged // report osquery results with N5 found and confirm that the N5 marked as verified hostProfileReports["N5"] = []profileData{{"200", "L5", "D5"}} reportHostProfs("N2", "N5") expectedProfileStatuses["N5"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for N5 was not resent verifyCommands(0, syncml.CmdStatusOK) // report osquery results again, this time N5 is missing and confirm that the N5 marked as // failed (max retries exceeded) reportHostProfs("N2") expectedProfileStatuses["N5"] = fleet.MDMDeliveryFailed checkProfilesStatus(t) checkRetryCounts(t) // unchanged // trigger a profile sync and confirm that the install profile command for N5 was not resent verifyCommands(0, syncml.CmdStatusOK) }) } // TestWindowsProfileResend verifies that a Windows profile is resent when its contents have been modified. func (s *integrationMDMTestSuite) TestWindowsProfileResend() { t := s.T() ctx := context.Background() testProfiles := []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1"}})}, {Name: "N2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L2", Data: "D2"}, {Verb: "Replace", LocURI: "L3", Data: "D3"}})}, } h, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) expectedProfileStatuses := map[string]fleet.MDMDeliveryStatus{ "N1": fleet.MDMDeliveryVerifying, "N2": fleet.MDMDeliveryVerifying, } checkProfilesStatus := func(t *testing.T) { storedProfs, err := s.ds.GetHostMDMWindowsProfiles(ctx, h.UUID) require.NoError(t, err) require.Len(t, storedProfs, len(expectedProfileStatuses)) for _, p := range storedProfs { want, ok := expectedProfileStatuses[p.Name] require.True(t, ok, "unexpected profile: %s", p.Name) require.Equal(t, want, *p.Status, "expected status %s but got %s for profile: %s", want, *p.Status, p.Name) } } hostProfileReports := map[string][]profileData{ "N1": {{"200", "L1", "D1"}}, "N2": {{"200", "L2", "D2"}, {"200", "L3", "D3"}}, } reportHostProfs := func(profileNames ...string) { selectedReports := make(map[string][]profileData) for _, name := range profileNames { if reports, exists := hostProfileReports[name]; exists { selectedReports[name] = reports } } s.reportWindowsOSQueryProfiles(ctx, t, h, selectedReports) } verifyCommands := func(wantProfileInstalls int, status string) { s.awaitTriggerProfileSchedule(t) cmds, err := mdmDevice.StartManagementSession() require.NoError(t, err) // profile installs + 2 protocol commands acks require.Len(t, cmds, wantProfileInstalls+2) msgID, err := mdmDevice.GetCurrentMsgID() require.NoError(t, err) atomicCmds := 0 for _, c := range cmds { if c.Verb == "Atomic" { atomicCmds++ } mdmDevice.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: ptr.String(c.Cmd.CmdID.Value), Cmd: ptr.String(c.Verb), Data: ptr.String(status), Items: nil, CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } require.Equal(t, wantProfileInstalls, atomicCmds) cmds, err = mdmDevice.SendResponse() require.NoError(t, err) // the ack of the message should be the only returned command require.Len(t, cmds, 1) } t.Run("do not resend if nothing changed", func(t *testing.T) { t.Cleanup(func() { // Clear the profiles s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{}}, http.StatusNoContent) }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // profiles to install + 2 boilerplate verifyCommands(len(testProfiles), syncml.CmdStatusOK) expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerifying expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerifying checkProfilesStatus(t) // all profiles verifying // report osquery results and confirm that all profiles are verified reportHostProfs("N1", "N2") expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) // trigger a profile sync and confirm that no profiles were sent verifyCommands(0, syncml.CmdStatusOK) // Upload the same profiles again s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // trigger a profile sync and confirm that no profiles were sent verifyCommands(0, syncml.CmdStatusOK) }) t.Run("resend if contents changed", func(t *testing.T) { t.Cleanup(func() { // Clear the profiles s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{}}, http.StatusNoContent) }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) // profiles to install + 2 boilerplate verifyCommands(len(testProfiles), syncml.CmdStatusOK) expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerifying expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerifying checkProfilesStatus(t) // all profiles verifying // report osquery results and confirm that all profiles are verified reportHostProfs("N1", "N2") expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerified expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) // trigger a profile sync and confirm that no profiles were sent verifyCommands(0, syncml.CmdStatusOK) // Change one profile and upload copiedTestProfiles := make([]fleet.MDMProfileBatchPayload, len(testProfiles)) copy(copiedTestProfiles, testProfiles) copiedTestProfiles[0].Contents = syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "L1", Data: "D1-Modified"}}) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: copiedTestProfiles}, http.StatusNoContent) // Confirm that one profile was sent and its status verifyCommands(1, syncml.CmdStatusOK) expectedProfileStatuses["N1"] = fleet.MDMDeliveryVerifying expectedProfileStatuses["N2"] = fleet.MDMDeliveryVerified checkProfilesStatus(t) // all profiles verifying }) } func (s *integrationMDMTestSuite) TestPuppetMatchPreassignProfiles() { ctx := context.Background() t := s.T() // before we switch to a gitops token, ensure ABM is setup s.enableABM(t.Name()) // Use a gitops user for all Puppet actions u := &fleet.User{ Name: "GitOps", Email: "gitops-TestPuppetMatchPreassignProfiles@example.com", GlobalRole: ptr.String(fleet.RoleGitOps), } require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10)) _, err := s.ds.NewUser(context.Background(), u) require.NoError(t, err) s.setTokenForTest(t, "gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword) runWithAdminToken := func(cb func()) { s.token = s.getTestAdminToken() cb() s.token = s.getCachedUserToken("gitops-TestPuppetMatchPreassignProfiles@example.com", test.GoodPassword) } // create a host enrolled in fleet mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) // create a host that's not enrolled into MDM nonMDMHost, err := s.ds.NewHost(context.Background(), &fleet.Host{ OsqueryHostID: ptr.String("not-mdm-enrolled"), NodeKey: ptr.String("not-mdm-enrolled"), UUID: uuid.New().String(), Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()), Platform: "darwin", }) require.NoError(t, err) // create a setup assistant for no team, for this we need to: // 1. mock the ABM API, as it gets called to set the profile // 2. run the DEP schedule, as this registers the default profile s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`)) })) s.runDEPSchedule() noTeamProf := `{"x": 1}` var globalAsstResp createMDMAppleSetupAssistantResponse s.DoJSON("POST", "/api/latest/fleet/enrollment_profiles/automatic", createMDMAppleSetupAssistantRequest{ TeamID: nil, Name: "no-team", EnrollmentProfile: json.RawMessage(noTeamProf), }, http.StatusOK, &globalAsstResp) // set the global Enable Release Device manually setting to true, // will be inherited by teams created via preassign/match. s.Do("PATCH", "/api/latest/fleet/setup_experience", json.RawMessage(jsonMustMarshal(t, map[string]any{"enable_release_device_manually": true})), http.StatusNoContent) s.runWorker() // preassign an empty profile, fails s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "empty", HostUUID: nonMDMHost.UUID, Profile: nil}}, http.StatusUnprocessableEntity) // preassign a valid profile to the MDM host prof1 := mobileconfigForTest("n1", "i1") s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof1}}, http.StatusNoContent) // preassign another valid profile to the MDM host prof2 := mobileconfigForTest("n2", "i2") s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm1", HostUUID: mdmHost.UUID, Profile: prof2, Group: "g1"}}, http.StatusNoContent) // preassign a valid profile to the non-MDM host, still works as the host is not validated in this call prof3 := mobileconfigForTest("n3", "i3") s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "non-mdm", HostUUID: nonMDMHost.UUID, Profile: prof3, Group: "g2"}}, http.StatusNoContent) // match with an invalid external host id, succeeds as it is the same as if // there was no matching to do (no preassignment was done) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "no-such-id"}, http.StatusNoContent) // match with the non-mdm host fails res := s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "non-mdm"}, http.StatusBadRequest) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "host is not enrolled in Fleet MDM") // match with the mdm host succeeds and creates a team based on the group labels s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm1"}, http.StatusNoContent) // the host is now part of that team h, err := s.ds.Host(ctx, mdmHost.ID) require.NoError(t, err) require.NotNil(t, h.TeamID) tm1, err := s.ds.TeamLite(ctx, *h.TeamID) require.NoError(t, err) require.Equal(t, "g1", tm1.Name) require.True(t, tm1.Config.MDM.EnableDiskEncryption) require.True(t, tm1.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) runWithAdminToken(func() { // it create activities for the new team, the profiles assigned to it, // the host moved to it, and setup assistant s.lastActivityOfTypeMatches( fleet.ActivityTypeCreatedTeam{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name), 0) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm1.ID, tm1.Name), 0) s.lastActivityOfTypeMatches( fleet.ActivityTypeTransferredHostsToTeam{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q, "host_ids": [%d], "host_display_names": [%q]}`, tm1.ID, tm1.Name, h.ID, h.DisplayName()), 0) s.lastActivityOfTypeMatches( fleet.ActivityTypeChangedMacosSetupAssistant{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "name": %q, "team_name": %q}`, tm1.ID, globalAsstResp.Name, tm1.Name), 0) }) // and the team has the expected profiles (prof1 and prof2) profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID) require.NoError(t, err) require.Len(t, profs, 2) // order is guaranteed by profile name require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) // setup assistant settings are copyied from "no team" teamAsst, err := s.ds.GetMDMAppleSetupAssistant(ctx, &tm1.ID) require.NoError(t, err) require.Equal(t, globalAsstResp.Name, teamAsst.Name) require.JSONEq(t, string(globalAsstResp.Profile), string(teamAsst.Profile)) // trigger the schedule so profiles are set in their state s.awaitTriggerProfileSchedule(t) s.runWorker() // the mdm host has the same profiles (i1, i2, plus fleetd config and disk encryption) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ mdmHost: { {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "i2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // create a team and set profiles to it (note that it doesn't have disk encryption enabled) tm2, err := s.ds.NewTeam(context.Background(), &fleet.Team{ Name: "g1 - g4", Secrets: []*fleet.EnrollSecret{{Secret: "tm2secret"}}, }) require.NoError(t, err) prof4 := mobileconfigForTest("n4", "i4") s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ prof1, prof4, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) // tm2 has disk encryption and release device manually disabled require.False(t, tm2.Config.MDM.EnableDiskEncryption) require.False(t, tm2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // create another team with a superset of profiles tm3, err := s.ds.NewTeam(context.Background(), &fleet.Team{ Name: "team3_" + t.Name(), Secrets: []*fleet.EnrollSecret{{Secret: "tm3secret"}}, }) require.NoError(t, err) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ prof1, prof2, prof4, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm3.ID)) // and yet another team with the same profiles as tm3 tm4, err := s.ds.NewTeam(context.Background(), &fleet.Team{ Name: "team4_" + t.Name(), Secrets: []*fleet.EnrollSecret{{Secret: "tm4secret"}}, }) require.NoError(t, err) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ prof1, prof2, prof4, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm4.ID)) // preassign the MDM host to prof1 and prof4, should match existing team tm2 // // additionally, use external host identifiers with different // suffixes to simulate real world distributed scenarios where more // than one puppet server might be running at the time. s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler06.test.example.com", HostUUID: mdmHost.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler01.test.example.com", HostUUID: mdmHost.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent) // match with the mdm host succeeds and assigns it to tm2 s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "6f36ab2c-1a40-429b-9c9d-07c9029f4aa8-puppetcompiler03.test.example.com"}, http.StatusNoContent) // the host is now part of that team h, err = s.ds.Host(ctx, mdmHost.ID) require.NoError(t, err) require.NotNil(t, h.TeamID) require.Equal(t, tm2.ID, *h.TeamID) // tmLite2 still has disk encryption and release device manually disabled tmLite2, err := s.ds.TeamLite(ctx, *h.TeamID) require.NoError(t, err) require.False(t, tmLite2.Config.MDM.EnableDiskEncryption) require.False(t, tmLite2.Config.MDM.MacOSSetup.EnableReleaseDeviceManually.Value) // the host's profiles are: // - the same as the team's and are pending (prof1 + prof4) // - prof2 + old filevault are pending removal // - fleetd config being reinstalled (for new enroll secret) s.awaitTriggerProfileSchedule(t) // useful for debugging // mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { // mysql.DumpTable(t, q, "host_mdm_apple_profiles") // return nil // }) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ mdmHost: { {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, // Profiles from previous team being deleted {Identifier: "i2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, { Identifier: mobileconfig.FleetFileVaultPayloadIdentifier, OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending, }, }, }) // create a new mdm host enrolled in fleet mdmHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) // make it part of team 2 s.Do("POST", "/api/v1/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &tmLite2.ID, HostIDs: []uint{mdmHost2.ID}}, http.StatusOK) // simulate having its profiles installed mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { res, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE host_uuid = ?`, fleet.OSSettingsVerifying, mdmHost2.UUID) n, _ := res.RowsAffected() require.Equal(t, 4, int(n)) return err }) // preassign the MDM host using "g1" and "g4", should match existing // team tmLite2, and nothing be done since the host is already in tmLite2 s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof1, Group: "g1"}}, http.StatusNoContent) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: fleet.MDMApplePreassignProfilePayload{ExternalHostIdentifier: "mdm2", HostUUID: mdmHost2.UUID, Profile: prof4, Group: "g4"}}, http.StatusNoContent) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: "mdm2"}, http.StatusNoContent) // the host is still part of tmLite2 h, err = s.ds.Host(ctx, mdmHost2.ID) require.NoError(t, err) require.NotNil(t, h.TeamID) require.Equal(t, tmLite2.ID, *h.TeamID) // and its profiles have been left untouched s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ mdmHost2: { {Identifier: "i1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "i4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) } // while s.TestPuppetMatchPreassignProfiles focuses on many edge cases/extra // checks around profile assignment, this test is mainly focused on // simulating a few puppet runs in scenarios we want to support, and ensuring that: // // - different hosts end up in the right teams // - teams get edited as expected // - commands to add/remove profiles are issued adequately func (s *integrationMDMTestSuite) TestPuppetRun() { t := s.T() ctx := context.Background() // define a few profiles prof1, prof2, prof3, prof4 := mobileconfigForTest("n1", "i1"), mobileconfigForTest("n2", "i2"), mobileconfigForTest("n3", "i3"), mobileconfigForTest("n4", "i4") // create three hosts host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) host3, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) s.runWorker() // Set up a mock Apple DEP API s.enableABM(t.Name()) s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { encoder := json.NewEncoder(w) switch r.URL.Path { case "/session": _, _ = w.Write([]byte(`{"auth_session_token": "session123"}`)) case "/account": _, _ = w.Write([]byte(fmt.Sprintf(`{"admin_id": "admin123", "org_name": "%s"}`, "foo"))) case "/profile": w.WriteHeader(http.StatusOK) require.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: "profile123"})) } })) // Use a gitops user for all Puppet actions u := &fleet.User{ Name: "GitOps", Email: "gitops-TestPuppetRun@example.com", GlobalRole: ptr.String(fleet.RoleGitOps), } require.NoError(t, u.SetPassword(test.GoodPassword, 10, 10)) _, err := s.ds.NewUser(context.Background(), u) require.NoError(t, err) s.setTokenForTest(t, "gitops-TestPuppetRun@example.com", test.GoodPassword) // preassignAndMatch simulates the puppet module doing all the // preassign/match calls for a given set of profiles. preassignAndMatch := func(profs []fleet.MDMApplePreassignProfilePayload) { require.NotEmpty(t, profs) for _, prof := range profs { s.Do( "POST", "/api/latest/fleet/mdm/apple/profiles/preassign", preassignMDMAppleProfileRequest{MDMApplePreassignProfilePayload: prof}, http.StatusNoContent, ) } s.Do( "POST", "/api/latest/fleet/mdm/apple/profiles/match", matchMDMApplePreassignmentRequest{ExternalHostIdentifier: profs[0].ExternalHostIdentifier}, http.StatusNoContent, ) } // node default { // fleetdm::profile { 'n1': // template => template('n1.mobileconfig.erb'), // group => 'base', // } // // fleetdm::profile { 'n2': // template => template('n2.mobileconfig.erb'), // group => 'workstations', // } // // fleetdm::profile { 'n3': // template => template('n3.mobileconfig.erb'), // group => 'workstations', // } // // if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' { // fleetdm::profile { 'n4': // template => template('fleetdm/n4.mobileconfig.erb'), // group => 'kiosks', // } // } puppetRun := func(host *fleet.Host) { payload := []fleet.MDMApplePreassignProfilePayload{ { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof1, Group: "base", }, { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof2, Group: "workstations", }, { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof3, Group: "workstations", }, } if host.UUID == host2.UUID { payload = append(payload, fleet.MDMApplePreassignProfilePayload{ ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof4, Group: "kiosks", }) } preassignAndMatch(payload) } // host1 checks in puppetRun(host1) // the host now belongs to a team h1, err := s.ds.Host(ctx, host1.ID) require.NoError(t, err) require.NotNil(t, h1.TeamID) // the team has the right name tm1, err := s.ds.TeamWithExtras(ctx, *h1.TeamID) require.NoError(t, err) require.Equal(t, "base - workstations", tm1.Name) // and the right profiles profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID) require.NoError(t, err) require.Len(t, profs, 3) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in puppetRun(host2) // a new team is created h2, err := s.ds.Host(ctx, host2.ID) require.NoError(t, err) require.NotNil(t, h2.TeamID) // the team has the right name tm2, err := s.ds.TeamWithExtras(ctx, *h2.TeamID) require.NoError(t, err) require.Equal(t, "base - kiosks - workstations", tm2.Name) // and the right profiles profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID) require.NoError(t, err) require.Len(t, profs, 4) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.Equal(t, prof4, []byte(profs[3].Mobileconfig)) require.True(t, tm2.Config.MDM.EnableDiskEncryption) // host3 checks in puppetRun(host3) // it belongs to the same team as host1 h3, err := s.ds.Host(ctx, host3.ID) require.NoError(t, err) require.Equal(t, h1.TeamID, h3.TeamID) // prof2 is edited oldProf2 := prof2 prof2 = mobileconfigForTest("n2", "i2-v2") // host3 checks in again puppetRun(host3) // still belongs to the same team h3, err = s.ds.Host(ctx, host3.ID) require.NoError(t, err) require.Equal(t, tm1.ID, *h3.TeamID) // but the team has prof2 updated profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID) require.NoError(t, err) require.Len(t, profs, 3) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig)) require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in, still belongs to the same team puppetRun(host2) h2, err = s.ds.Host(ctx, host2.ID) require.NoError(t, err) require.Equal(t, tm2.ID, *h2.TeamID) // but the team has prof2 updated as well profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID) require.NoError(t, err) require.Len(t, profs, 4) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.Equal(t, prof4, []byte(profs[3].Mobileconfig)) require.NotEqual(t, oldProf2, []byte(profs[1].Mobileconfig)) require.True(t, tm1.Config.MDM.EnableDiskEncryption) // the puppet manifest is changed, and prof3 is removed // node default { // fleetdm::profile { 'n1': // template => template('n1.mobileconfig.erb'), // group => 'base', // } // // fleetdm::profile { 'n2': // template => template('n2.mobileconfig.erb'), // group => 'workstations', // } // // if $facts['system_profiler']['hardware_uuid'] == 'host_2_uuid' { // fleetdm::profile { 'n4': // template => template('fleetdm/n4.mobileconfig.erb'), // group => 'kiosks', // } // } puppetRun = func(host *fleet.Host) { payload := []fleet.MDMApplePreassignProfilePayload{ { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof1, Group: "base", }, { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof2, Group: "workstations", }, } if host.UUID == host2.UUID { payload = append(payload, fleet.MDMApplePreassignProfilePayload{ ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof4, Group: "kiosks", }) } preassignAndMatch(payload) } // host1 checks in again puppetRun(host1) // still belongs to the same team h1, err = s.ds.Host(ctx, host1.ID) require.NoError(t, err) require.Equal(t, tm1.ID, *h1.TeamID) // but the team doesn't have prof3 anymore profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID) require.NoError(t, err) require.Len(t, profs, 2) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.True(t, tm1.Config.MDM.EnableDiskEncryption) // same for host2 puppetRun(host2) h2, err = s.ds.Host(ctx, host2.ID) require.NoError(t, err) require.Equal(t, tm2.ID, *h2.TeamID) profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID) require.NoError(t, err) require.Len(t, profs, 3) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof4, []byte(profs[2].Mobileconfig)) require.True(t, tm1.Config.MDM.EnableDiskEncryption) // The puppet manifest is drastically updated, this time to use exclusions on host3: // // node default { // fleetdm::profile { 'n1': // template => template('n1.mobileconfig.erb'), // group => 'base', // } // // fleetdm::profile { 'n2': // template => template('n2.mobileconfig.erb'), // group => 'workstations', // } // // if $facts['system_profiler']['hardware_uuid'] == 'host_3_uuid' { // fleetdm::profile { 'n3': // template => template('fleetdm/n3.mobileconfig.erb'), // group => 'no-nudge', // } // } else { // fleetdm::profile { 'n3': // ensure => absent, // template => template('fleetdm/n3.mobileconfig.erb'), // group => 'workstations', // } // } // } puppetRun = func(host *fleet.Host) { manifest := []fleet.MDMApplePreassignProfilePayload{ { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof1, Group: "base", }, { ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof2, Group: "workstations", }, } if host.UUID == host3.UUID { manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{ ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof3, Group: "no-nudge", Exclude: true, }) } else { manifest = append(manifest, fleet.MDMApplePreassignProfilePayload{ ExternalHostIdentifier: host.Hostname, HostUUID: host.UUID, Profile: prof3, Group: "workstations", }) } preassignAndMatch(manifest) } // host1 checks in puppetRun(host1) // the host belongs to the same team h1, err = s.ds.Host(ctx, host1.ID) require.NoError(t, err) require.Equal(t, tm1.ID, *h1.TeamID) // the team has the right profiles profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm1.ID) require.NoError(t, err) require.Len(t, profs, 3) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.Equal(t, prof3, []byte(profs[2].Mobileconfig)) require.True(t, tm1.Config.MDM.EnableDiskEncryption) // host2 checks in puppetRun(host2) // it is assigned to tm1 h2, err = s.ds.Host(ctx, host2.ID) require.NoError(t, err) require.Equal(t, tm1.ID, *h2.TeamID) // host3 checks in puppetRun(host3) // it is assigned to a new team h3, err = s.ds.Host(ctx, host3.ID) require.NoError(t, err) require.NotNil(t, h3.TeamID) require.NotEqual(t, tm1.ID, *h3.TeamID) require.NotEqual(t, tm2.ID, *h3.TeamID) // a new team is created tm3, err := s.ds.TeamWithExtras(ctx, *h3.TeamID) require.NoError(t, err) require.Equal(t, "base - no-nudge - workstations", tm3.Name) // and the right profiles profs, err = s.ds.ListMDMAppleConfigProfiles(ctx, &tm3.ID) require.NoError(t, err) require.Len(t, profs, 2) require.Equal(t, prof1, []byte(profs[0].Mobileconfig)) require.Equal(t, prof2, []byte(profs[1].Mobileconfig)) require.True(t, tm3.Config.MDM.EnableDiskEncryption) } func (s *integrationMDMTestSuite) TestMDMAppleListConfigProfiles() { t := s.T() ctx := context.Background() testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"}) require.NoError(t, err) mdmHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) s.runWorker() t.Run("no profiles", func(t *testing.T) { var listResp listMDMAppleConfigProfilesResponse s.DoJSON("GET", "/api/v1/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp) require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil require.Len(t, listResp.ConfigProfiles, 0) listResp = listMDMAppleConfigProfilesResponse{} s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &listResp) require.NotNil(t, listResp.ConfigProfiles) // expect empty slice instead of nil require.Len(t, listResp.ConfigProfiles, 0) var hostProfilesResp getHostProfilesResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp) require.NotNil(t, hostProfilesResp.Profiles) // expect empty slice instead of nil require.Len(t, hostProfilesResp.Profiles, 0) require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID) }) t.Run("with profiles", func(t *testing.T) { p1, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p1", "p1.identifier", "p1.uuid"), nil) require.NoError(t, err) _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p1, nil) require.NoError(t, err) p2, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p2", "p2.identifier", "p2.uuid"), &testTeam.ID) require.NoError(t, err) _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p2, nil) require.NoError(t, err) var resp listMDMAppleConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: 0}, http.StatusOK, &resp) require.NotNil(t, resp.ConfigProfiles) require.Len(t, resp.ConfigProfiles, 1) require.Equal(t, p1.Name, resp.ConfigProfiles[0].Name) require.Equal(t, p1.Identifier, resp.ConfigProfiles[0].Identifier) resp = listMDMAppleConfigProfilesResponse{} s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp) require.NotNil(t, resp.ConfigProfiles) require.Len(t, resp.ConfigProfiles, 1) require.Equal(t, p2.Name, resp.ConfigProfiles[0].Name) require.Equal(t, p2.Identifier, resp.ConfigProfiles[0].Identifier) p3, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("p3", "p3.identifier", "p3.uuid"), &testTeam.ID) require.NoError(t, err) _, err = s.ds.NewMDMAppleConfigProfile(ctx, *p3, nil) require.NoError(t, err) resp = listMDMAppleConfigProfilesResponse{} s.DoJSON("GET", fmt.Sprintf(`/api/v1/fleet/mdm/apple/profiles?team_id=%d`, testTeam.ID), nil, http.StatusOK, &resp) require.NotNil(t, resp.ConfigProfiles) require.Len(t, resp.ConfigProfiles, 2) for _, p := range resp.ConfigProfiles { if p.Name == p2.Name { //nolint:gocritic // ignore ifElseChain require.Equal(t, p2.Identifier, p.Identifier) } else if p.Name == p3.Name { require.Equal(t, p3.Identifier, p.Identifier) } else { require.Fail(t, "unexpected profile name") } } var hostProfilesResp getHostProfilesResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp) require.NotNil(t, hostProfilesResp.Profiles) require.Len(t, hostProfilesResp.Profiles, 1) require.Equal(t, p1.Name, hostProfilesResp.Profiles[0].Name) require.Equal(t, p1.Identifier, hostProfilesResp.Profiles[0].Identifier) require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID) // add the host to a team err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&testTeam.ID, []uint{mdmHost.ID})) require.NoError(t, err) hostProfilesResp = getHostProfilesResponse{} s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d/configuration_profiles", mdmHost.ID), nil, http.StatusOK, &hostProfilesResp) require.NotNil(t, hostProfilesResp.Profiles) require.Len(t, hostProfilesResp.Profiles, 2) require.EqualValues(t, mdmHost.ID, hostProfilesResp.HostID) }) } func (s *integrationMDMTestSuite) TestAppConfigMDMCustomSettings() { t := s.T() // set the macos custom settings fields with the deprecated Labels field acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": { "custom_settings": [ {"path": "foo", "labels": ["baz"]}, {"path": "bar"} ] } } }`), http.StatusOK, &acResp) assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings) // check that they are returned by a GET /config acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings) // set the windows custom settings fields with included/excluded labels acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_settings": { "custom_settings": [ {"path": "foo", "labels_exclude_any": ["x", "y"]}, {"path": "bar", "labels_include_all": ["a", "b"]}, {"path": "baz", "labels": ["c"]} ] } } }`), http.StatusOK, &acResp) assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings) assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings) // check that they are returned by a GET /config acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings) assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings) // patch without specifying the windows/macos custom settings fields and an unrelated // field, should not remove them acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings) assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings) // patch with explicitly empty macos/windows custom settings fields, would remove // them but this is a dry-run acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": { "custom_settings": null }, "windows_settings": { "custom_settings": null } } }`), http.StatusOK, &acResp, "dry_run", "true") assert.Equal(t, []fleet.MDMProfileSpec{{Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar"}}, acResp.MDM.MacOSSettings.CustomSettings) assert.Equal(t, optjson.SetSlice([]fleet.MDMProfileSpec{{Path: "foo", LabelsExcludeAny: []string{"x", "y"}}, {Path: "bar", LabelsIncludeAll: []string{"a", "b"}}, {Path: "baz", LabelsIncludeAll: []string{"c"}}}), acResp.MDM.WindowsSettings.CustomSettings) // patch with explicitly empty macos custom settings fields, removes them acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": { "custom_settings": null }, "windows_settings": { "custom_settings": null } } }`), http.StatusOK, &acResp) assert.Empty(t, acResp.MDM.MacOSSettings.CustomSettings) assert.Equal(t, optjson.Slice[fleet.MDMProfileSpec]{Set: true, Value: []fleet.MDMProfileSpec{}}, acResp.MDM.WindowsSettings.CustomSettings) // mix of labels fields returns an error res := s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "macos_settings": { "custom_settings": [ {"path": "foo", "labels": ["a"], "labels_exclude_any": ["b"]} ] } } }`), http.StatusUnprocessableEntity) msg := extractServerErrorText(res.Body) require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_settings": { "custom_settings": [ {"path": "foo", "labels_include_all": ["a"], "labels_exclude_any": ["b"]} ] } } }`), http.StatusUnprocessableEntity) msg = extractServerErrorText(res.Body) require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_settings": { "custom_settings": [ {"path": "foo", "labels_include_any": ["a"], "labels_exclude_any": ["b"]} ] } } }`), http.StatusUnprocessableEntity) msg = extractServerErrorText(res.Body) require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) res = s.Do("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_settings": { "custom_settings": [ {"path": "foo", "labels": ["a"], "labels_include_any": ["b"]} ] } } }`), http.StatusUnprocessableEntity) msg = extractServerErrorText(res.Body) require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) } func (s *integrationMDMTestSuite) TestApplyTeamsMDMAppleProfiles() { t := s.T() // create a team through the service so it initializes the agent ops teamName := t.Name() + "team1" team := &fleet.Team{ Name: teamName, Description: "desc team1", } var createTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) require.NotZero(t, createTeamResp.Team.ID) team = createTeamResp.Team // apply with custom macos settings teamSpecs := applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ MacOSSettings: map[string]interface{}{ "custom_settings": []map[string]interface{}{ {"path": "foo", "labels": []string{"a", "b"}}, {"path": "bar", "labels_exclude_any": []string{"c"}}, }, }, }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) // retrieving the team returns the custom macos settings var teamResp getTeamResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.Equal(t, []fleet.MDMProfileSpec{ {Path: "foo", LabelsIncludeAll: []string{"a", "b"}}, {Path: "bar", LabelsExcludeAny: []string{"c"}}, }, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings) // apply with invalid macos settings subfield should fail teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ MacOSSettings: map[string]interface{}{"foo_bar": 123}, }, }}} res := s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest) errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, `unsupported key provided: "foo_bar"`) // apply with some good and some bad macos settings subfield should fail teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ MacOSSettings: map[string]interface{}{"custom_settings": []interface{}{"A", true}}, }, }}} res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusBadRequest) errMsg = extractServerErrorText(res.Body) assert.Contains(t, errMsg, `invalid value type at 'macos_settings.custom_settings': expected array of MDMProfileSpecs but got bool`) // apply without custom macos settings specified and unrelated field, should // not replace existing settings teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ EnableDiskEncryption: optjson.SetBool(false), }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.Equal(t, []fleet.MDMProfileSpec{ {Path: "foo", LabelsIncludeAll: []string{"a", "b"}}, {Path: "bar", LabelsExcludeAny: []string{"c"}}, }, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings) // apply with explicitly empty custom macos settings would clear the existing // settings, but dry-run teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ MacOSSettings: map[string]interface{}{"custom_settings": []map[string]interface{}{}}, }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK, "dry_run", "true") teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.Equal(t, []fleet.MDMProfileSpec{ {Path: "foo", LabelsIncludeAll: []string{"a", "b"}}, {Path: "bar", LabelsExcludeAny: []string{"c"}}, }, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings) // apply with explicitly empty custom macos settings clears the existing settings teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ MacOSSettings: map[string]interface{}{"custom_settings": []map[string]interface{}{}}, }, }}} s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusOK) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.Equal(t, []fleet.MDMProfileSpec{}, teamResp.Team.Config.MDM.MacOSSettings.CustomSettings) // apply with invalid mix of labels fails teamSpecs = applyTeamSpecsRequest{Specs: []*fleet.TeamSpec{{ Name: teamName, MDM: fleet.TeamSpecMDM{ MacOSSettings: map[string]interface{}{ "custom_settings": []map[string]interface{}{ {"path": "bar", "labels": []string{"x"}}, {"path": "foo", "labels": []string{"a", "b"}, "labels_include_all": []string{"c"}}, }, }, }, }}} res = s.Do("POST", "/api/latest/fleet/spec/teams", teamSpecs, http.StatusUnprocessableEntity) errMsg = extractServerErrorText(res.Body) assert.Contains(t, errMsg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) } func (s *integrationMDMTestSuite) TestBatchSetMDMAppleProfiles() { t := s.T() ctx := context.Background() bigString := strings.Repeat("a", 1024*1024+1) // create a new team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) require.NoError(t, err) // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil}, http.StatusNoContent) s.lastActivityMatches( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) // apply to both team id and name s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name) // invalid team name s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: nil}, http.StatusNotFound, "team_name", uuid.New().String()) // Profile is too big resp := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{[]byte(bigString)}}, http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(resp.Body), "maximum configuration profile file size is 1 MB") // duplicate profile names s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ mobileconfigForTest("N1", "I1"), mobileconfigForTest("N1", "I2"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) // profiles with reserved identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ mobileconfigForTest("N1", "I1"), mobileconfigForTest(p, p), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p)) } // payloads with reserved types for p := range mobileconfig.FleetPayloadTypes() { if p == mobileconfig.FleetCustomSettingsPayloadType { // FileVault options in the custom settings payload are checked in file_vault_options_test.go continue } res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ mobileconfigForTestWithContent("N1", "I1", "II1", p, ""), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) switch p { case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType: assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg) default: assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p)) } } // payloads with reserved identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ mobileconfigForTestWithContent("N1", "I1", p, "random", ""), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p)) } // successfully apply a profile for the team s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: [][]byte{ mobileconfigForTest("N1", "I1"), }}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.lastActivityMatches( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) } func (s *integrationMDMTestSuite) TestHostMDMAppleProfilesStatus() { t := s.T() ctx := context.Background() createManualMDMEnrollWithOrbit := func(secret string, doUserEnroll bool) (*fleet.Host, *fleet.NanoEnrollment, *mdmtest.TestAppleMDMClient) { // orbit enrollment happens before mdm enrollment, otherwise the host would // always receive the "no team" profiles on mdm enrollment since it would // not be part of any team yet (team assignment is done when it enrolls // with orbit). mdmDevice := mdmtest.NewTestMDMClientAppleDirect(mdmtest.AppleEnrollInfo{ SCEPChallenge: s.scepChallenge, SCEPURL: s.server.URL + apple_mdm.SCEPPath, MDMURL: s.server.URL + apple_mdm.MDMPath, }, "MacBookPro16,1") // enroll the device with orbit var resp EnrollOrbitResponse s.DoJSON("POST", "/api/fleet/orbit/enroll", contract.EnrollOrbitRequest{ EnrollSecret: secret, HardwareUUID: mdmDevice.UUID, // will not match any existing host HardwareSerial: mdmDevice.SerialNumber, }, http.StatusOK, &resp) require.NotEmpty(t, resp.OrbitNodeKey) orbitNodeKey := resp.OrbitNodeKey h, err := s.ds.LoadHostByOrbitNodeKey(ctx, orbitNodeKey) require.NoError(t, err) h.OrbitNodeKey = &orbitNodeKey h.Platform = "darwin" err = mdmDevice.Enroll() require.NoError(t, err) var userEnrollment *fleet.NanoEnrollment if doUserEnroll { // Do a user enrollment with a bit of extra sanity checking userEnrollment, err = s.ds.GetNanoMDMUserEnrollment(ctx, h.UUID) require.NoError(t, err) require.Nil(t, userEnrollment) err = mdmDevice.UserEnroll() require.NoError(t, err) userEnrollment, err = s.ds.GetNanoMDMUserEnrollment(ctx, h.UUID) require.NoError(t, err) require.NotNil(t, userEnrollment) } return h, userEnrollment, mdmDevice } triggerReconcileProfilesMarkVerifying := func() { t.Logf("[TestHostMDMAppleProfilesStatus] Calling awaitTriggerProfileSchedule at %s", time.Now().Format(time.RFC3339)) s.awaitTriggerProfileSchedule(t) t.Logf("[TestHostMDMAppleProfilesStatus] awaitTriggerProfileSchedule completed, updating profiles to verifying at %s", time.Now().Format(time.RFC3339)) // this will only mark them as "pending", as the response to confirm // profile deployment is asynchronous, so we simulate it here by // updating any "pending" (not NULL) profiles to "verifying" mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending) return err }) t.Logf("[TestHostMDMAppleProfilesStatus] Profiles updated to verifying status at %s", time.Now().Format(time.RFC3339)) } assignHostToTeam := func(h *fleet.Host, teamID *uint) { var moveHostResp addHostsToTeamResponse s.DoJSON("POST", "/api/v1/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: teamID, HostIDs: []uint{h.ID}}, http.StatusOK, &moveHostResp) h.TeamID = teamID } // add a couple global profiles payloadScopeSystem := fleet.PayloadScopeSystem payloadScopeUser := fleet.PayloadScopeUser globalProfiles := [][]byte{ mobileconfigForTest("G1", "G1"), scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem), scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // create the no-team enroll secret var applyResp applyEnrollSecretSpecResponse globalEnrollSec := "global_enroll_sec" s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ Spec: &fleet.EnrollSecretSpec{ Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}}, }, }, http.StatusOK, &applyResp) // create a team with a couple profiles tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_1"}) require.NoError(t, err) tm1Profiles := [][]byte{ mobileconfigForTest("T1.1", "T1.1"), scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem), scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID)) // create the team 1 enroll secret var teamResp teamEnrollSecretsResponse tm1EnrollSec := "team1_enroll_sec" s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d/secrets", tm1.ID), modifyTeamEnrollSecretsRequest{ Secrets: []fleet.EnrollSecret{{Secret: tm1EnrollSec}}, }, http.StatusOK, &teamResp) // create another team with different profiles tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profiles_status_2"}) require.NoError(t, err) tm2Profiles := [][]byte{ mobileconfigForTest("T2.1", "T2.1"), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) // enroll a couple hosts in no team h1, h1UserEnrollment, _ := createManualMDMEnrollWithOrbit(globalEnrollSec, true) require.Nil(t, h1.TeamID) h2, _, _ := createManualMDMEnrollWithOrbit(globalEnrollSec, false) require.Nil(t, h2.TeamID) // run the cron t.Logf("[TestHostMDMAppleProfilesStatus] Starting FIRST cron run (after h1, h2 enrolled) at %s", time.Now().Format(time.RFC3339)) s.awaitTriggerProfileSchedule(t) t.Logf("[TestHostMDMAppleProfilesStatus] FIRST cron run completed at %s", time.Now().Format(time.RFC3339)) // G3 is user-scoped and the h2 host doesn't have a user-channel yet (and // enrolled just now, so the minimum delay to give up and fail the profile // delivery is not reached) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // Verify there is a command on the user channel for h1 enrollmentIds, err := s.ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx) require.NoError(t, err) assert.Contains(t, enrollmentIds, h1UserEnrollment.ID) assert.Contains(t, enrollmentIds, h1.UUID) assert.Contains(t, enrollmentIds, h2.UUID) // enroll a couple hosts in team 1 h3, h3UserEnrollment, _ := createManualMDMEnrollWithOrbit(tm1EnrollSec, true) require.NotNil(t, h3.TeamID) require.Equal(t, tm1.ID, *h3.TeamID) h4, _, h4Device := createManualMDMEnrollWithOrbit(tm1EnrollSec, false) require.NotNil(t, h4.TeamID) require.Equal(t, tm1.ID, *h4.TeamID) // run the cron t.Logf("[TestHostMDMAppleProfilesStatus] Starting SECOND cron run (after h3, h4 enrolled in team1) at %s", time.Now().Format(time.RFC3339)) s.awaitTriggerProfileSchedule(t) t.Logf("[TestHostMDMAppleProfilesStatus] SECOND cron run completed at %s", time.Now().Format(time.RFC3339)) // T1.3 is user-scoped and the h4 host doesn't have a user-channel yet (and // enrolled just now, so the minimum delay to give up and send the // user-scoped profiles to the device channel is not reached) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // apply the pending profiles triggerReconcileProfilesMarkVerifying() // Verify there is a command on the user channel for h3 enrollmentIds, err = s.ds.GetEnrollmentIDsWithPendingMDMAppleCommands(ctx) require.NoError(t, err) assert.Contains(t, enrollmentIds, h3UserEnrollment.ID) assert.Contains(t, enrollmentIds, h3.UUID) assert.Contains(t, enrollmentIds, h4.UUID) // switch a no team host (h1) to a team (tm2) t.Logf("[TestHostMDMAppleProfilesStatus] Transferring host h1 (id=%d) from no team to team tm2 (id=%d) at %s", h1.ID, tm2.ID, time.Now().Format(time.RFC3339)) var moveHostResp addHostsToTeamResponse s.DoJSON("POST", "/api/v1/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h1.ID}}, http.StatusOK, &moveHostResp) t.Logf("[TestHostMDMAppleProfilesStatus] Host h1 transfer completed at %s", time.Now().Format(time.RFC3339)) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // switch a team host (h3) to another team (tm2) s.DoJSON("POST", "/api/v1/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: &tm2.ID, HostIDs: []uint{h3.ID}}, http.StatusOK, &moveHostResp) // create the user-enrollment for host h4 err = h4Device.UserEnroll() require.NoError(t, err) // run the cron t.Logf("[TestHostMDMAppleProfilesStatus] Starting THIRD cron run (after h3->tm2, h4 user enrolled) at %s", time.Now().Format(time.RFC3339)) s.awaitTriggerProfileSchedule(t) t.Logf("[TestHostMDMAppleProfilesStatus] THIRD cron run completed at %s", time.Now().Format(time.RFC3339)) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // switch a team host (h4) to no team t.Logf("[TestHostMDMAppleProfilesStatus] Transferring host h4 (id=%d) from team tm1 to NO TEAM (nil) at %s", h4.ID, time.Now().Format(time.RFC3339)) s.DoJSON("POST", "/api/v1/fleet/hosts/transfer", addHostsToTeamRequest{TeamID: nil, HostIDs: []uint{h4.ID}}, http.StatusOK, &moveHostResp) t.Logf("[TestHostMDMAppleProfilesStatus] Host h4 transfer to NO TEAM completed at %s", time.Now().Format(time.RFC3339)) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h3: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "T1.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, // still pending install due to cron not having run {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // apply the pending profiles triggerReconcileProfilesMarkVerifying() // add a profile to no team (h2 and h4 are now part of no team) body, headers := generateNewProfileMultipartRequest(t, "some_name", mobileconfigForTest("G4", "G4"), s.token, nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h2: { // still no user channel {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, h4: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // add a profile to team 2 (h1 and h3 are now part of team 2) body, headers = generateNewProfileMultipartRequest(t, "some_name", mobileconfigForTest("T2.2", "T2.2"), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm2.ID)}}) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // apply the pending profiles triggerReconcileProfilesMarkVerifying() // delete a no team profile noTeamProfs, err := s.ds.ListMDMAppleConfigProfiles(ctx, nil) require.NoError(t, err) var g1ProfID uint for _, p := range noTeamProfs { if p.Identifier == "G1" { g1ProfID = p.ProfileID break } } require.NotZero(t, g1ProfID) var delProfResp deleteMDMAppleConfigProfileResponse s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", g1ProfID), deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h2: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // delete a team profile tm2Profs, err := s.ds.ListMDMAppleConfigProfiles(ctx, &tm2.ID) require.NoError(t, err) var tm21ProfID uint for _, p := range tm2Profs { if p.Identifier == "T2.1" { tm21ProfID = p.ProfileID break } } require.NotZero(t, tm21ProfID) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", tm21ProfID), deleteMDMAppleConfigProfileRequest{}, http.StatusOK, &delProfResp) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "T2.1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // apply the pending profiles triggerReconcileProfilesMarkVerifying() // bulk-set profiles for no team, with add/delete/edit g2Edited := mobileconfigForTest("G2b", "G2b") g5Content := mobileconfigForTest("G5", "G5") s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{ Profiles: [][]byte{ g2Edited, // G3 is deleted // G4 is deleted g5Content, }, }, http.StatusNoContent) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h2: { {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G3.user", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G4", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // bulk-set profiles for a team, with add/delete/edit t22Edited := mobileconfigForTest("T2.2b", "T2.2b") t23Content := mobileconfigForTest("T2.3", "T2.3") s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{ Profiles: [][]byte{ t22Edited, t23Content, }, }, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "T2.2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // apply the pending profiles triggerReconcileProfilesMarkVerifying() // bulk-set profiles for no team and team 2, without changes, and team 1 added (but no host affected) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{ Profiles: [][]byte{ g2Edited, g5Content, }, }, http.StatusNoContent) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{ Profiles: [][]byte{ t22Edited, t23Content, }, }, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) s.Do("POST", "/api/latest/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{ Profiles: [][]byte{ scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser), }, }, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID)) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h2: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // delete team 2 (h1 and h3 are part of that team) s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/teams/%d", tm2.ID), nil, http.StatusOK) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "T2.2b", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "T2.3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // apply the pending profiles triggerReconcileProfilesMarkVerifying() // all profiles now verifying s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h2: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // h1 verified one of the profiles require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{ "G2b": {Identifier: "G2b", DisplayName: "G2b", InstallDate: time.Now()}, })) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h2: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // switch a team host (h1) to another team (tm1) assignHostToTeam(h1, &tm1.ID) // Create a new profile that will be labeled body, headers = generateNewProfileMultipartRequest( t, "label_prof", mobileconfigForTest("label_prof", "label_prof"), s.token, map[string][]string{"team_id": {fmt.Sprintf("%d", tm1.ID)}}, ) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers) var uid string mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &uid, `SELECT profile_uuid FROM mdm_apple_configuration_profiles WHERE identifier = ?`, "label_prof") }) label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "test label 1", Query: "select 1;"}) require.NoError(t, err) // Update label with host membership mysql.ExecAdhocSQL( t, s.ds, func(db sqlx.ExtContext) error { _, err := db.ExecContext( context.Background(), "INSERT IGNORE INTO label_membership (host_id, label_id) VALUES (?, ?)", h1.ID, label.ID, ) return err }, ) // Update profile <-> label mapping mysql.ExecAdhocSQL( t, s.ds, func(db sqlx.ExtContext) error { _, err := db.ExecContext( context.Background(), "INSERT INTO mdm_configuration_profile_labels (apple_profile_uuid, label_name, label_id) VALUES (?, ?, ?)", uid, label.Name, label.ID, ) return err }, ) triggerReconcileProfilesMarkVerifying() s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h2: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) require.NoError(t, apple_mdm.VerifyHostMDMProfiles(context.Background(), s.ds, h1, map[string]*fleet.HostMacOSProfile{ "label_prof": {Identifier: "label_prof", DisplayName: "label_prof", InstallDate: time.Now()}, })) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ h1: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "T1.3.user", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "label_prof", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, h2: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h3: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, h4: { {Identifier: "G2b", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "G5", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) } func (s *integrationMDMTestSuite) TestMDMConfigProfileCRUD() { t := s.T() ctx := context.Background() testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"}) require.NoError(t, err) // Ensure MDM is turned on appConfig, err := s.ds.AppConfig(ctx) require.NoError(t, err) appConfig.MDM.AndroidEnabledAndConfigured = true appConfig.MDM.EnabledAndConfigured = true appConfig.MDM.WindowsEnabledAndConfigured = true err = s.ds.SaveAppConfig(ctx, appConfig) require.NoError(t, err) // NOTE: label names starting with "-" are sent as "labels_excluding_any" // (and the leading "-" is removed from the name). Names starting with // "!" are sent as the deprecated "labels" field (and the "!" is removed). // Names starting with a "~" prefix are sent as "labels_include_any" // (and the leading "~" is removed. addLabelsFields := func(labelNames []string) map[string][]string { var deprLabels, inclAllLabels, inclAnyLabels, exclLabels []string for _, lbl := range labelNames { switch { case strings.HasPrefix(lbl, "~"): inclAnyLabels = append(inclAnyLabels, strings.TrimPrefix(lbl, "~")) case strings.HasPrefix(lbl, "-"): exclLabels = append(exclLabels, strings.TrimPrefix(lbl, "-")) case strings.HasPrefix(lbl, "!"): deprLabels = append(deprLabels, strings.TrimPrefix(lbl, "!")) default: inclAllLabels = append(inclAllLabels, lbl) } } fields := make(map[string][]string) if len(deprLabels) > 0 { fields["labels"] = deprLabels } if len(inclAllLabels) > 0 { fields["labels_include_all"] = inclAllLabels } if len(exclLabels) > 0 { fields["labels_exclude_any"] = exclLabels } if len(inclAnyLabels) > 0 { fields["labels_include_any"] = inclAnyLabels } return fields } assertAppleProfile := func(filename, name, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string { fields := addLabelsFields(labelNames) if teamID > 0 { fields["team_id"] = []string{fmt.Sprintf("%d", teamID)} } body, headers := generateNewProfileMultipartRequest( t, filename, mobileconfigForTest(name, ident), s.token, fields, ) res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers) if wantErrMsg != "" { errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, wantErrMsg) return "" } var resp newMDMConfigProfileResponse err := json.NewDecoder(res.Body).Decode(&resp) require.NoError(t, err) require.NotEmpty(t, resp.ProfileUUID) require.Equal(t, "a", string(resp.ProfileUUID[0])) return resp.ProfileUUID } assertAppleDeclaration := func(filename, ident string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string { fields := addLabelsFields(labelNames) if teamID > 0 { fields["team_id"] = []string{fmt.Sprintf("%d", teamID)} } bytes := []byte(fmt.Sprintf(`{ "Type": "com.apple.configuration.foo", "Payload": { "Echo": "f1337" }, "Identifier": "%s" }`, ident)) body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields) res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers) if wantErrMsg != "" { errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, wantErrMsg) return "" } var resp newMDMConfigProfileResponse err := json.NewDecoder(res.Body).Decode(&resp) require.NoError(t, err) require.NotEmpty(t, resp.ProfileUUID) require.Equal(t, fleet.MDMAppleDeclarationUUIDPrefix, string(resp.ProfileUUID[0])) return resp.ProfileUUID } createAppleProfile := func(name, ident string, teamID uint, labelNames []string) string { uid := assertAppleProfile(name+".mobileconfig", name, ident, teamID, labelNames, http.StatusOK, "") var wantJSON string if teamID == 0 { wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "profile_identifier": %q}`, name, ident) } else { wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "profile_identifier": %q}`, teamID, testTeam.Name, name, ident) } s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedMacosProfile{}.ActivityName(), wantJSON, 0) return uid } createAppleDeclaration := func(name, ident string, teamID uint, labelNames []string) string { uid := assertAppleDeclaration(name+".json", ident, teamID, labelNames, http.StatusOK, "") var wantJSON string if teamID == 0 { wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q, "identifier": %q}`, name, ident) } else { wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q, "identifier": %q}`, teamID, testTeam.Name, name, ident) } s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedDeclarationProfile{}.ActivityName(), wantJSON, 0) return uid } assertWindowsProfile := func(filename, locURI string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string { fields := addLabelsFields(labelNames) if teamID > 0 { fields["team_id"] = []string{fmt.Sprintf("%d", teamID)} } body, headers := generateNewProfileMultipartRequest( t, filename, []byte(fmt.Sprintf(`%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 } assertAndroidProfile := func(filename string, teamID uint, labelNames []string, wantStatus int, wantErrMsg string) string { fields := addLabelsFields(labelNames) if teamID > 0 { fields["team_id"] = []string{fmt.Sprintf("%d", teamID)} } bytes := []byte(`{ "removeUserDisabled": false }`) body, headers := generateNewProfileMultipartRequest(t, filename, bytes, s.token, fields) res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), wantStatus, headers) if wantErrMsg != "" { errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, wantErrMsg) return "" } var resp newMDMConfigProfileResponse err := json.NewDecoder(res.Body).Decode(&resp) require.NoError(t, err) require.NotEmpty(t, resp.ProfileUUID) require.Equal(t, fleet.MDMAndroidProfileUUIDPrefix, string(resp.ProfileUUID[0])) return resp.ProfileUUID } createAndroidProfile := func(name string, teamID uint, labelNames []string) string { uid := assertAndroidProfile(name+".json", teamID, labelNames, http.StatusOK, "") var wantJSON string if teamID == 0 { wantJSON = fmt.Sprintf(`{"team_id": null, "team_name": null, "profile_name": %q}`, name) } else { wantJSON = fmt.Sprintf(`{"team_id": %d, "team_name": %q, "profile_name": %q}`, teamID, testTeam.Name, name) } s.lastActivityOfTypeMatches(fleet.ActivityTypeCreatedAndroidProfile{}.ActivityName(), wantJSON, 0) return uid } // create a couple Apple profiles for no-team and team noTeamAppleProfUUID := createAppleProfile("apple-global-profile", "test-global-ident", 0, nil) teamAppleProfUUID := createAppleProfile("apple-team-profile", "test-team-ident", testTeam.ID, nil) // create a couple Windows profiles for no-team and team noTeamWinProfUUID := createWindowsProfile("win-global-profile", 0, nil) teamWinProfUUID := createWindowsProfile("win-team-profile", testTeam.ID, nil) // Create a couple Android profiles for no-team and team noTeamAndroidProfUUID := createAndroidProfile("android-global-profile", 0, nil) teamAndroidProfUUID := createAndroidProfile("android-team-profile", testTeam.ID, nil) // Windows profile name conflicts with Apple's for no team assertWindowsProfile("apple-global-profile.xml", "./Test", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // but no conflict for team 1 assertWindowsProfile("apple-global-profile.xml", "./Test", testTeam.ID, nil, http.StatusOK, "") // Apple profile name conflicts with Windows' for no team assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // but no conflict for team 1 assertAppleProfile("win-global-profile.mobileconfig", "win-global-profile", "test-global-ident-2", testTeam.ID, nil, http.StatusOK, "") // Windows profile name conflicts with Apple's for team 1 assertWindowsProfile("apple-team-profile.xml", "./Test", testTeam.ID, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // but no conflict for no-team assertWindowsProfile("apple-team-profile.xml", "./Test", 0, nil, http.StatusOK, "") // Apple profile name conflicts with Windows' for team 1 assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", testTeam.ID, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // but no conflict for no-team assertAppleProfile("win-team-profile.mobileconfig", "win-team-profile", "test-team-ident-2", 0, nil, http.StatusOK, "") // Android profile name conflicts with Apple's for no team assertAndroidProfile("apple-global-profile.json", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // add some macOS declarations createAppleDeclaration("apple-declaration", "test-declaration-ident", 0, nil) // identifier must be unique, it conflicts with existing declaration assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", 0, nil, http.StatusConflict, "test-declaration-ident already exists") // name is pulled from filename, it conflicts with existing declaration assertAppleDeclaration("apple-declaration.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // uniqueness is checked only within team, so it's fine to have the same name and identifier in different teams assertAppleDeclaration("apple-declaration.json", "test-declaration-ident", testTeam.ID, nil, http.StatusOK, "") // name is pulled from filename, it conflicts with existing macOS config profile assertAppleDeclaration("apple-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // name is pulled from filename, it conflicts with existing macOS config profile assertAppleDeclaration("win-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // name is pulled from filename, it conflicts with existing Android config profile assertAppleDeclaration("android-global-profile.json", "test-declaration-ident-2", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // windows profile name conflicts with existing declaration assertWindowsProfile("apple-declaration.xml", "./Test", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // macOS profile name conflicts with existing declaration assertAppleProfile("apple-declaration.mobileconfig", "apple-declaration", "test-declaration-ident", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // Android profile name conflicts with existing declaration assertAndroidProfile("apple-declaration.json", 0, nil, http.StatusConflict, SameProfileNameUploadErrorMsg) // not an xml nor mobileconfig file assertWindowsProfile("foo.txt", "./Test", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.") assertAppleProfile("foo.txt", "foo", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.") assertAppleDeclaration("foo.txt", "foo-ident", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.") assertAndroidProfile("foo.txt", 0, nil, http.StatusBadRequest, "Couldn't add profile. The file should be a .mobileconfig, XML, or JSON file.") // Windows-reserved LocURI assertWindowsProfile("bitlocker.xml", syncml.FleetBitLockerTargetLocURI, 0, nil, http.StatusBadRequest, syncml.DiskEncryptionProfileRestrictionErrMsg) assertWindowsProfile("updates.xml", syncml.FleetOSUpdateTargetLocURI, testTeam.ID, nil, http.StatusBadRequest, "Couldn't add. Custom configuration profiles can't include Windows updates settings.") // Fleet-reserved profiles for name := range servermdm.FleetReservedProfileNames() { assertAppleProfile(name+".mobileconfig", name, name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %s is not allowed`, name)) assertAppleDeclaration(name+".json", name+"-ident", 0, nil, http.StatusBadRequest, fmt.Sprintf(`name %q is not allowed`, name)) assertWindowsProfile(name+".xml", "./Test", 0, nil, http.StatusBadRequest, fmt.Sprintf(`Couldn't add. Profile name %q is not allowed.`, name)) } // profiles with non-existent labels assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"does-not-exist"}, http.StatusBadRequest, "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") assertAndroidProfile("android-with-labels.json", 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") assertAndroidProfile("android-profile-with-labels.json", 0, []string{"does-not-exist", "bar"}, http.StatusBadRequest, "some or all the labels provided don't exist") // profiles with invalid mix of labels assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertAppleProfile("apple-invalid-profile-with-labels.mobileconfig", "apple-invalid-profile-with-labels", "ident-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "-bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertAppleDeclaration("apple-invalid-decl-with-labels.json", "ident-decl-with-labels", 0, []string{"foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertWindowsProfile("win-invalid-profile-with-labels.xml", "./Test", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "!bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) assertAndroidProfile("android-invalid-profile-with-labels.json", 0, []string{"-foo", "~bar"}, http.StatusBadRequest, `Only one of "labels_exclude_any", "labels_include_all", "labels_include_any", or "labels" can be included.`) // profiles with valid labels uuidAppleWithLabel := assertAppleProfile("apple-profile-with-labels.mobileconfig", "apple-profile-with-labels", "ident-with-labels", 0, []string{"!foo"}, http.StatusOK, "") uuidAppleWithInclAnyLabel := assertAppleProfile("apple-profile-with-incl-any-labels.mobileconfig", "apple-profile-with-incl-any-labels", "ident-with-incl-any-labels", 0, []string{"~foo", "~bar"}, http.StatusOK, "") uuidAppleDDMWithLabel := createAppleDeclaration("apple-decl-with-labels", "ident-decl-with-labels", 0, []string{"foo"}) uuidWindowsWithLabel := assertWindowsProfile("win-profile-with-labels.xml", "./Test", 0, []string{"-foo", "-bar"}, http.StatusOK, "") uuidAppleDDMTeamWithLabel := createAppleDeclaration("apple-team-decl-with-labels", "ident-team-decl-with-labels", testTeam.ID, []string{"-foo"}) uuidWindowsTeamWithLabel := assertWindowsProfile("win-team-profile-with-labels.xml", "./Test", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "") uuidWindowsTeamWithInclAnyLabel := assertWindowsProfile("win-team-profile-with-incl-any-labels.xml", "./Test", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "") uuidAndroidWithLabel := assertAndroidProfile("android-profile-with-labels.json", 0, []string{"-foo", "-bar"}, http.StatusOK, "") uuidAndroidTeamWithLabel := assertAndroidProfile("android-team-profile-with-labels.json", testTeam.ID, []string{"foo", "bar"}, http.StatusOK, "") uuidAndroidTeamWithInclAnyLabel := assertAndroidProfile("android-team-profile-with-incl-any-labels.json", testTeam.ID, []string{"~foo", "~bar"}, http.StatusOK, "") // Windows invalid content body, headers := generateNewProfileMultipartRequest(t, "win.xml", []byte("\x00\x01\x02"), s.token, nil) res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn't add. The file should include valid XML:") // Apple invalid mobileconfig content body, headers = generateNewProfileMultipartRequest(t, "apple.mobileconfig", []byte("\x00\x01\x02"), s.token, nil) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Configuration profiles can't be signed. Fleet wil sign the profile for you.") // Apple/Android invalid json declaration body, headers = generateNewProfileMultipartRequest(t, "apple.json", []byte("{"), s.token, nil) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Couldn't add. The file should include valid JSON:") // Apple/Android cannot determine which body, headers = generateNewProfileMultipartRequest(t, "apple_or_android.json", []byte(`{"lower_key": true,"UpperKey": false}`), s.token, nil) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Keys in declaration (DDM) profile must contain only letters and start with a uppercase letter. Keys in Android profile must contain only letters and start with a lowercase letter.") // Android invalid keys for key, expectedErr := range fleet.AndroidForbiddenJSONKeys { body, headers = generateNewProfileMultipartRequest(t, "android.json", []byte(fmt.Sprintf(`{"%s": true}`, key)), s.token, nil) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusBadRequest, headers) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, expectedErr) } // get the existing profiles work expectedProfiles := []fleet.MDMConfigProfilePayload{ {ProfileUUID: noTeamAppleProfUUID, Platform: "darwin", Name: "apple-global-profile", Identifier: "test-global-ident", TeamID: nil, Scope: string(fleet.PayloadScopeSystem)}, {ProfileUUID: teamAppleProfUUID, Platform: "darwin", Name: "apple-team-profile", Identifier: "test-team-ident", TeamID: &testTeam.ID, Scope: string(fleet.PayloadScopeSystem)}, {ProfileUUID: noTeamWinProfUUID, Platform: "windows", Name: "win-global-profile", TeamID: nil}, {ProfileUUID: teamWinProfUUID, Platform: "windows", Name: "win-team-profile", TeamID: &testTeam.ID}, {ProfileUUID: noTeamAndroidProfUUID, Platform: "android", Name: "android-global-profile", TeamID: nil}, {ProfileUUID: teamAndroidProfUUID, Platform: "android", Name: "android-team-profile", TeamID: &testTeam.ID}, { ProfileUUID: uuidAppleDDMWithLabel, Platform: "darwin", Name: "apple-decl-with-labels", Identifier: "ident-decl-with-labels", TeamID: nil, LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidAppleWithLabel, Platform: "darwin", Name: "apple-profile-with-labels", Identifier: "ident-with-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem), LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidAppleWithInclAnyLabel, Platform: "darwin", Name: "apple-profile-with-incl-any-labels", Identifier: "ident-with-incl-any-labels", TeamID: nil, Scope: string(fleet.PayloadScopeSystem), LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidWindowsWithLabel, Platform: "windows", Name: "win-profile-with-labels", TeamID: nil, LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidAppleDDMTeamWithLabel, Platform: "darwin", Name: "apple-team-decl-with-labels", Identifier: "ident-team-decl-with-labels", TeamID: &testTeam.ID, LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidWindowsTeamWithLabel, Platform: "windows", Name: "win-team-profile-with-labels", TeamID: &testTeam.ID, LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidWindowsTeamWithInclAnyLabel, Platform: "windows", Name: "win-team-profile-with-incl-any-labels", TeamID: &testTeam.ID, LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidAndroidWithLabel, Platform: "android", Name: "android-profile-with-labels", TeamID: nil, LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidAndroidTeamWithLabel, Platform: "android", Name: "android-team-profile-with-labels", TeamID: &testTeam.ID, LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, { ProfileUUID: uuidAndroidTeamWithInclAnyLabel, Platform: "android", Name: "android-team-profile-with-incl-any-labels", TeamID: &testTeam.ID, LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: labelBar.ID, LabelName: labelBar.Name}, {LabelID: labelFoo.ID, LabelName: labelFoo.Name}, }, }, } for _, prof := range expectedProfiles { var getResp getMDMConfigProfileResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, &getResp) require.NotZero(t, getResp.CreatedAt) require.NotZero(t, getResp.UploadedAt) if getResp.Platform == "darwin" { require.Len(t, getResp.Checksum, 16) } else { require.Empty(t, getResp.Checksum) } getResp.CreatedAt, getResp.UploadedAt = time.Time{}, time.Time{} getResp.Checksum = nil // sort the labels by name sort.Slice(getResp.LabelsIncludeAll, func(i, j int) bool { return getResp.LabelsIncludeAll[i].LabelName < getResp.LabelsIncludeAll[j].LabelName }) sort.Slice(getResp.LabelsExcludeAny, func(i, j int) bool { return getResp.LabelsExcludeAny[i].LabelName < getResp.LabelsExcludeAny[j].LabelName }) sort.Slice(getResp.LabelsIncludeAny, func(i, j int) bool { return getResp.LabelsIncludeAny[i].LabelName < getResp.LabelsIncludeAny[j].LabelName }) require.Equal(t, prof, *getResp.MDMConfigProfilePayload) resp := s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", prof.ProfileUUID), nil, http.StatusOK, "alt", "media") require.NotZero(t, resp.ContentLength) require.Contains(t, resp.Header.Get("Content-Disposition"), "attachment;") if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleProfileUUIDPrefix) { //nolint:gocritic // ignore ifElseChain require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") } else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAppleDeclarationUUIDPrefix) { require.Contains(t, resp.Header.Get("Content-Type"), "application/json") } else if strings.HasPrefix(prof.ProfileUUID, fleet.MDMAndroidProfileUUIDPrefix) { require.Contains(t, resp.Header.Get("Content-Type"), "application/json") } else { require.Contains(t, resp.Header.Get("Content-Type"), "application/octet-stream") } require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") b, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, resp.ContentLength, int64(len(b))) } var getResp getMDMConfigProfileResponse // get an unknown Apple profile s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &getResp) s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, "alt", "media") // get an unknown Apple declaration s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &getResp) s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, "alt", "media") // get an unknown Windows profile s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &getResp) s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, "alt", "media") // get an unknown Android profile s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &getResp) s.Do("GET", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, "alt", "media") var deleteResp deleteMDMConfigProfileResponse // delete existing Apple profiles s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAppleProfUUID), nil, http.StatusOK, &deleteResp) // turn off apple MDM appCfg, err := s.ds.AppConfig(ctx) require.NoError(t, err) appCfg.MDM.EnabledAndConfigured = false err = s.ds.SaveAppConfig(ctx, appCfg) require.NoError(t, err) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAppleProfUUID), nil, http.StatusOK, &deleteResp) // delete non-existing Apple profile s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "ano-such-profile"), nil, http.StatusNotFound, &deleteResp) // delete existing Apple declaration s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uuidAppleDDMWithLabel), nil, http.StatusOK, &deleteResp) s.lastActivityOfTypeMatches( fleet.ActivityTypeDeletedDeclarationProfile{}.ActivityName(), `{"profile_name": "apple-decl-with-labels", "identifier": "ident-decl-with-labels", "team_id": null, "team_name": null}`, 0, ) // delete non-existing Apple declaration s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAppleDeclarationUUIDPrefix)), nil, http.StatusNotFound, &deleteResp) // delete existing Windows profiles s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamWinProfUUID), nil, http.StatusOK, &deleteResp) // Now disabling windows MDM filler := struct{}{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_enabled_and_configured": false}}`), http.StatusOK, &filler) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamWinProfUUID), nil, http.StatusOK, &deleteResp) // delete non-existing Windows profile s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", "wno-such-profile"), nil, http.StatusNotFound, &deleteResp) // delete existing Android profiles s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", noTeamAndroidProfUUID), nil, http.StatusOK, &deleteResp) s.lastActivityOfTypeMatches( fleet.ActivityTypeDeletedAndroidProfile{}.ActivityName(), `{"profile_name": "android-global-profile", "team_id": null, "team_name": null}`, 0, ) // turn off Android MDM appCfg, err = s.ds.AppConfig(ctx) require.NoError(t, err) appCfg.MDM.AndroidEnabledAndConfigured = false err = s.ds.SaveAppConfig(ctx, appCfg) require.NoError(t, err) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", teamAndroidProfUUID), nil, http.StatusOK, &deleteResp) s.lastActivityOfTypeMatches( fleet.ActivityTypeDeletedAndroidProfile{}.ActivityName(), fmt.Sprintf(`{"profile_name": "android-team-profile", "team_id": %d, "team_name": %q}`, testTeam.ID, testTeam.Name), 0, ) // delete non-existing Android profiles s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", fmt.Sprintf("%sno-such-profile", fleet.MDMAndroidProfileUUIDPrefix)), nil, http.StatusNotFound, &deleteResp) // turn back on apple MDM mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err = q.ExecContext(ctx, "UPDATE app_config_json SET json_value = JSON_SET(json_value, '$.mdm.enabled_and_configured', true) ") return err }) // trying to create/delete profiles managed by Fleet fails for p := range mobileconfig.FleetPayloadIdentifiers() { assertAppleProfile("foo.mobileconfig", p, p, 0, nil, http.StatusBadRequest, fmt.Sprintf("payload identifier %s is not allowed", p)) // create it directly in the DB to test deletion uid := "a" + uuid.NewString() mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { mc := mcBytesForTest(p, p, uuid.New().String()) _, err := q.ExecContext(ctx, "INSERT INTO mdm_apple_configuration_profiles (profile_uuid, identifier, name, mobileconfig, checksum, team_id, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP())", uid, p, p, mc, "1234", 0) return err }) var deleteResp deleteMDMConfigProfileResponse s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", uid), nil, http.StatusBadRequest, &deleteResp) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, "DELETE FROM mdm_apple_configuration_profiles WHERE profile_uuid = ?", uid) return err }) } // TODO: Add tests for create/delete forbidden declaration types? // make fleet add a FileVault profile acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.True(t, acResp.MDM.EnableDiskEncryption.Value) profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) // try to delete the profile s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profile.ProfileUUID), nil, http.StatusBadRequest, &deleteResp) // make fleet add a Windows OS Updates profile acResp = appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} } }`), http.StatusOK, &acResp) profUUID := checkWindowsOSUpdatesProfile(t, s.ds, nil, &fleet.WindowsUpdates{DeadlineDays: optjson.SetInt(1), GracePeriodDays: optjson.SetInt(1)}) // try to delete the profile s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profUUID), nil, http.StatusBadRequest, &deleteResp) // TODO: Add tests for OS updates declaration when implemented. } func (s *integrationMDMTestSuite) TestListMDMConfigProfiles() { t := s.T() ctx := context.Background() // create some teams tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team1"}) require.NoError(t, err) tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team2"}) require.NoError(t, err) tm3, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team3"}) require.NoError(t, err) // create 5 profiles for no team and team 1, names are A, B, C ... for global and // tA, tB, tC ... for team 1. Alternate macOS and Windows profiles. for i := 0; i < 5; i++ { name := string('A' + byte(i)) if i%2 == 0 { prof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest(name, name+".identifier", name+".uuid"), nil) require.NoError(t, err) _, err = s.ds.NewMDMAppleConfigProfile(ctx, *prof, nil) require.NoError(t, err) tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("t"+name, "t"+name+".identifier", "t"+name+".uuid"), nil) require.NoError(t, err) tprof.TeamID = &tm1.ID _, err = s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil) require.NoError(t, err) } else { _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: name, SyncML: []byte(``)}, nil) require.NoError(t, err) _, err = s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{Name: "t" + name, TeamID: &tm1.ID, SyncML: []byte(``)}, nil) require.NoError(t, err) } } lblFoo, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "foo", Query: "select 1"}) require.NoError(t, err) lblBar, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "bar", Query: "select 1"}) require.NoError(t, err) lblBaz, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "baz", Query: "select 1"}) require.NoError(t, err) // create a couple profiles (Win and mac) for team 2, and none for team 3 tprof, err := fleet.NewMDMAppleConfigProfile(mcBytesForTest("tF", "tF.identifier", "tF.uuid"), nil) require.NoError(t, err) tprof.TeamID = &tm2.ID // make tm2ProfF a "exclude-any" label-based profile tprof.LabelsExcludeAny = []fleet.ConfigurationProfileLabel{ {LabelID: lblFoo.ID, LabelName: lblFoo.Name}, {LabelID: lblBar.ID, LabelName: lblBar.Name}, } tm2ProfF, err := s.ds.NewMDMAppleConfigProfile(ctx, *tprof, nil) require.NoError(t, err) // checksum is not returned by New..., so compute it manually checkSum := md5.Sum(tm2ProfF.Mobileconfig) // nolint:gosec // used only for test tm2ProfF.Checksum = checkSum[:] // make tm2ProfG a "include-all" label-based profile tm2ProfG, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{ Name: "tG", TeamID: &tm2.ID, SyncML: []byte(``), LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: lblFoo.ID, LabelName: lblFoo.Name}, {LabelID: lblBar.ID, LabelName: lblBar.Name}, }, }, nil) require.NoError(t, err) // make tm2ProfH a "include-any" label-based profile tm2ProfH, err := s.ds.NewMDMWindowsConfigProfile(ctx, fleet.MDMWindowsConfigProfile{ Name: "tH", TeamID: &tm2.ID, SyncML: []byte(``), LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: lblBaz.ID, LabelName: lblBaz.Name}, }, }, nil) require.NoError(t, err) // break lblFoo by deleting it require.NoError(t, s.ds.DeleteLabel(ctx, lblFoo.Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}})) // test that all fields are correctly returned with team 2 var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, "team_id", fmt.Sprint(tm2.ID)) require.Len(t, listResp.Profiles, 3) require.NotZero(t, listResp.Profiles[0].CreatedAt) require.NotZero(t, listResp.Profiles[0].UploadedAt) require.NotZero(t, listResp.Profiles[1].CreatedAt) require.NotZero(t, listResp.Profiles[1].UploadedAt) listResp.Profiles[0].CreatedAt, listResp.Profiles[0].UploadedAt = time.Time{}, time.Time{} listResp.Profiles[1].CreatedAt, listResp.Profiles[1].UploadedAt = time.Time{}, time.Time{} listResp.Profiles[2].CreatedAt, listResp.Profiles[2].UploadedAt = time.Time{}, time.Time{} require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfF.ProfileUUID, TeamID: tm2ProfF.TeamID, Name: tm2ProfF.Name, Platform: "darwin", Identifier: tm2ProfF.Identifier, Checksum: tm2ProfF.Checksum, Scope: string(fleet.PayloadScopeSystem), // labels are ordered by name LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: 0, LabelName: lblFoo.Name, Broken: true}, }, }, listResp.Profiles[0]) require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfG.ProfileUUID, TeamID: tm2ProfG.TeamID, Name: tm2ProfG.Name, Platform: "windows", // labels are ordered by name LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: 0, LabelName: lblFoo.Name, Broken: true}, }, }, listResp.Profiles[1]) require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfH.ProfileUUID, TeamID: tm2ProfH.TeamID, Name: tm2ProfH.Name, Platform: "windows", // labels are ordered by name LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: lblBaz.ID, LabelName: lblBaz.Name}, }, }, listResp.Profiles[2]) // get the specific include-all label-based profile returns the information var getProfResp getMDMConfigProfileResponse s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfG.ProfileUUID, nil, http.StatusOK, &getProfResp) getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{} require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfG.ProfileUUID, TeamID: tm2ProfG.TeamID, Name: tm2ProfG.Name, Platform: "windows", // labels are ordered by name LabelsIncludeAll: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: 0, LabelName: lblFoo.Name, Broken: true}, }, }, getProfResp.MDMConfigProfilePayload) // get the specific exclude-any label-based profile returns the information getProfResp = getMDMConfigProfileResponse{} s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfF.ProfileUUID, nil, http.StatusOK, &getProfResp) getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{} require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfF.ProfileUUID, TeamID: tm2ProfF.TeamID, Name: tm2ProfF.Name, Platform: "darwin", Identifier: tm2ProfF.Identifier, Checksum: tm2ProfF.Checksum, Scope: string(fleet.PayloadScopeSystem), // labels are ordered by name LabelsExcludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: 0, LabelName: lblFoo.Name, Broken: true}, }, }, getProfResp.MDMConfigProfilePayload) // get the specific include-any label-based profile returns the information getProfResp = getMDMConfigProfileResponse{} s.DoJSON("GET", "/api/latest/fleet/mdm/profiles/"+tm2ProfH.ProfileUUID, nil, http.StatusOK, &getProfResp) getProfResp.CreatedAt, getProfResp.UploadedAt = time.Time{}, time.Time{} require.Equal(t, &fleet.MDMConfigProfilePayload{ ProfileUUID: tm2ProfH.ProfileUUID, TeamID: tm2ProfH.TeamID, Name: tm2ProfH.Name, Platform: "windows", // labels are ordered by name LabelsIncludeAny: []fleet.ConfigurationProfileLabel{ {LabelID: lblBar.ID, LabelName: lblBar.Name}, {LabelID: lblBaz.ID, LabelName: lblBaz.Name}, }, }, getProfResp.MDMConfigProfilePayload) // list for a non-existing team returns 404 s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusNotFound, &listResp, "team_id", "99999") cases := []struct { queries []string // alternate query name and value teamID *uint wantNames []string wantMeta *fleet.PaginationMetadata }{ { wantNames: []string{"A", "B", "C", "D", "E"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, }, { queries: []string{"per_page", "2"}, wantNames: []string{"A", "B"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, }, { queries: []string{"per_page", "2", "page", "1"}, wantNames: []string{"C", "D"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: true}, }, { queries: []string{"per_page", "2", "page", "2"}, wantNames: []string{"E"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, }, { queries: []string{"per_page", "3"}, teamID: &tm1.ID, wantNames: []string{"tA", "tB", "tC"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: true, HasPreviousResults: false}, }, { queries: []string{"per_page", "3", "page", "1"}, teamID: &tm1.ID, wantNames: []string{"tD", "tE"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, }, { queries: []string{"per_page", "3", "page", "2"}, teamID: &tm1.ID, wantNames: nil, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: true}, }, { queries: []string{"per_page", "3"}, teamID: &tm2.ID, wantNames: []string{"tF", "tG", "tH"}, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, }, { queries: []string{"per_page", "2"}, teamID: &tm3.ID, wantNames: nil, wantMeta: &fleet.PaginationMetadata{HasNextResults: false, HasPreviousResults: false}, }, } for _, c := range cases { t.Run(fmt.Sprintf("%v: %#v", c.teamID, c.queries), func(t *testing.T) { var listResp listMDMConfigProfilesResponse queryArgs := c.queries if c.teamID != nil { queryArgs = append(queryArgs, "team_id", fmt.Sprint(*c.teamID)) } s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp, queryArgs...) require.Equal(t, len(c.wantNames), len(listResp.Profiles)) require.Equal(t, c.wantMeta, listResp.Meta) var gotNames []string if len(listResp.Profiles) > 0 { gotNames = make([]string, len(listResp.Profiles)) for i, p := range listResp.Profiles { gotNames[i] = p.Name if p.Name == "tG" { require.Len(t, p.LabelsIncludeAll, 2) } else { require.Nil(t, p.LabelsIncludeAll) } if p.Name == "tF" { require.Len(t, p.LabelsExcludeAny, 2) } else { require.Nil(t, p.LabelsExcludeAny) } if c.teamID == nil { // we set it to 0 for global require.NotNil(t, p.TeamID) require.Zero(t, *p.TeamID) } else { require.NotNil(t, p.TeamID) require.Equal(t, *c.teamID, *p.TeamID) } require.NotEmpty(t, p.Platform) } } require.Equal(t, c.wantNames, gotNames) }) } } func (s *integrationMDMTestSuite) TestWindowsProfileManagement() { t := s.T() ctx := context.Background() err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) require.NoError(t, err) globalProfiles := []string{ mysql.InsertWindowsProfileForTest(t, s.ds, 0), mysql.InsertWindowsProfileForTest(t, s.ds, 0), mysql.InsertWindowsProfileForTest(t, s.ds, 0), } // create a new team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) require.NoError(t, err) teamProfiles := []string{ mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID), mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID), } // create a non-Windows host _, err = s.ds.NewHost(context.Background(), &fleet.Host{ ID: 1, OsqueryHostID: ptr.String("non-windows-host"), NodeKey: ptr.String("non-windows-host"), UUID: uuid.New().String(), Hostname: fmt.Sprintf("%sfoo.local.non.windows", t.Name()), Platform: "darwin", }) require.NoError(t, err) // create a Windows host that's not enrolled into MDM _, err = s.ds.NewHost(context.Background(), &fleet.Host{ ID: 2, OsqueryHostID: ptr.String("not-mdm-enrolled"), NodeKey: ptr.String("not-mdm-enrolled"), UUID: uuid.New().String(), Hostname: fmt.Sprintf("%sfoo.local.not.enrolled", t.Name()), Platform: "windows", }) require.NoError(t, err) verifyHostProfileStatus := func(cmds []fleet.ProtoCmdOperation, wantStatus string) { for _, cmd := range cmds { var gotProfile struct { Status string `db:"status"` Retries int `db:"retries"` } mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := ` SELECT COALESCE(status, 'pending') as status, retries FROM host_mdm_windows_profiles WHERE command_uuid = ?` return sqlx.GetContext(context.Background(), q, &gotProfile, stmt, cmd.Cmd.CmdID.Value) }) wantDeliveryStatus := fleet.WindowsResponseToDeliveryStatus(wantStatus) if gotProfile.Retries <= servermdm.MaxProfileRetries && wantDeliveryStatus == fleet.MDMDeliveryFailed { require.EqualValues(t, "pending", gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value) } else { require.EqualValues(t, wantDeliveryStatus, gotProfile.Status, "command_uuid", cmd.Cmd.CmdID.Value) } } } verifyProfiles := func(device *mdmtest.TestWindowsMDMClient, n int, fail bool) { mdmResponseStatus := syncml.CmdStatusOK if fail { mdmResponseStatus = syncml.CmdStatusAtomicFailed } s.awaitTriggerProfileSchedule(t) cmds, err := device.StartManagementSession() require.NoError(t, err) // 2 Status + n profiles require.Len(t, cmds, n+2) var atomicCmds []fleet.ProtoCmdOperation msgID, err := device.GetCurrentMsgID() require.NoError(t, err) for _, c := range cmds { cmdID := c.Cmd.CmdID status := syncml.CmdStatusOK if c.Verb == "Atomic" { atomicCmds = append(atomicCmds, c) status = mdmResponseStatus require.NotEmpty(t, c.Cmd.ReplaceCommands) for _, rc := range c.Cmd.ReplaceCommands { require.NotEmpty(t, rc.CmdID) } } device.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmdID.Value, Cmd: ptr.String(c.Verb), Data: &status, Items: nil, CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } // TODO: verify profile contents as well require.Len(t, atomicCmds, n) // before we send the response, commands should be "pending" verifyHostProfileStatus(atomicCmds, "") cmds, err = device.SendResponse() require.NoError(t, err) // the ack of the message should be the only returned command require.Len(t, cmds, 1) // verify that we updated status in the db verifyHostProfileStatus(atomicCmds, mdmResponseStatus) } checkHostsProfilesMatch := func(host *fleet.Host, wantUUIDs []string) { var gotUUIDs []string mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `SELECT profile_uuid FROM host_mdm_windows_profiles WHERE host_uuid = ?` return sqlx.SelectContext(context.Background(), q, &gotUUIDs, stmt, host.UUID) }) require.ElementsMatch(t, wantUUIDs, gotUUIDs) } checkHostDetails := func(t *testing.T, host *fleet.Host, wantProfs []string, wantStatus fleet.MDMDeliveryStatus) { var gotHostResp getHostResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", host.ID), nil, http.StatusOK, &gotHostResp) require.NotNil(t, gotHostResp.Host.MDM.Profiles) var gotProfs []string require.Len(t, *gotHostResp.Host.MDM.Profiles, len(wantProfs)) for _, p := range *gotHostResp.Host.MDM.Profiles { gotProfs = append(gotProfs, strings.Replace(p.Name, "name-", "", 1)) require.NotNil(t, p.Status) require.EqualValues(t, wantStatus, *p.Status, "profile", p.Name) require.Equal(t, "windows", p.Platform) // Fleet reserved profiles (e.g., OS updates) should be screened from the host details response require.NotContains(t, servermdm.ListFleetReservedWindowsProfileNames(), p.Name) } require.ElementsMatch(t, wantProfs, gotProfs) } checkHostsFilteredByOSSettingsStatus := func(t *testing.T, wantHosts []string, wantStatus fleet.MDMDeliveryStatus, teamID *uint, labels ...*fleet.Label) { var teamFilter string if teamID != nil { teamFilter = fmt.Sprintf("&team_id=%d", *teamID) } var gotHostsResp listHostsResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp) require.NotNil(t, gotHostsResp.Hosts) var gotHosts []string for _, h := range gotHostsResp.Hosts { gotHosts = append(gotHosts, h.Hostname) } require.ElementsMatch(t, wantHosts, gotHosts) var countHostsResp countHostsResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?os_settings=%s%s", wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp) require.Equal(t, len(wantHosts), countHostsResp.Count) for _, l := range labels { gotHostsResp = listHostsResponse{} s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/labels/%d/hosts?os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &gotHostsResp) require.NotNil(t, gotHostsResp.Hosts) gotHosts = []string{} for _, h := range gotHostsResp.Hosts { gotHosts = append(gotHosts, h.Hostname) } require.ElementsMatch(t, wantHosts, gotHosts, "label", l.Name) countHostsResp = countHostsResponse{} s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/count?label_id=%d&os_settings=%s%s", l.ID, wantStatus, teamFilter), nil, http.StatusOK, &countHostsResp) } } getProfileUUID := func(t *testing.T, profName string, teamID *uint) string { var profUUID string mysql.ExecAdhocSQL(t, s.ds, func(tx sqlx.ExtContext) error { var globalOrTeamID uint if teamID != nil { globalOrTeamID = *teamID } return sqlx.GetContext(ctx, tx, &profUUID, `SELECT profile_uuid FROM mdm_windows_configuration_profiles WHERE team_id = ? AND name = ?`, globalOrTeamID, profName) }) require.NotNil(t, profUUID) return profUUID } checkHostProfileStatus := func(t *testing.T, hostUUID string, profUUID string, wantStatus fleet.MDMDeliveryStatus) { var gotStatus fleet.MDMDeliveryStatus mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_uuid = ?` err := sqlx.GetContext(context.Background(), q, &gotStatus, stmt, hostUUID, profUUID) return err }) require.Equal(t, wantStatus, gotStatus) } // Create a host and then enroll to MDM. host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) // trigger a profile sync verifyProfiles(mdmDevice, 3, false) checkHostsProfilesMatch(host, globalProfiles) checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying) // can't resend windows configuration profiles as admin or from device endpoint while verifying res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, globalProfiles[0]), nil, http.StatusConflict) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent") deviceToken := "windows-device-token" createDeviceTokenForHost(t, s.ds, host.ID, deviceToken) res = s.DoRawNoAuth("POST", fmt.Sprintf("/api/latest/fleet/device/%s/configuration_profiles/%s/resend", deviceToken, globalProfiles[0]), nil, http.StatusConflict) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent") // create new label that includes host label := &fleet.Label{ Name: t.Name() + "foo", Query: "select * from foo;", } label, err = s.ds.NewLabel(context.Background(), label) require.NoError(t, err) require.NoError(t, s.ds.RecordLabelQueryExecutions(ctx, host, map[uint]*bool{label.ID: ptr.Bool(true)}, time.Now(), false)) // simulate osquery reporting host mdm details (host_mdm.enrolled = 1 is condition for // hosts filtering by os settings status and generating mdm profiles summaries) require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "", false)) checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label) s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{ Verifying: 1, }, nil) // another sync shouldn't return profiles verifyProfiles(mdmDevice, 0, false) // make fleet add a Windows OS Updates profile acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &acResp) osUpdatesProf := getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, nil) // os updates is sent via a profiles commands verifyProfiles(mdmDevice, 1, false) checkHostsProfilesMatch(host, append(globalProfiles, osUpdatesProf)) // but is hidden from host details response checkHostDetails(t, host, globalProfiles, fleet.MDMDeliveryVerifying) // os updates profile status doesn't matter for filtered hosts results or summaries checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying) checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label) s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{ Verifying: 1, }, nil) // force os updates profile to failed, doesn't impact filtered hosts results or summaries mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, osUpdatesProf) return err }) checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed) checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, nil, label) s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{ Verifying: 1, }, nil) // force another profile to failed, does impact filtered hosts results and summaries mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, globalProfiles[0]) return err }) checkHostProfileStatus(t, host.UUID, globalProfiles[0], fleet.MDMDeliveryFailed) checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerifying, nil, label) // expect no hosts checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, nil, label) // expect host s.checkMDMProfilesSummaries(t, nil, fleet.MDMProfilesSummary{ Failed: 1, Verifying: 0, }, nil) // add the host to a team err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{host.ID})) require.NoError(t, err) // trigger a profile sync, device gets the team profile verifyProfiles(mdmDevice, 2, false) checkHostsProfilesMatch(host, teamProfiles) checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying) // set new team profiles (delete + addition) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, teamProfiles[1]) return err }) teamProfiles = []string{ teamProfiles[0], mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID), } // trigger a profile sync, device gets the team profile verifyProfiles(mdmDevice, 1, false) // check that we deleted the old profile in the DB checkHostsProfilesMatch(host, teamProfiles) checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying) // another sync shouldn't return profiles verifyProfiles(mdmDevice, 0, false) // set new team profiles (delete + addition) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `DELETE FROM mdm_windows_configuration_profiles WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, teamProfiles[1]) return err }) teamProfiles = []string{ teamProfiles[0], mysql.InsertWindowsProfileForTest(t, s.ds, tm.ID), } // trigger a profile sync, this time fail the delivery verifyProfiles(mdmDevice, 1, true) // check that we deleted the old profile in the DB checkHostsProfilesMatch(host, teamProfiles) // a second sync gets the profile again, because of delivery retries. // Succeed that one verifyProfiles(mdmDevice, 1, false) // another sync shouldn't return profiles verifyProfiles(mdmDevice, 0, false) // make fleet add a Windows OS Updates profile tmResp := teamResponse{} s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tm.ID), json.RawMessage(`{"mdm": { "windows_updates": {"deadline_days": 1, "grace_period_days": 1} }}`), http.StatusOK, &tmResp) osUpdatesProf = getProfileUUID(t, servermdm.FleetWindowsOSUpdatesProfileName, &tm.ID) // os updates is sent via a profiles commands verifyProfiles(mdmDevice, 1, false) checkHostsProfilesMatch(host, append(teamProfiles, osUpdatesProf)) // but is hidden from host details response checkHostDetails(t, host, teamProfiles, fleet.MDMDeliveryVerifying) // os updates profile status doesn't matter for filtered hosts results or summaries checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryVerifying) checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{ Verifying: 1, }, nil) // force os updates profile to failed, doesn't impact filtered hosts results or summaries mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, osUpdatesProf) return err }) checkHostProfileStatus(t, host.UUID, osUpdatesProf, fleet.MDMDeliveryFailed) checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryVerifying, &tm.ID, label) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{ Verifying: 1, }, nil) // force another profile to failed, does impact filtered hosts results and summaries mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_windows_profiles SET status = 'failed' WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, teamProfiles[0]) return err }) checkHostProfileStatus(t, host.UUID, teamProfiles[0], fleet.MDMDeliveryFailed) checkHostsFilteredByOSSettingsStatus(t, []string{}, fleet.MDMDeliveryVerifying, &tm.ID, label) // expect no hosts checkHostsFilteredByOSSettingsStatus(t, []string{host.Hostname}, fleet.MDMDeliveryFailed, &tm.ID, label) // expect host s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{ Failed: 1, Verifying: 0, }, nil) // Resend the failed profile. Should succeed s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusAccepted) s.checkMDMProfilesSummaries(t, &tm.ID, fleet.MDMProfilesSummary{ Pending: 1, }, nil) // Try resending, should fail since it's pending res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusConflict) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent") // Trigger a profile sync, device gets the resent profile verifyProfiles(mdmDevice, 1, false) // update to verifying - should not allow resending mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_windows_profiles SET status = 'verifying' WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, teamProfiles[0]) return err }) res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusConflict) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Configuration profiles with “pending” or “verifying” status can’t be resent") // trigger a profile sync, device doesn't get the profile since resend was not allowed verifyProfiles(mdmDevice, 0, false) // Update to verified, resending allowed again mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE host_mdm_windows_profiles SET status = 'verified' WHERE profile_uuid = ?` _, err := q.ExecContext(context.Background(), stmt, teamProfiles[0]) return err }) s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, teamProfiles[0]), nil, http.StatusAccepted) // Trigger a profile sync, device gets the resent profile verifyProfiles(mdmDevice, 1, false) // add a macOS profile to the team mcUUID := "a" + uuid.NewString() prof := mcBytesForTest("name-"+mcUUID, "identifier-"+mcUUID, mcUUID) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `INSERT INTO mdm_apple_configuration_profiles (profile_uuid, team_id, name, identifier, mobileconfig, checksum, uploaded_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);` _, err := q.ExecContext(context.Background(), stmt, mcUUID, tm.ID, "name-"+mcUUID, "identifier-"+mcUUID, prof, test.MakeTestBytes()) return err }) // trigger a profile sync, device doesn't get the macOS profile verifyProfiles(mdmDevice, 0, false) // can't resend a macOS profile to a Windows host res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/configuration_profiles/%s/resend", host.ID, mcUUID), nil, http.StatusUnprocessableEntity) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Profile is not compatible with host platform") } func (s *integrationMDMTestSuite) TestApplyTeamsMDMWindowsProfiles() { t := s.T() // create a team through the service so it initializes the agent ops teamName := t.Name() + "team1" team := &fleet.Team{ Name: teamName, Description: "desc team1", } var createTeamResp teamResponse s.DoJSON("POST", "/api/latest/fleet/teams", team, http.StatusOK, &createTeamResp) require.NotZero(t, createTeamResp.Team.ID) team = createTeamResp.Team rawTeamSpec := func(mdmValue string) json.RawMessage { return json.RawMessage(fmt.Sprintf(`{ "specs": [{ "name": %q, "mdm": %s }] }`, team.Name, mdmValue)) } // set the windows custom settings fields var applyResp applyTeamSpecsResponse s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` { "windows_settings": { "custom_settings": [ {"path": "foo", "labels": ["baz"]}, {"path": "bar", "labels_exclude_any": ["x", "y"]} ] } } `), http.StatusOK, &applyResp) require.Len(t, applyResp.TeamIDsByName, 1) // check that they are returned by a GET /config var teamResp getTeamResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.ElementsMatch(t, []fleet.MDMProfileSpec{ {Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar", LabelsExcludeAny: []string{"x", "y"}}, }, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) // patch without specifying the windows custom settings fields and an unrelated // field, should not remove them applyResp = applyTeamSpecsResponse{} s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(`{ "enable_disk_encryption": true }`), http.StatusOK, &applyResp) require.Len(t, applyResp.TeamIDsByName, 1) // check that they are returned by a GET /config teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.ElementsMatch(t, []fleet.MDMProfileSpec{ {Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar", LabelsExcludeAny: []string{"x", "y"}}, }, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) // patch with explicitly empty windows custom settings fields, would remove // them but this is a dry-run applyResp = applyTeamSpecsResponse{} s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` { "windows_settings": { "custom_settings": null } } `), http.StatusOK, &applyResp, "dry_run", "true") assert.Equal(t, map[string]uint{team.Name: team.ID}, applyResp.TeamIDsByName) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.ElementsMatch(t, []fleet.MDMProfileSpec{ {Path: "foo", LabelsIncludeAll: []string{"baz"}}, {Path: "bar", LabelsExcludeAny: []string{"x", "y"}}, }, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) // patch with explicitly empty windows custom settings fields, removes them applyResp = applyTeamSpecsResponse{} s.DoJSON("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` { "windows_settings": { "custom_settings": null } } `), http.StatusOK, &applyResp) require.Len(t, applyResp.TeamIDsByName, 1) teamResp = getTeamResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/teams/%d", team.ID), nil, http.StatusOK, &teamResp) require.Empty(t, teamResp.Team.Config.MDM.WindowsSettings.CustomSettings.Value) // apply with invalid mix of labels fails res := s.Do("POST", "/api/latest/fleet/spec/teams", rawTeamSpec(` { "windows_settings": { "custom_settings": [ {"path": "foo", "labels": ["a"], "labels_include_all": ["b"]} ] } } `), http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) } func (s *integrationMDMTestSuite) TestBatchSetMDMProfiles() { t := s.T() ctx := context.Background() // create a new team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) require.NoError(t, err) bigString := strings.Repeat("a", 1024*1024+1) // Profile is too big resp := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{{Contents: []byte(bigString)}}}, http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB") // invalid profile (bad mobileconfig) resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ { Name: "Bad mobileconfig", Contents: []byte(` PayloadContent `), }, }}, http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: new MDMAppleConfigProfile: plist: error parsing XML property list: XML syntax error") // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNoContent) // Nothing changed, so no activity items s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) // apply to both team id and name s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name) // invalid team name s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: nil}, http.StatusNotFound, "team_name", uuid.New().String()) // duplicate PayloadDisplayName s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: "N2", Contents: mobileconfigForTest("N1", "I2")}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) // profiles with reserved macOS identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: p, Contents: mobileconfigForTest(p, p)}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p)) } // payloads with reserved types for p := range mobileconfig.FleetPayloadTypes() { if p == mobileconfig.FleetCustomSettingsPayloadType { // FileVault options in the custom settings payload are checked in file_vault_options_test.go continue } res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) switch p { case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType: assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg) default: assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p)) } } // payloads with reserved identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTestWithContent("N1", "I1", p, "random", "")}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p)) } // profiles with forbidden declaration types for dt := range fleet.ForbiddenDeclTypes { res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTestWithType("D1", dt)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt) } // and one more for the software update declaration res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.") // invalid JSON res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: []byte(`{"foo":}`)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "N4 is not a valid macOS, Windows, or Android configuration profile") // profiles with reserved Windows location URIs // bitlocker res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: syncml.FleetBitLockerTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg) // os updates res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: syncml.FleetOSUpdateTargetLocURI, Contents: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))}, {Name: "N3", Contents: syncMLForTest("./Foo/Bar")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.") // invalid windows tag res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N3", Contents: []byte(``)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(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", fmt.Sprint(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", fmt.Sprint(tm.ID), "dry_run", "true") s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false) s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false) // successfully apply for a team and verify activities s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1")}, {Name: "N2", Contents: syncMLForTest("./Foo/Bar")}, {Name: "N4", Contents: declarationForTest("D1")}, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true) s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) // batch-apply profiles with labels lbl1, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L1", Query: "select 1;"}) require.NoError(t, err) lbl2, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L2", Query: "select 1;"}) require.NoError(t, err) lbl3, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L3", Query: "select 1;"}) require.NoError(t, err) // attempt with an invalid label name res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name, "no-such-label"}}, }}, http.StatusBadRequest) msg := extractServerErrorText(res.Body) require.Contains(t, msg, "some or all the labels provided don't exist") // mix of labels fields res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name}, LabelsExcludeAny: []string{lbl2.Name}}, }}, http.StatusUnprocessableEntity) msg = extractServerErrorText(res.Body) require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) // successful batch-set s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1"), Labels: []string{lbl1.Name, lbl2.Name}}, {Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}}, {Name: "N4", Contents: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}}, }}, http.StatusNoContent) // confirm expected results var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) require.Len(t, listResp.Profiles, 3) require.Equal(t, "N1", listResp.Profiles[0].Name) require.Equal(t, "N2", listResp.Profiles[1].Name) require.Equal(t, "N4", listResp.Profiles[2].Name) require.Equal(t, listResp.Profiles[0].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{ {LabelID: lbl1.ID, LabelName: lbl1.Name}, {LabelID: lbl2.ID, LabelName: lbl2.Name}, }) require.Nil(t, listResp.Profiles[0].LabelsExcludeAny) require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{ {LabelID: lbl1.ID, LabelName: lbl1.Name}, }) require.Nil(t, listResp.Profiles[1].LabelsExcludeAny) require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{ {LabelID: lbl2.ID, LabelName: lbl2.Name}, }) require.Nil(t, listResp.Profiles[2].LabelsIncludeAll) // successful batch-set that updates some labels s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}}, {Name: "N2", Contents: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}}, }}, http.StatusNoContent) listResp = listMDMConfigProfilesResponse{} s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) require.Len(t, listResp.Profiles, 2) require.Equal(t, "N1", listResp.Profiles[0].Name) require.Equal(t, "N2", listResp.Profiles[1].Name) require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{ {LabelID: lbl1.ID, LabelName: lbl1.Name}, {LabelID: lbl3.ID, LabelName: lbl3.Name}, }) require.Nil(t, listResp.Profiles[0].LabelsIncludeAll) require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{ {LabelID: lbl2.ID, LabelName: lbl2.Name}, }) require.Nil(t, listResp.Profiles[1].LabelsExcludeAny) // names cannot be duplicated across platforms declBytes := json.RawMessage(`{ "Type": "com.apple.configuration.decl.foo", "Identifier": "com.fleet.config.foo", "Payload": { "ServiceType": "com.apple.bash", "DataAssetReference": "com.fleet.asset.bash" }}`) mcBytes := mobileconfigForTest("N1", "I1") winBytes := syncMLForTest("./Foo/Bar") for _, p := range []struct { payload []fleet.MDMProfileBatchPayload expectErr string }{ { payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: winBytes}}, expectErr: "More than one configuration profile have the same name 'N1'", }, { payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: declBytes}, {Name: "N1", Contents: winBytes}}, expectErr: "More than one configuration profile have the same name 'N1'", }, { payload: []fleet.MDMProfileBatchPayload{{Name: "N1", Contents: mcBytes}, {Name: "N1", Contents: declBytes}}, expectErr: "More than one configuration profile have the same name 'N1'", }, } { // team profiles res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, p.expectErr) // no team profiles res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: p.payload}, http.StatusUnprocessableEntity) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, p.expectErr) } } // This tests the new public API endpoint for batch modifying MDM profiles func (s *integrationMDMTestSuite) TestBatchModifyMDMProfiles() { t := s.T() ctx := context.Background() // create a new team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) require.NoError(t, err) bigString := strings.Repeat("a", 1024*1024+1) // Profile is too big resp := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{{Profile: []byte(bigString)}}}, http.StatusUnprocessableEntity) require.Contains(t, extractServerErrorText(resp.Body), "Validation Failed: maximum configuration profile file size is 1 MB") // apply an empty set to no-team s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil}, http.StatusNoContent) // Nothing changed, so no activity items s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) // apply to both team id and name s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name) // invalid team name s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: nil}, http.StatusNotFound, "team_name", uuid.New().String()) // duplicate PayloadDisplayName s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: "N2", Profile: mobileconfigForTest("N1", "I2")}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) // profiles with reserved macOS identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: p, Profile: mobileconfigForTest(p, p)}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p)) } // payloads with reserved types for p := range mobileconfig.FleetPayloadTypes() { if p == mobileconfig.FleetCustomSettingsPayloadType { // FileVault options in the custom settings payload are checked in file_vault_options_test.go continue } res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTestWithContent("N1", "I1", "II1", p, "")}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) switch p { case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType: assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg) default: assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p)) } } // payloads with reserved identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTestWithContent("N1", "I1", p, "random", "")}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTest("D1")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p)) } // profiles with forbidden declaration types for dt := range fleet.ForbiddenDeclTypes { res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTestWithType("D1", dt)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Only configuration declarations that don’t require an asset reference are supported", dt) } // and one more for the software update declaration res := s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTestWithType("D1", "com.apple.configuration.softwareupdate.enforcement.specific")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Declaration profile can’t include OS updates settings. To control these settings, go to OS updates.") // invalid JSON res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: []byte(`{"foo":}`)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "N4 is not a valid macOS, Windows, or Android configuration profile.") // profiles with reserved Windows location URIs // bitlocker res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: syncml.FleetBitLockerTargetLocURI, Profile: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI))}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg) // os updates res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: syncml.FleetOSUpdateTargetLocURI, Profile: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI))}, {DisplayName: "N3", Profile: syncMLForTest("./Foo/Bar")}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.") // invalid windows tag res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N3", Profile: []byte(``)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(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/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N3", Profile: []byte(`foo`)}, }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows configuration profiles can only have or top level elements.") // successfully apply windows and macOS a profiles for the team, but it's a dry run s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1")}, {DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTest("D1")}, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID), "dry_run", "true") s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false) s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false) // successfully apply for a team and verify activities s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "NotRelevant", Profile: mobileconfigForTest("N1", "I1")}, // Check that we don't care about displayname for mobileconfig profiles {DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar")}, {DisplayName: "N4", Profile: declarationForTest("D1")}, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true) s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedDeclarationProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) // batch-apply profiles with labels lbl1, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L1", Query: "select 1;"}) require.NoError(t, err) lbl2, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L2", Query: "select 1;"}) require.NoError(t, err) lbl3, err := s.ds.NewLabel(ctx, &fleet.Label{Name: "L3", Query: "select 1;"}) require.NoError(t, err) // attempt with an invalid label name res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name, "no-such-label"}}, }}, http.StatusBadRequest) msg := extractServerErrorText(res.Body) require.Contains(t, msg, "some or all the labels provided don't exist") // mix of labels fields res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAll: []string{lbl1.Name}, LabelsExcludeAny: []string{lbl2.Name}}, }}, http.StatusUnprocessableEntity) msg = extractServerErrorText(res.Body) require.Contains(t, msg, `For each profile, only one of "labels_exclude_any", "labels_include_all", "labels_include_any" or "labels" can be included.`) // successful batch-set s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsIncludeAny: []string{lbl1.Name, lbl2.Name}}, {DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl1.Name}}, {DisplayName: "N4", Profile: declarationForTest("D1"), LabelsExcludeAny: []string{lbl2.Name}}, }}, http.StatusNoContent) // confirm expected results var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) require.Len(t, listResp.Profiles, 3) require.Equal(t, "N1", listResp.Profiles[0].Name) require.Equal(t, "N2", listResp.Profiles[1].Name) require.Equal(t, "N4", listResp.Profiles[2].Name) require.Equal(t, listResp.Profiles[0].LabelsIncludeAny, []fleet.ConfigurationProfileLabel{ {LabelID: lbl1.ID, LabelName: lbl1.Name}, {LabelID: lbl2.ID, LabelName: lbl2.Name}, }) require.Nil(t, listResp.Profiles[0].LabelsExcludeAny) require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{ {LabelID: lbl1.ID, LabelName: lbl1.Name}, }) require.Nil(t, listResp.Profiles[1].LabelsExcludeAny) require.Equal(t, listResp.Profiles[2].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{ {LabelID: lbl2.ID, LabelName: lbl2.Name}, }) require.Nil(t, listResp.Profiles[2].LabelsIncludeAll) // successful batch-set that updates some labels s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "N1", Profile: mobileconfigForTest("N1", "I1"), LabelsExcludeAny: []string{lbl1.Name, lbl3.Name}}, {DisplayName: "N2", Profile: syncMLForTest("./Foo/Bar"), LabelsIncludeAll: []string{lbl2.Name}}, }}, http.StatusNoContent) listResp = listMDMConfigProfilesResponse{} s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) require.Len(t, listResp.Profiles, 2) require.Equal(t, "N1", listResp.Profiles[0].Name) require.Equal(t, "N2", listResp.Profiles[1].Name) require.Equal(t, listResp.Profiles[0].LabelsExcludeAny, []fleet.ConfigurationProfileLabel{ {LabelID: lbl1.ID, LabelName: lbl1.Name}, {LabelID: lbl3.ID, LabelName: lbl3.Name}, }) require.Nil(t, listResp.Profiles[0].LabelsIncludeAll) require.Equal(t, listResp.Profiles[1].LabelsIncludeAll, []fleet.ConfigurationProfileLabel{ {LabelID: lbl2.ID, LabelName: lbl2.Name}, }) require.Nil(t, listResp.Profiles[1].LabelsExcludeAny) // names cannot be duplicated across platforms declBytes := json.RawMessage(`{ "Type": "com.apple.configuration.decl.foo", "Identifier": "com.fleet.config.foo", "Payload": { "ServiceType": "com.apple.bash", "DataAssetReference": "com.fleet.asset.bash" }}`) mcBytes := mobileconfigForTest("N1", "I1") winBytes := syncMLForTest("./Foo/Bar") for _, p := range []struct { payload []fleet.BatchModifyMDMConfigProfilePayload expectErr string }{ { payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: winBytes}}, expectErr: "More than one configuration profile have the same name 'N1'", }, { payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: declBytes}, {DisplayName: "N1", Profile: winBytes}}, expectErr: "More than one configuration profile have the same name 'N1'", }, { payload: []fleet.BatchModifyMDMConfigProfilePayload{{DisplayName: "N1", Profile: mcBytes}, {DisplayName: "N1", Profile: declBytes}}, expectErr: "More than one configuration profile have the same name 'N1'", }, } { // team profiles res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, p.expectErr) // no team profiles res = s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: p.payload}, http.StatusUnprocessableEntity) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, p.expectErr) } // Get the current list of configuration profiles var currentProfiles listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, ¤tProfiles) require.Greater(t, len(currentProfiles.Profiles), 0) // Now we disable all three MDM's appCfg, err := s.ds.AppConfig(ctx) require.NoError(t, err) appCfg.MDM.EnabledAndConfigured = false appCfg.MDM.WindowsEnabledAndConfigured = false appCfg.MDM.AndroidEnabledAndConfigured = false err = s.ds.SaveAppConfig(ctx, appCfg) require.NoError(t, err) // Now do a batch with profiles in it, to see it fails trying to add. s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{ {DisplayName: "NEW", Profile: mobileconfigForTest("NEW", "INEW")}, }}, http.StatusUnprocessableEntity) // Now do a batch without any profiles to ensure we can delete with all MDM's disabled. s.Do("POST", "/api/latest/fleet/configuration_profiles/batch", batchModifyMDMConfigProfilesRequest{ConfigurationProfiles: []fleet.BatchModifyMDMConfigProfilePayload{}}, http.StatusNoContent) } func (s *integrationMDMTestSuite) TestBatchSetMDMProfilesBackwardsCompat() { t := s.T() ctx := context.Background() // create a new team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "batch_set_mdm_profiles"}) require.NoError(t, err) // apply an empty set to no-team s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNoContent) // Nothing changed, so no activity s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) s.lastActivityOfTypeDoesNotMatch( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), `{"team_id": null, "team_name": null}`, 0, ) // apply to both team id and name s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID), "team_name", tm.Name) // invalid team name s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": nil}, http.StatusNotFound, "team_name", uuid.New().String()) // duplicate PayloadDisplayName s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTest("N1", "I1"), "N2": mobileconfigForTest("N1", "I2"), "N3": syncMLForTest("./Foo/Bar"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) // profiles with reserved macOS identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTest("N1", "I1"), p: mobileconfigForTest(p, p), "N3": syncMLForTest("./Foo/Bar"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: payload identifier %s is not allowed", p)) } // payloads with reserved types for p := range mobileconfig.FleetPayloadTypes() { if p == mobileconfig.FleetCustomSettingsPayloadType { // FileVault options in the custom settings payload are checked in file_vault_options_test.go continue } res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTestWithContent("N1", "I1", "II1", p, ""), "N3": syncMLForTest("./Foo/Bar"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) switch p { case mobileconfig.FleetFileVaultPayloadType, mobileconfig.FleetRecoveryKeyEscrowPayloadType: assert.Contains(t, errMsg, mobileconfig.DiskEncryptionProfileRestrictionErrMsg) default: assert.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadType(s): %s", p)) } } // payloads with reserved identifiers for p := range mobileconfig.FleetPayloadIdentifiers() { res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTestWithContent("N1", "I1", p, "random", ""), "N3": syncMLForTest("./Foo/Bar"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, fmt.Sprintf("Validation Failed: unsupported PayloadIdentifier(s): %s", p)) } // profiles with reserved Windows location URIs // bitlocker res := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTest("N1", "I1"), syncml.FleetBitLockerTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetBitLockerTargetLocURI)), "N3": syncMLForTest("./Foo/Bar"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg := extractServerErrorText(res.Body) assert.Contains(t, errMsg, syncml.DiskEncryptionProfileRestrictionErrMsg) // os updates res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTest("N1", "I1"), syncml.FleetOSUpdateTargetLocURI: syncMLForTest(fmt.Sprintf("%s/Foo", syncml.FleetOSUpdateTargetLocURI)), "N3": syncMLForTest("./Foo/Bar"), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(tm.ID)) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Custom configuration profiles can't include Windows updates settings. To control these settings, use the mdm.windows_updates option.") // invalid windows tag res = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N3": []byte(``), }}, http.StatusUnprocessableEntity, "team_id", fmt.Sprint(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", fmt.Sprint(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", fmt.Sprint(tm.ID), "dry_run", "true") s.assertConfigProfilesByIdentifier(&tm.ID, "I1", false) s.assertWindowsConfigProfilesByName(&tm.ID, "N1", false) // successfully apply for a team and verify activities s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", map[string]any{"profiles": map[string][]byte{ "N1": mobileconfigForTest("N1", "I1"), "N2": syncMLForTest("./Foo/Bar"), }}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) s.assertConfigProfilesByIdentifier(&tm.ID, "I1", true) s.assertWindowsConfigProfilesByName(&tm.ID, "N2", true) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedMacosProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) s.lastActivityOfTypeMatches( fleet.ActivityTypeEditedWindowsProfile{}.ActivityName(), fmt.Sprintf(`{"team_id": %d, "team_name": %q}`, tm.ID, tm.Name), 0, ) } func (s *integrationMDMTestSuite) TestMDMBatchSetProfilesKeepsReservedNames() { t := s.T() ctx := context.Background() checkMacProfs := func(teamID *uint, names ...string) { var count int mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var tid uint if teamID != nil { tid = *teamID } return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_apple_configuration_profiles WHERE team_id = ?`, tid) }) require.Equal(t, len(names), count) for _, n := range names { s.assertMacOSConfigProfilesByName(teamID, n, true) } } checkWinProfs := func(teamID *uint, names ...string) { var count int mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var tid uint if teamID != nil { tid = *teamID } return sqlx.GetContext(ctx, q, &count, `SELECT COUNT(*) FROM mdm_windows_configuration_profiles WHERE team_id = ?`, tid) }) for _, n := range names { s.assertWindowsConfigProfilesByName(teamID, n, true) } } acResp := appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) require.True(t, acResp.MDM.EnabledAndConfigured) require.True(t, acResp.MDM.WindowsEnabledAndConfigured) // ensures that the fleetd profile is created secrets, err := s.ds.GetEnrollSecrets(ctx, nil) require.NoError(t, err) if len(secrets) == 0 { require.NoError(t, s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}})) } require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger)) // turn on disk encryption and os updates s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "enable_disk_encryption": true, "windows_updates": { "deadline_days": 3, "grace_period_days": 1 }, "macos_updates": { "deadline": "2023-12-31", "minimum_version": "13.3.7" } } }`), http.StatusOK, &acResp) checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...) checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...) // batch set only windows profiles doesn't remove the reserved names newWinProfile := syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "l1", Data: "d1"}}) var testProfiles []fleet.MDMProfileBatchPayload testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{ Name: "n1", Contents: newWinProfile, }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) checkMacProfs(nil, servermdm.ListFleetReservedMacOSProfileNames()...) checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...) // batch set windows and mac profiles doesn't remove the reserved names newMacProfile := mcBytesForTest("n2", "i2", uuid.NewString()) testProfiles = append(testProfiles, fleet.MDMProfileBatchPayload{ Name: "n2", Contents: newMacProfile, }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...) checkWinProfs(nil, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...) // batch set only mac profiles doesn't remove the reserved names testProfiles = []fleet.MDMProfileBatchPayload{{ Name: "n2", Contents: newMacProfile, }} s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testProfiles}, http.StatusNoContent) checkMacProfs(nil, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...) checkWinProfs(nil, servermdm.ListFleetReservedWindowsProfileNames()...) // create a team var tmResp teamResponse s.DoJSON("POST", "/api/v1/fleet/teams", map[string]string{"Name": t.Name()}, http.StatusOK, &tmResp) // edit team mdm config to turn on disk encryption and os updates s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/teams/%d", tmResp.Team.ID), modifyTeamRequest{ TeamPayload: fleet.TeamPayload{ Name: ptr.String(t.Name()), MDM: &fleet.TeamPayloadMDM{ EnableDiskEncryption: optjson.SetBool(true), WindowsUpdates: &fleet.WindowsUpdates{ DeadlineDays: optjson.SetInt(4), GracePeriodDays: optjson.SetInt(1), }, MacOSUpdates: &fleet.AppleOSUpdateSettings{ Deadline: optjson.SetString("2023-12-31"), MinimumVersion: optjson.SetString("13.3.8"), UpdateNewHosts: optjson.SetBool(true), }, }, }, }, http.StatusOK, &teamResponse{}) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/teams/%d", tmResp.Team.ID), nil, http.StatusOK, &tmResp) require.True(t, tmResp.Team.Config.MDM.EnableDiskEncryption) require.Equal(t, 4, tmResp.Team.Config.MDM.WindowsUpdates.DeadlineDays.Value) require.Equal(t, 1, tmResp.Team.Config.MDM.WindowsUpdates.GracePeriodDays.Value) require.Equal(t, "2023-12-31", tmResp.Team.Config.MDM.MacOSUpdates.Deadline.Value) require.Equal(t, "13.3.8", tmResp.Team.Config.MDM.MacOSUpdates.MinimumVersion.Value) require.Equal(t, true, tmResp.Team.Config.MDM.MacOSUpdates.UpdateNewHosts.Value) require.NoError(t, ReconcileAppleProfiles(ctx, s.ds, s.mdmCommander, s.logger)) checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...) checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...) // batch set only windows profiles doesn't remove the reserved names var testTeamProfiles []fleet.MDMProfileBatchPayload testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{ Name: "n1", Contents: newWinProfile, }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tmResp.Team.ID)) checkMacProfs(&tmResp.Team.ID, servermdm.ListFleetReservedMacOSProfileNames()...) checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...) // batch set windows and mac profiles doesn't remove the reserved names testTeamProfiles = append(testTeamProfiles, fleet.MDMProfileBatchPayload{ Name: "n2", Contents: newMacProfile, }) s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tmResp.Team.ID)) checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...) checkWinProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedWindowsProfileNames(), "n1")...) // batch set only mac profiles doesn't remove the reserved names testTeamProfiles = []fleet.MDMProfileBatchPayload{{ Name: "n2", Contents: newMacProfile, }} s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: testTeamProfiles}, http.StatusNoContent, "team_id", fmt.Sprint(tmResp.Team.ID)) checkMacProfs(&tmResp.Team.ID, append(servermdm.ListFleetReservedMacOSProfileNames(), "n2")...) checkWinProfs(&tmResp.Team.ID, servermdm.ListFleetReservedWindowsProfileNames()...) } func (s *integrationMDMTestSuite) TestMDMAppleConfigProfileCRUD() { t := s.T() ctx := context.Background() testTeam, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TestTeam"}) require.NoError(t, err) teamDelete, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "TeamDelete"}) require.NoError(t, err) testProfiles := make(map[string]fleet.MDMAppleConfigProfile) generateTestProfile := func(name string, identifier string) { i := identifier if i == "" { i = fmt.Sprintf("%s.SomeIdentifier", name) } cp := fleet.MDMAppleConfigProfile{ Name: name, Identifier: i, } cp.Mobileconfig = mcBytesForTest(cp.Name, cp.Identifier, fmt.Sprintf("%s.UUID", name)) testProfiles[name] = cp } setTestProfileID := func(name string, id uint) { tp := testProfiles[name] tp.ProfileID = id testProfiles[name] = tp } generateNewReq := func(name string, teamID *uint) (*bytes.Buffer, map[string]string) { args := map[string][]string{} if teamID != nil { args["team_id"] = []string{fmt.Sprintf("%d", *teamID)} } return generateNewProfileMultipartRequest(t, "some_filename", testProfiles[name].Mobileconfig, s.token, args) } checkGetResponse := func(resp *http.Response, expected fleet.MDMAppleConfigProfile) { // check expected headers require.Contains(t, resp.Header["Content-Type"], "application/x-apple-aspen-config") require.Contains(t, resp.Header["Content-Disposition"], fmt.Sprintf(`attachment;filename="%s_%s.%s"`, time.Now().Format("2006-01-02"), strings.ReplaceAll(expected.Name, " ", "_"), "mobileconfig")) // check expected body var bb bytes.Buffer _, err = io.Copy(&bb, resp.Body) require.NoError(t, err) require.Equal(t, []byte(expected.Mobileconfig), bb.Bytes()) } checkConfigProfile := func(expected fleet.MDMAppleConfigProfile, actual fleet.MDMAppleConfigProfile) { require.Equal(t, expected.Name, actual.Name) require.Equal(t, expected.Identifier, actual.Identifier) } host, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) s.Do("POST", "/api/latest/fleet/hosts/transfer", addHostsToTeamRequest{ TeamID: &teamDelete.ID, HostIDs: []uint{host.ID}, }, http.StatusOK) // create new profile (no team) generateTestProfile("TestNoTeam", "") body, headers := generateNewReq("TestNoTeam", nil) newResp := s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers) var newCP fleet.MDMAppleConfigProfile err = json.NewDecoder(newResp.Body).Decode(&newCP) require.NoError(t, err) require.NotEmpty(t, newCP.ProfileID) setTestProfileID("TestNoTeam", newCP.ProfileID) // create new profile (with team id) generateTestProfile("TestWithTeamID", "") body, headers = generateNewReq("TestWithTeamID", &testTeam.ID) newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers) err = json.NewDecoder(newResp.Body).Decode(&newCP) require.NoError(t, err) require.NotEmpty(t, newCP.ProfileID) setTestProfileID("TestWithTeamID", newCP.ProfileID) // Create a profile that we're going to remove immediately generateTestProfile("TestImmediateDelete", "") body, headers = generateNewReq("TestImmediateDelete", &teamDelete.ID) newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusOK, headers) newCP = fleet.MDMAppleConfigProfile{} err = json.NewDecoder(newResp.Body).Decode(&newCP) require.NoError(t, err) require.NotEmpty(t, newCP.ProfileID) setTestProfileID("TestImmediateDelete", newCP.ProfileID) // check that host_mdm_apple_profiles entry was created var hostResp getHostResponse s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) require.NotNil(t, hostResp.Host.MDM.Profiles) require.Len(t, *hostResp.Host.MDM.Profiles, 1) require.Equal(t, (*hostResp.Host.MDM.Profiles)[0].Name, "TestImmediateDelete") // now delete the profile before it's sent, we should see the host_mdm_apple_profiles entry go // away deletedCP := testProfiles["TestImmediateDelete"] deletePath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID) var deleteResp deleteMDMAppleConfigProfileResponse s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp) // confirm deleted var listResp listMDMAppleConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: teamDelete.ID}, http.StatusOK, &listResp) require.Len(t, listResp.ConfigProfiles, 0) getPath := fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID) _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)}) // confirm no host profiles hostResp = getHostResponse{} s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &hostResp) require.Nil(t, hostResp.Host.MDM.Profiles) // list profiles (no team) expectedCP := testProfiles["TestNoTeam"] s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", nil, http.StatusOK, &listResp) require.Len(t, listResp.ConfigProfiles, 1) respCP := listResp.ConfigProfiles[0] require.Equal(t, expectedCP.Name, respCP.Name) checkConfigProfile(expectedCP, *respCP) require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes require.Empty(t, respCP.TeamID) // zero means no team // list profiles (team 1) expectedCP = testProfiles["TestWithTeamID"] listResp = listMDMAppleConfigProfilesResponse{} s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp) require.Len(t, listResp.ConfigProfiles, 1) respCP = listResp.ConfigProfiles[0] require.Equal(t, expectedCP.Name, respCP.Name) checkConfigProfile(expectedCP, *respCP) require.Empty(t, respCP.Mobileconfig) // list profiles endpoint shouldn't include mobileconfig bytes require.Equal(t, testTeam.ID, *respCP.TeamID) // team 1 // get profile (no team) expectedCP = testProfiles["TestNoTeam"] getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID) getResp := s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)}) checkGetResponse(getResp, expectedCP) // get profile (team 1) expectedCP = testProfiles["TestWithTeamID"] getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", expectedCP.ProfileID) getResp = s.DoRawWithHeaders("GET", getPath, nil, http.StatusOK, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)}) checkGetResponse(getResp, expectedCP) // delete profile (no team) deletedCP = testProfiles["TestNoTeam"] deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID) s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp) // confirm deleted listResp = listMDMAppleConfigProfilesResponse{} s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{}, http.StatusOK, &listResp) require.Len(t, listResp.ConfigProfiles, 0) getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID) _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)}) // delete profile (team 1) deletedCP = testProfiles["TestWithTeamID"] deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID) deleteResp = deleteMDMAppleConfigProfileResponse{} s.DoJSON("DELETE", deletePath, nil, http.StatusOK, &deleteResp) // confirm deleted listResp = listMDMAppleConfigProfilesResponse{} s.DoJSON("GET", "/api/latest/fleet/mdm/apple/profiles", listMDMAppleConfigProfilesRequest{TeamID: testTeam.ID}, http.StatusOK, &listResp) require.Len(t, listResp.ConfigProfiles, 0) getPath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", deletedCP.ProfileID) _ = s.DoRawWithHeaders("GET", getPath, nil, http.StatusNotFound, map[string]string{"Authorization": fmt.Sprintf("Bearer %s", s.token)}) // fail to create new profile (no team), invalid fleet secret testProfiles["badSecrets"] = fleet.MDMAppleConfigProfile{ Name: "badSecrets", Identifier: "badSecrets.One", Mobileconfig: mobileconfig.Mobileconfig(` PayloadContent PayloadDisplayName badSecrets PayloadIdentifier badSecrets.One PayloadType Configuration PayloadUUID $FLEET_SECRET_INVALID.35E2029E-A0C2-4754-B709-4CAAB1B8D3CB PayloadVersion 1 `), } body, headers = generateNewReq("badSecrets", nil) newResp = s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusUnprocessableEntity, headers) errMsg := extractServerErrorText(newResp.Body) require.Contains(t, errMsg, "$FLEET_SECRET_INVALID") // trying to add/delete profiles with identifiers managed by Fleet fails for p := range mobileconfig.FleetPayloadIdentifiers() { generateTestProfile("TestNoTeam", p) body, headers := generateNewReq("TestNoTeam", nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) generateTestProfile("TestWithTeamID", p) body, headers = generateNewReq("TestWithTeamID", nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil) require.NoError(t, err) testProfiles["WithContent"] = *cp body, headers = generateNewReq("WithContent", nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) } // trying to add profiles with identifiers managed by Fleet fails for p := range mobileconfig.FleetPayloadIdentifiers() { generateTestProfile("TestNoTeam", p) body, headers := generateNewReq("TestNoTeam", nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) generateTestProfile("TestWithTeamID", p) body, headers = generateNewReq("TestWithTeamID", nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent("N1", "I1", p, "random", ""), nil) require.NoError(t, err) testProfiles["WithContent"] = *cp body, headers = generateNewReq("WithContent", nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) } // trying to add profiles with names reserved by Fleet fails for name := range servermdm.FleetReservedProfileNames() { cp := &fleet.MDMAppleConfigProfile{ Name: name, Identifier: "valid.identifier", Mobileconfig: mcBytesForTest(name, "valid.identifier", "some-uuid"), } body, headers := generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{ "team_id": {fmt.Sprintf("%d", testTeam.ID)}, }) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) cp, err := fleet.NewMDMAppleConfigProfile(mobileconfigForTestWithContent( "valid outer name", "valid.outer.identifier", "valid.inner.identifer", "some-uuid", name, ), nil) require.NoError(t, err) body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, nil) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) cp.TeamID = &testTeam.ID body, headers = generateNewProfileMultipartRequest(t, "some_filename", cp.Mobileconfig, s.token, map[string][]string{ "team_id": {fmt.Sprintf("%d", testTeam.ID)}, }) s.DoRawWithHeaders("POST", "/api/latest/fleet/mdm/apple/profiles", body.Bytes(), http.StatusBadRequest, headers) } // make fleet add a FileVault profile acResp := appConfigResponse{} s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "enable_disk_encryption": true } }`), http.StatusOK, &acResp) assert.True(t, acResp.MDM.EnableDiskEncryption.Value) profile := s.assertConfigProfilesByIdentifier(nil, mobileconfig.FleetFileVaultPayloadIdentifier, true) // try to delete the profile deletePath = fmt.Sprintf("/api/latest/fleet/mdm/apple/profiles/%d", profile.ProfileID) deleteResp = deleteMDMAppleConfigProfileResponse{} s.DoJSON("DELETE", deletePath, nil, http.StatusBadRequest, &deleteResp) } func (s *integrationMDMTestSuite) TestHostMDMProfilesExcludeLabels() { t := s.T() s.setSkipWorkerJobs(t) ctx := context.Background() triggerReconcileProfiles := func() { s.awaitTriggerProfileSchedule(t) // this will only mark them as "pending", as the response to confirm // profile deployment is asynchronous, so we simulate it here by // updating any "pending" (not NULL) profiles to "verifying" mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { return err } if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { return err } if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { return err } return nil }) } // run the crons immediately, will create the Fleet-controlled profiles that // will then be expected to be applied (e.g. com.fleetdm.fleetd.config and // com.fleetdm.caroot) // first create the no-team enroll secret (required to create the fleet profiles) var applyResp applyEnrollSecretSpecResponse s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}}, }, http.StatusOK, &applyResp) s.awaitTriggerProfileSchedule(t) // create an Apple and a Windows host appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) // create a few labels labels := make([]*fleet.Label, 5) for i := 0; i < len(labels); i++ { label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"}) require.NoError(t, err) labels[i] = label } // simulate reporting label results for those hosts appleHost.LabelUpdatedAt = time.Now() windowsHost.LabelUpdatedAt = time.Now() err := s.ds.UpdateHost(ctx, appleHost) require.NoError(t, err) err = s.ds.UpdateHost(ctx, windowsHost) require.NoError(t, err) // set an Apple profile and declaration and a Windows profile s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsExcludeAny: []string{labels[0].Name, labels[1].Name}}, {Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsExcludeAny: []string{labels[2].Name, labels[3].Name}}, {Name: "D3", Contents: declarationForTest("D3"), LabelsExcludeAny: []string{labels[4].Name}}, }}, http.StatusNoContent) // hosts are not members of any label yet, so running the cron applies the labels s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // simulate the reconcile profiles deployment triggerReconcileProfiles() s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // mark some profiles as verified (despite accepting a HostMacOSProfile struct, it supports Windows too) err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, appleHost, map[string]*fleet.HostMacOSProfile{ "A1": {Identifier: "A1", DisplayName: "A1", InstallDate: time.Now()}, }) require.NoError(t, err) err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, windowsHost, map[string]*fleet.HostMacOSProfile{ "W2": {Identifier: "W2", DisplayName: "W2", InstallDate: time.Now()}, }) require.NoError(t, err) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, }) // make hosts members of labels [1], [2], [3] and [4], meaning that none of the profiles apply anymore err = s.ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{ {labels[1].ID, appleHost.ID}, {labels[2].ID, appleHost.ID}, {labels[3].ID, appleHost.ID}, {labels[4].ID, appleHost.ID}, {labels[1].ID, windowsHost.ID}, {labels[2].ID, windowsHost.ID}, {labels[3].ID, windowsHost.ID}, {labels[4].ID, windowsHost.ID}, }) require.NoError(t, err) s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // windows profiles go straight to removed without getting deleted on the host s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: {}, }) // remove membership of labels [2] for Windows, and [4] for Apple, meaning // that only D3 will be installed on Apple (as the Windows host is still // member of an excluded label) err = s.ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{ {labels[4].ID, appleHost.ID}, {labels[2].ID, windowsHost.ID}, }) require.NoError(t, err) s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: {}, }) // remove label [3] as an excluded label for the Windows profile, meaning // that the host now meets the requirement to install. s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsExcludeAny: []string{labels[0].Name, labels[1].Name}}, {Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsExcludeAny: []string{labels[2].Name}}, {Name: "D3", Contents: declarationForTest("D3"), LabelsExcludeAny: []string{labels[4].Name}}, }}, http.StatusNoContent) s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // simulate the reconcile profiles deployment and mark as verified triggerReconcileProfiles() err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, windowsHost, map[string]*fleet.HostMacOSProfile{ "W2": {Identifier: "W2", DisplayName: "W2", InstallDate: time.Now()}, }) require.NoError(t, err) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, }) // break the A1 profile by deleting labels [1] err = s.ds.DeleteLabel(ctx, labels[1].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) // it doesn't get installed to the Apple host, as it is broken triggerReconcileProfiles() s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, }) // it also doesn't get installed to a new host not a member of any labels appleHost2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) triggerReconcileProfiles() s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, appleHost2: { {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, }) // delete labels [2] and [4], breaking D3 and W2, they don't get removed // since they are broken err = s.ds.DeleteLabel(ctx, labels[2].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) err = s.ds.DeleteLabel(ctx, labels[4].Name, fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}) require.NoError(t, err) triggerReconcileProfiles() s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, appleHost2: { {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerified}, }, }) } func (s *integrationMDMTestSuite) TestMDMProfilesIncludeAnyLabels() { t := s.T() ctx := context.Background() triggerReconcileProfiles := func() { s.awaitTriggerProfileSchedule(t) // this will only mark them as "pending", as the response to confirm // profile deployment is asynchronous, so we simulate it here by // updating any "pending" (not NULL) profiles to "verifying" mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { return err } if _, err := q.ExecContext(ctx, `UPDATE host_mdm_apple_declarations SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { return err } if _, err := q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET status = ? WHERE status = ?`, fleet.OSSettingsVerifying, fleet.OSSettingsPending); err != nil { return err } return nil }) } // run the crons immediately, will create the Fleet-controlled profiles that // will then be expected to be applied (e.g. com.fleetdm.fleetd.config and // com.fleetdm.caroot) // first create the no-team enroll secret (required to create the fleet profiles) var applyResp applyEnrollSecretSpecResponse s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ Spec: &fleet.EnrollSecretSpec{Secrets: []*fleet.EnrollSecret{{Secret: "super-global-secret"}}}, }, http.StatusOK, &applyResp) s.awaitTriggerProfileSchedule(t) // create an Apple and a Windows host appleHost, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) windowsHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) // create a few labels, we'll use the first five for "exclude any" profiles and the remaining for "include any" labels := make([]*fleet.Label, 10) for i := 0; i < len(labels); i++ { label, err := s.ds.NewLabel(ctx, &fleet.Label{Name: fmt.Sprintf("label-%d", i), Query: "select 1;"}) require.NoError(t, err) labels[i] = label } // simulate reporting label results for those hosts appleHost.LabelUpdatedAt = time.Now() windowsHost.LabelUpdatedAt = time.Now() err := s.ds.UpdateHost(ctx, appleHost) require.NoError(t, err) err = s.ds.UpdateHost(ctx, windowsHost) require.NoError(t, err) // set up some Apple profiles and declarations and Windows profiles s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "A1", Contents: mobileconfigForTest("A1", "A1"), LabelsIncludeAny: []string{labels[0].Name, labels[1].Name}}, {Name: "W2", Contents: syncMLForTest("./Foo/W2"), LabelsIncludeAny: []string{labels[2].Name, labels[3].Name}}, {Name: "D3", Contents: declarationForTest("D3"), LabelsIncludeAny: []string{labels[4].Name}}, }}, http.StatusNoContent) // hosts are not members of any label yet, so running the cron applies no labels s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: {}, }) // make hosts members of labels [1], [2], [3] and [4], meaning that each of the "include any" // labels will now match at least one host err = s.ds.AsyncBatchInsertLabelMembership(ctx, [][2]uint{ {labels[0].ID, appleHost.ID}, {labels[1].ID, appleHost.ID}, {labels[2].ID, appleHost.ID}, {labels[3].ID, appleHost.ID}, {labels[4].ID, appleHost.ID}, {labels[1].ID, windowsHost.ID}, {labels[2].ID, windowsHost.ID}, {labels[3].ID, windowsHost.ID}, {labels[4].ID, windowsHost.ID}, }) require.NoError(t, err) triggerReconcileProfiles() s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) // remove membership of labels [2] for Windows, and [1] and [4] for Apple, meaning // that D3 will be removed on Apple, A1 will remain on Apple because the host is still a member // of [0], and W2 will remain on Windows because the host is still a member of [3] err = s.ds.AsyncBatchDeleteLabelMembership(ctx, [][2]uint{ {labels[1].ID, appleHost.ID}, {labels[4].ID, appleHost.ID}, {labels[2].ID, windowsHost.ID}, }) require.NoError(t, err) s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ appleHost: { {Identifier: "A1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "D3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ windowsHost: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, }, }) } func (s *integrationMDMTestSuite) TestOTAProfile() { t := s.T() ctx := context.Background() // Getting profile for non-existent secret it's ok s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", getOTAProfileRequest{}, http.StatusOK, "enroll_secret", "not-real") // Create an enroll secret; has some special characters that should be escaped in the profile globalEnrollSec := "global_enroll+_/sec" escSec := url.QueryEscape(globalEnrollSec) s.Do("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{ Spec: &fleet.EnrollSecretSpec{ Secrets: []*fleet.EnrollSecret{{Secret: globalEnrollSec}}, }, }, http.StatusOK) cfg, err := s.ds.AppConfig(ctx) require.NoError(t, err) t.Run("gets profile with idp uuid included if boyd cookie is set", func(t *testing.T) { // Get profile with that enroll secret j, err := json.Marshal(getOTAProfileRequest{}) require.NoError(t, err) idpUUID := uuid.New() resp := s.DoRawWithHeaders("GET", "/api/latest/fleet/enrollment_profiles/ota", j, http.StatusOK, map[string]string{ "Cookie": fmt.Sprintf("%s=%s", shared_mdm.BYODIdpCookieName, idpUUID.String()), }, "enroll_secret", globalEnrollSec) require.NotZero(t, resp.ContentLength) require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`) require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") defer resp.Body.Close() b, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, resp.ContentLength, int64(len(b))) require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota") require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s&idp_uuid=%s", cfg.ServerSettings.ServerURL, escSec, idpUUID.String())) require.Contains(t, string(b), cfg.OrgInfo.OrgName) }) t.Run("does not include idp_uuid in the url if cookie is not set", func(t *testing.T) { resp := s.Do("GET", "/api/latest/fleet/enrollment_profiles/ota", &getOTAProfileRequest{}, http.StatusOK, "enroll_secret", globalEnrollSec) require.NotZero(t, resp.ContentLength) require.Contains(t, resp.Header.Get("Content-Disposition"), `attachment;filename="fleet-mdm-enrollment-profile.mobileconfig"`) require.Contains(t, resp.Header.Get("Content-Type"), "application/x-apple-aspen-config") require.Contains(t, resp.Header.Get("X-Content-Type-Options"), "nosniff") b, err := io.ReadAll(resp.Body) require.NoError(t, err) require.Equal(t, resp.ContentLength, int64(len(b))) require.Contains(t, string(b), "com.fleetdm.fleet.mdm.apple.ota") require.Contains(t, string(b), fmt.Sprintf("%s/api/v1/fleet/ota_enrollment?enroll_secret=%s", cfg.ServerSettings.ServerURL, escSec)) require.NotContains(t, string(b), "idp_uuid=") require.Contains(t, string(b), cfg.OrgInfo.OrgName) }) } // TestAppleDDMSecretVariablesUpload tests uploading DDM profiles with secrets via the /configuration_profiles endpoint func (s *integrationMDMTestSuite) TestAppleDDMSecretVariablesUpload() { tmpl := ` { "Type": "com.apple.configuration.decl%d", "Identifier": "com.fleet.config%d", "Payload": { "ServiceType": "com.apple.bash%d", "DataAssetReference": "com.fleet.asset.bash" } }` newProfileBytes := func(i int) []byte { return []byte(fmt.Sprintf(tmpl, i, i, i)) } getProfileContents := func(profileUUID string) string { profile, err := s.ds.GetMDMAppleDeclaration(context.Background(), profileUUID) require.NoError(s.T(), err) assert.NotNil(s.T(), profile.SecretsUpdatedAt) return string(profile.RawJSON) } s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "json", "darwin") } func (s *integrationMDMTestSuite) testSecretVariablesUpload(newProfileBytes func(i int) []byte, getProfileContents func(profileUUID string) string, fileExtension string, platform string, ) { t := s.T() const numProfiles = 2 var profiles [][]byte for i := 0; i < numProfiles; i++ { profiles = append(profiles, newProfileBytes(i)) } // Use secrets myBash := "com.apple.bash0" profiles[0] = []byte(strings.ReplaceAll(string(profiles[0]), myBash, "$"+fleet.ServerSecretPrefix+"BASH")) secretProfile := profiles[1] profiles[1] = []byte("${" + fleet.ServerSecretPrefix + "PROFILE}") body, headers := generateNewProfileMultipartRequest( t, "secret-config0."+fileExtension, profiles[0], s.token, nil, ) res := s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusUnprocessableEntity, headers) assertBodyContains(t, res, `Secret variable \"$FLEET_SECRET_BASH\" missing`) // Add secret(s) to server req := createSecretVariablesRequest{ SecretVariables: []fleet.SecretVariable{ { Name: "FLEET_SECRET_BASH", Value: myBash, }, { Name: "FLEET_SECRET_PROFILE", Value: string(secretProfile), }, }, } secretResp := createSecretVariablesResponse{} s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers) var resp newMDMConfigProfileResponse err := json.NewDecoder(res.Body).Decode(&resp) require.NoError(t, err) assert.NotEmpty(t, resp.ProfileUUID) body, headers = generateNewProfileMultipartRequest( t, "secret-config1."+fileExtension, profiles[1], s.token, nil, ) s.DoJSON("PUT", "/api/latest/fleet/spec/secret_variables", req, http.StatusOK, &secretResp) res = s.DoRawWithHeaders("POST", "/api/latest/fleet/configuration_profiles", body.Bytes(), http.StatusOK, headers) err = json.NewDecoder(res.Body).Decode(&resp) require.NoError(t, err) assert.NotEmpty(t, resp.ProfileUUID) var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &listResp) require.Len(t, listResp.Profiles, numProfiles) profileUUIDs := make([]string, numProfiles) for _, p := range listResp.Profiles { switch p.Name { case "secret-config0": assert.Equal(t, platform, p.Platform) profileUUIDs[0] = p.ProfileUUID case "secret-config1": assert.Equal(t, platform, p.Platform) profileUUIDs[1] = p.ProfileUUID default: t.Errorf("unexpected profile %s", p.Name) } } // Check that contents are masking secret values for i := 0; i < numProfiles; i++ { assert.Equal(t, string(profiles[i]), getProfileContents(profileUUIDs[i])) } // Delete profiles -- make sure there is no issue deleting profiles with secrets for i := 0; i < numProfiles; i++ { s.Do("DELETE", "/api/latest/fleet/configuration_profiles/"+profileUUIDs[i], nil, http.StatusOK) } s.DoJSON("GET", "/api/latest/fleet/mdm/profiles", &listMDMConfigProfilesRequest{}, http.StatusOK, &listResp) require.Empty(t, listResp.Profiles) } // TestAppleConfigSecretVariablesUpload tests uploading Apple config profiles with secrets via the /configuration_profiles endpoint func (s *integrationMDMTestSuite) TestAppleConfigSecretVariablesUpload() { tmpl := ` PayloadDescription For secret variables PayloadDisplayName secret-config%d PayloadIdentifier PI%d PayloadType Configuration PayloadUUID %d PayloadVersion 1 PayloadContent Bash $FLEET_SECRET_BASH PayloadDisplayName secret payload PayloadIdentifier com.test.secret PayloadType com.test.secretd PayloadUUID 476F5334-D501-4768-9A31-1A18A4E1E808 PayloadVersion 1 ` newProfileBytes := func(i int) []byte { return []byte(fmt.Sprintf(tmpl, i, i, i)) } getProfileContents := func(profileUUID string) string { profile, err := s.ds.GetMDMAppleConfigProfile(context.Background(), profileUUID) require.NoError(s.T(), err) assert.NotNil(s.T(), profile.SecretsUpdatedAt) return string(profile.Mobileconfig) } s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "mobileconfig", "darwin") } // TestWindowsConfigSecretVariablesUpload tests uploading Windows profiles with secrets via the /configuration_profiles endpoint func (s *integrationMDMTestSuite) TestWindowsConfigSecretVariablesUpload() { tmpl := ` int ./Device/Vendor/MSFT/Policy/Config/System/DisableOneDriveFileSync $FLEET_SECRET_BASH ` newProfileBytes := func(i int) []byte { return []byte(fmt.Sprintf(tmpl, i, i, i)) } getProfileContents := func(profileUUID string) string { profile, err := s.ds.GetMDMWindowsConfigProfile(context.Background(), profileUUID) require.NoError(s.T(), err) return string(profile.SyncML) } s.testSecretVariablesUpload(newProfileBytes, getProfileContents, "xml", "windows") } func (s *integrationMDMTestSuite) TestAppleProfileDeletion() { t := s.T() ctx := context.Background() err := s.ds.ApplyEnrollSecrets(ctx, nil, []*fleet.EnrollSecret{{Secret: t.Name()}}) require.NoError(t, err) globalProfiles := [][]byte{ mobileconfigForTest("N1", "I1"), } wantGlobalProfiles := globalProfiles wantGlobalProfiles = append( wantGlobalProfiles, setupExpectedFleetdProfile(t, s.server.URL, t.Name(), nil), ) // add global profiles s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // Create a host and then enroll to MDM. host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) // Add IdP email to host mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error { _, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp@example.com", host.ID, fleet.DeviceMappingMDMIdpAccounts) return err }) // trigger a profile sync s.awaitTriggerProfileSchedule(t) installs, removes := checkNextPayloads(t, mdmDevice, false) // verify that we received all profiles s.signedProfilesMatch( append(wantGlobalProfiles, setupExpectedCAProfile(t, s.ds)), installs, ) require.Empty(t, removes) // Add a profile with a Fleet variable. We are also testing that removal of a profile with a Fleet variable works. // A unique command is created for each host when this Fleet variable is used. globalProfilesPlusOne := [][]byte{ globalProfiles[0], mobileconfigForTest("N2", "$FLEET_VAR_"+string(fleet.FleetVarHostEndUserEmailIDP)), } // via the deprecated endpoint, this fails because variables are not supported res := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfilesPlusOne}, http.StatusUnprocessableEntity) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "profile variables are not supported by this deprecated endpoint") // via the new endpoint, this works s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: globalProfilesPlusOne[0]}, {Name: "N2", Contents: globalProfilesPlusOne[1]}, }}, http.StatusNoContent) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // Make sure profile was uploaded profiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) assert.Len(t, profiles, 4) // Delete a profile before it is sent to device s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // trigger a profile sync s.awaitTriggerProfileSchedule(t) sendErrorOnRemoveProfile := func(device *mdmtest.TestAppleMDMClient) { // The host grabs the removal command from Fleet cmd, err := device.Idle() require.NoError(t, err) assert.Equal(t, "RemoveProfile", cmd.Command.RequestType) // Since profile is not on the device, it returns an error. errChain := []mdm.ErrorChain{ { ErrorCode: 89, ErrorDomain: "FooErrorDomain", LocalizedDescription: "The profile not found", }, } cmd, err = device.Err(cmd.CommandUUID, errChain) require.NoError(t, err) assert.Nil(t, cmd) } sendErrorOnRemoveProfile(mdmDevice) // Make sure deleted profile no longer shows up profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) assert.Len(t, profiles, 3) // Add a profile again s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: globalProfilesPlusOne[0]}, {Name: "N2", Contents: globalProfilesPlusOne[1]}, }}, http.StatusNoContent) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // The host grabs the profile from Fleet cmd, err := mdmDevice.Idle() require.NoError(t, err) assert.Equal(t, "InstallProfile", cmd.Command.RequestType) // Verify that the Fleet variable was replaced with the IdP email type Command struct { Command struct { Payload []byte } } var p Command err = plist.Unmarshal(cmd.Raw, &p) require.NoError(t, err) assert.NotContains(t, string(p.Command.Payload), "$FLEET_VAR_"+fleet.FleetVarHostEndUserEmailIDP) assert.Contains(t, string(p.Command.Payload), "idp@example.com") // While the host is installing the profile, we delete it. s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // Host acknowledges installing the profile and grabs the remove command cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) assert.Equal(t, "RemoveProfile", cmd.Command.RequestType) cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) assert.Nil(t, cmd) // Add another device host2, mdmDevice2 := createHostThenEnrollMDM(s.ds, s.server.URL, t) // Add IdP email to host mysql.ExecAdhocSQL(t, s.ds, func(e sqlx.ExtContext) error { _, err := e.ExecContext(ctx, `INSERT INTO host_emails (email, host_id, source) VALUES (?, ?, ?)`, "idp2@example.com", host2.ID, fleet.DeviceMappingMDMIdpAccounts) return err }) // trigger a profile sync s.awaitTriggerProfileSchedule(t) installs, removes = checkNextPayloads(t, mdmDevice2, false) assert.Len(t, installs, 3) assert.Empty(t, removes) // Add a profile again s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: globalProfilesPlusOne[0]}, {Name: "N2", Contents: globalProfilesPlusOne[1]}, }}, http.StatusNoContent) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // Delete a profile before it is sent to both devices s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // The host grabs the removal command from Fleet sendErrorOnRemoveProfile(mdmDevice) sendErrorOnRemoveProfile(mdmDevice2) // Make sure deleted profile no longer shows up on either host profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) assert.Len(t, profiles, 3) profiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host2.UUID) require.NoError(t, err) assert.Len(t, profiles, 3) } func (s *integrationMDMTestSuite) TestBatchResendMDMProfiles() { t := s.T() ctx := t.Context() s.setSkipWorkerJobs(t) // create a few hosts host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) host3, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) // register a couple profiles for Apple and one for Windows profN1 := mobileconfigForTest("N1", "I1") profN2 := mobileconfigForTest("N2", "I2") profN3 := syncMLForTest("./Foo/N3") declN4 := declarationForTest("N4") batchRequest := batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "N1", Contents: profN1}, {Name: "N2", Contents: profN2}, {Name: "N3", Contents: profN3}, {Name: "N4", Contents: declN4}, }} s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchRequest, http.StatusNoContent) // list the profiles to get the UUIDs var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload) for _, prof := range listResp.Profiles { if len(prof.Checksum) == 0 { // not important, but must not be empty or it causes issues when forcing a status prof.Checksum = []byte("checksum") } profNameToPayload[prof.Name] = prof } // get status for non-existing profile s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "ano-such-profile"), nil, http.StatusNotFound) s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "wno-such-profile"), nil, http.StatusNotFound) s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "dno-such-profile"), nil, http.StatusNotFound) s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", "zno-such-profile"), nil, http.StatusNotFound) // get status for existing profiles, all 0 counts for _, uuid := range []string{profNameToPayload["N1"].ProfileUUID, profNameToPayload["N2"].ProfileUUID, profNameToPayload["N3"].ProfileUUID} { var statusResp getMDMConfigProfileStatusResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", uuid), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{}, statusResp.MDMConfigProfileStatus) } // except for the declaration, which is immediately set as pending on the hosts var statusResp getMDMConfigProfileStatusResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus) // try to batch-resend a non-existing profile batchReq := batchResendMDMProfileToHostsRequest{ProfileUUID: "zzzz"} // not a known prefix batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound) batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "azzzz"} // unknown Apple profile batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound) batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: "wzzzz"} // unknown Windows profile batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusNotFound) // batch-resend with an invalid filter batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID} batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryPending) res := s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest) msg := extractServerErrorText(res.Body) require.Contains(t, msg, "Invalid profile_status filter value, only 'failed' is currently supported.") // batch-resend with an Apple DDM batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N4"].ProfileUUID} batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) res = s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusBadRequest) msg = extractServerErrorText(res.Body) require.Contains(t, msg, "Can't resend declaration (DDM) profiles.") // batch-resend an Apple and a Windows profile, does nothing as it is not delivered yet batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N1"].ProfileUUID} batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID} batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending) forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending) forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending) forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending) forceSetWindowsHostProfileStatus(t, s.ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["N3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryPending) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ host3: { {Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N1"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N2"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N3"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 1}, statusResp.MDMConfigProfileStatus) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus) // acknowledge the Apple profiles, failing I2 on both hosts, and fail the Windows one forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N1"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) forceSetWindowsHostProfileStatus(t, s.ds, host3.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["N3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) // batch-resend N2 profile batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N2"].ProfileUUID} batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) s.lastActivityOfTypeMatches( fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(), fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N2", 2), 0, ) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ host3: { {Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryFailed}, }, }) // set I2/N2 as verifying forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["N2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) // batch-resend N3 profile batchReq = batchResendMDMProfileToHostsRequest{ProfileUUID: profNameToPayload["N3"].ProfileUUID} batchReq.Filters.ProfileStatus = string(fleet.MDMDeliveryFailed) s.Do("POST", "/api/v1/fleet/configuration_profiles/resend/batch", batchReq, http.StatusAccepted) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "I1", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "I2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryVerifying}, {Identifier: "N4", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ host3: { {Name: "N3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.lastActivityOfTypeMatches( fleet.ActivityTypeResentConfigurationProfileBatch{}.ActivityName(), fmt.Sprintf(`{"profile_name": %q, "host_count": %d}`, "N3", 1), 0, ) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N1"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Verifying: 2}, statusResp.MDMConfigProfileStatus) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N2"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Verifying: 2}, statusResp.MDMConfigProfileStatus) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N3"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 1}, statusResp.MDMConfigProfileStatus) s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", profNameToPayload["N4"].ProfileUUID), getMDMConfigProfileStatusRequest{}, http.StatusOK, &statusResp) require.Equal(t, fleet.MDMConfigProfileStatus{Pending: 2}, statusResp.MDMConfigProfileStatus) // trigger profile schedule to get the fleet-controlled profiles s.awaitTriggerProfileSchedule(t) // list the profiles to get a fleet-controlled profile UUID gotProfs, err := s.ds.GetHostMDMAppleProfiles(ctx, host1.UUID) require.NoError(t, err) var fleetReservedProfile string for _, prof := range gotProfs { // find the fleetd config one if prof.Identifier == mobileconfig.FleetdConfigPayloadIdentifier { fleetReservedProfile = prof.ProfileUUID } } require.NotEmpty(t, fleetReservedProfile) // fleet-reserved profiles are not returned by the API, only custom profiles s.Do("GET", fmt.Sprintf("/api/v1/fleet/configuration_profiles/%s/status", fleetReservedProfile), getMDMConfigProfileStatusRequest{}, http.StatusNotFound) } func (s *integrationMDMTestSuite) TestDeleteMDMProfileCancelsInstalls() { t := s.T() s.setSkipWorkerJobs(t) // create some Apple, Windows and declaration profiles profiles := []fleet.MDMProfileBatchPayload{ { Name: "A1", Contents: mobileconfigForTest("A1", "A1"), }, { Name: "A2", Contents: mobileconfigForTest("A2", "A2"), }, { Name: "D1", Contents: declarationForTest("D1"), }, { Name: "D2", Contents: declarationForTest("D2"), }, { Name: "W1", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "W1", Data: "W1"}}), }, { Name: "W2", Contents: syncml.ForTestWithData([]syncml.TestCommand{{Verb: "Replace", LocURI: "W2", Data: "W2"}}), }, } s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent) // list the profiles to get the UUIDs var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload) for _, prof := range listResp.Profiles { if len(prof.Checksum) == 0 { // not important, but must not be empty or it causes issues when forcing a status prof.Checksum = []byte("checksum") } profNameToPayload[prof.Name] = prof t.Logf("profile %s: %s", prof.Name, prof.ProfileUUID) } // deleting without any affected host is fine var deleteResp deleteMDMConfigProfileResponse s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A1"].ProfileUUID), nil, http.StatusOK, &deleteResp) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["D1"].ProfileUUID), nil, http.StatusOK, &deleteResp) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["W1"].ProfileUUID), nil, http.StatusOK, &deleteResp) // create some Apple and Windows hosts host1, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) host2, _ := createHostThenEnrollMDM(s.ds, s.server.URL, t) host3, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) host4, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) for i, h := range []*fleet.Host{host1, host2, host3, host4} { t.Logf("host %d: %s", i+1, h.UUID) } s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "D2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "D2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ host3: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host4: { {Name: "W2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // for the declaration, set host1 as NULL and host2 as verified forceSetAppleHostDeclarationStatus(t, s.ds, host1.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeInstall, "") forceSetAppleHostDeclarationStatus(t, s.ds, host2.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerified) // delete the declaration, will have removed it for host1 and set to remove pending for host2 s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["D2"].ProfileUUID), nil, http.StatusOK, &deleteResp) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "D2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // for the Windows profile, set host4 as failed forceSetWindowsHostProfileStatus(t, s.ds, host4.UUID, test.ToMDMWindowsConfigProfile(profNameToPayload["W2"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) // delete the Windows profile, will have removed it for both (because there // is no "Remove profile" for now with Windows) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["W2"].ProfileUUID), nil, http.StatusOK, &deleteResp) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ host3: {}, host4: {}, }) // for the Apple profile, set host1 as NULL (pending not queued yet), and leave host2 as actually pending (queued) forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A2"]), fleet.MDMOperationTypeInstall, "") assertIsCommandActiveForHostAndProfile := func(hostUUID, profileUUID string, wantActive bool) { var active bool ctx := t.Context() mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { return sqlx.GetContext(ctx, q, &active, `SELECT neq.active FROM nano_enrollment_queue neq JOIN host_mdm_apple_profiles hmap ON hmap.command_uuid = neq.command_uuid AND hmap.host_uuid = neq.id WHERE hmap.host_uuid = ? AND hmap.profile_uuid = ?`, hostUUID, profileUUID) }) if wantActive { require.True(t, active) } else { require.False(t, active) } } assertIsCommandActiveForHostAndProfile(host1.UUID, profNameToPayload["A2"].ProfileUUID, true) assertIsCommandActiveForHostAndProfile(host2.UUID, profNameToPayload["A2"].ProfileUUID, true) // delete the profile, will remove the row for host1 and set host2 to pending remove (and will deactivate the associated nano command) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A2"].ProfileUUID), nil, http.StatusOK, &deleteResp) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: "D2", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) assertIsCommandActiveForHostAndProfile(host2.UUID, profNameToPayload["A2"].ProfileUUID, false) // set the remove operations to verifying and reconcile profiles forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A2"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying) forceSetAppleHostDeclarationStatus(t, s.ds, host2.UUID, test.ToMDMAppleDecl(profNameToPayload["D2"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying) s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) s.assertHostWindowsConfigProfiles(map[*fleet.Host][]fleet.HostMDMWindowsProfile{ host3: {}, host4: {}, }) // add new profile A3 and re-add A2, behaves as if a new profile because it has a new uuid oldA2Contents := profiles[1].Contents profiles = []fleet.MDMProfileBatchPayload{ { Name: "A2", Contents: oldA2Contents, }, { Name: "A3", Contents: mobileconfigForTest("A3", "A3"), }, } s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent) // list the profiles to get the UUIDs s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) profNameToPayload = make(map[string]*fleet.MDMConfigProfilePayload) for _, prof := range listResp.Profiles { if len(prof.Checksum) == 0 { // not important, but must not be empty or it causes issues when forcing a status prof.Checksum = []byte("checksum") } profNameToPayload[prof.Name] = prof t.Logf("new profile %s: %s", prof.Name, prof.ProfileUUID) } s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "A3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "A3", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) // set A3 as failed on host1, and removed on host2 forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryFailed) forceSetAppleHostProfileStatus(t, s.ds, host2.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryFailed) // delete the profile, will mark host1 as pending remove and will not touch host2 (not installed) s.DoJSON("DELETE", fmt.Sprintf("/api/latest/fleet/configuration_profiles/%s", profNameToPayload["A3"].ProfileUUID), nil, http.StatusOK, &deleteResp) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryFailed}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) forceSetAppleHostProfileStatus(t, s.ds, host1.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeRemove, fleet.MDMDeliveryVerifying) s.awaitTriggerProfileSchedule(t) s.assertHostAppleConfigProfiles(map[*fleet.Host][]fleet.HostMDMAppleProfile{ host1: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, host2: { {Identifier: "A2", OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: "A3", OperationType: fleet.MDMOperationTypeRemove, Status: &fleet.MDMDeliveryFailed}, {Identifier: mobileconfig.FleetdConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, {Identifier: mobileconfig.FleetCARootConfigPayloadIdentifier, OperationType: fleet.MDMOperationTypeInstall, Status: &fleet.MDMDeliveryPending}, }, }) } // those helper functions to force-set a host profile status are copied from the Datastore // tests, couldn't put them in the test package due to circular dependency with mysql, would // be nice to find a way to avoid this copy eventually. func forceSetAppleHostProfileStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMAppleConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) { ctx := t.Context() // empty status string means set to NULL var actualStatus *fleet.MDMDeliveryStatus if status != "" { actualStatus = &status } mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_profiles (profile_identifier, host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid) VALUES (?, ?, ?, ?, ?, ?, UNHEX(MD5(?)), ?) ON DUPLICATE KEY UPDATE status = VALUES(status), operation_type = VALUES(operation_type) `, profile.Identifier, hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.Mobileconfig, profile.ProfileUUID) return err }) } func forceSetWindowsHostProfileStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMWindowsConfigProfile, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) { ctx := t.Context() // empty status string means set to NULL var actualStatus *fleet.MDMDeliveryStatus if status != "" { actualStatus = &status } mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `INSERT INTO host_mdm_windows_profiles (host_uuid, status, operation_type, command_uuid, profile_name, checksum, profile_uuid) VALUES (?, ?, ?, ?, ?, UNHEX(MD5(?)), ?) ON DUPLICATE KEY UPDATE status = VALUES(status), operation_type = VALUES(operation_type) `, hostUUID, actualStatus, operation, uuid.NewString(), profile.Name, profile.SyncML, profile.ProfileUUID) return err }) } func forceSetAppleHostDeclarationStatus(t *testing.T, ds *mysql.Datastore, hostUUID string, profile *fleet.MDMAppleDeclaration, operation fleet.MDMOperationType, status fleet.MDMDeliveryStatus) { ctx := t.Context() // empty status string means set to NULL var actualStatus *fleet.MDMDeliveryStatus if status != "" { actualStatus = &status } mysql.ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `INSERT INTO host_mdm_apple_declarations (declaration_identifier, host_uuid, status, operation_type, token, declaration_name, declaration_uuid) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE status = VALUES(status), operation_type = VALUES(operation_type) `, profile.Identifier, hostUUID, actualStatus, operation, test.MakeTestBytes(), profile.Name, profile.DeclarationUUID) return err }) } func (s *integrationMDMTestSuite) TestVerifyUserScopedProfiles() { t := s.T() ctx := t.Context() s.setSkipWorkerJobs(t) // create a macOS host, will enroll only with device host, device := createHostThenEnrollMDM(s.ds, s.server.URL, t) // create some profiles, system- and user-scoped payloadScopeSystem := fleet.PayloadScopeSystem payloadScopeUser := fleet.PayloadScopeUser profiles := []fleet.MDMProfileBatchPayload{ {Name: "A1", Contents: scopedMobileconfigForTest("A1", "A1", &payloadScopeSystem)}, {Name: "A2", Contents: scopedMobileconfigForTest("A2", "A2.user", &payloadScopeUser)}, {Name: "A3", Contents: scopedMobileconfigForTest("A3", "A3.user", &payloadScopeUser)}, } s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: profiles}, http.StatusNoContent) // ensure we are at least 1s after the profiles uploaded-at timestamp time.Sleep(time.Second) // list the profiles to get the UUIDs var listResp listMDMConfigProfilesResponse s.DoJSON("GET", "/api/latest/fleet/configuration_profiles", nil, http.StatusOK, &listResp) profNameToPayload := make(map[string]*fleet.MDMConfigProfilePayload) for _, prof := range listResp.Profiles { if len(prof.Checksum) == 0 { // not important, but must not be empty or it causes issues when forcing a status prof.Checksum = []byte("checksum") } profNameToPayload[prof.Name] = prof t.Logf("profile %s: %s", prof.Name, prof.ProfileUUID) } type hostProfile struct { ProfileUUID string `db:"profile_uuid"` ProfileIdentifier string `db:"profile_identifier"` ProfileName string `db:"profile_name"` Status *string `db:"status"` OperationType *string `db:"operation_type"` Retries int `db:"retries"` Scope string `db:"scope"` } assertHostProfiles := func(want []hostProfile) { var got []hostProfile mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { // for the purpose of this test, we ignore the Fleet-internal profiles // (we only care about the custom profiles) return sqlx.SelectContext(t.Context(), q, &got, ` SELECT profile_uuid, profile_identifier, profile_name, status, operation_type, retries, scope FROM host_mdm_apple_profiles WHERE host_uuid = ? AND profile_identifier NOT IN (?, ?)`, host.UUID, mobileconfig.FleetdConfigPayloadIdentifier, mobileconfig.FleetCARootConfigPayloadIdentifier) }) require.ElementsMatch(t, want, got) } forceProfileUploadeddAtTimestamp := func(ident string, ts time.Time) { mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, `UPDATE mdm_apple_configuration_profiles SET uploaded_at = ? WHERE identifier = ?`, ts, ident) return err }) } // cron job hasn't run yet, so no profile exist for the host assertHostProfiles([]hostProfile{}) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // user-scoped profiles show up as status nil (no user-enrollment yet) assertHostProfiles([]hostProfile{ { ProfileUUID: profNameToPayload["A1"].ProfileUUID, ProfileIdentifier: profNameToPayload["A1"].Identifier, ProfileName: profNameToPayload["A1"].Name, Status: ptr.String(string(fleet.MDMDeliveryPending)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeSystem), }, { ProfileUUID: profNameToPayload["A2"].ProfileUUID, ProfileIdentifier: profNameToPayload["A2"].Identifier, ProfileName: profNameToPayload["A2"].Name, Status: nil, OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, { ProfileUUID: profNameToPayload["A3"].ProfileUUID, ProfileIdentifier: profNameToPayload["A3"].Identifier, ProfileName: profNameToPayload["A3"].Name, Status: nil, OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, }) // verify the profiles, only the system one is reported as installed host.DetailUpdatedAt = time.Now().UTC() err := s.ds.UpdateHost(ctx, host) require.NoError(t, err) err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{ profNameToPayload["A1"].Identifier: { DisplayName: profNameToPayload["A1"].Name, Identifier: profNameToPayload["A1"].Identifier, InstallDate: time.Now().UTC(), }, }) require.NoError(t, err) // user-scoped profiles were left untouched assertHostProfiles([]hostProfile{ { ProfileUUID: profNameToPayload["A1"].ProfileUUID, ProfileIdentifier: profNameToPayload["A1"].Identifier, ProfileName: profNameToPayload["A1"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeSystem), }, { ProfileUUID: profNameToPayload["A2"].ProfileUUID, ProfileIdentifier: profNameToPayload["A2"].Identifier, ProfileName: profNameToPayload["A2"].Name, Status: nil, OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, { ProfileUUID: profNameToPayload["A3"].ProfileUUID, ProfileIdentifier: profNameToPayload["A3"].Identifier, ProfileName: profNameToPayload["A3"].Name, Status: nil, OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, }) // create the user-enrollment err = device.UserEnroll() require.NoError(t, err) // trigger a profile sync s.awaitTriggerProfileSchedule(t) // user-scoped profiles have been added as pending (not nil) assertHostProfiles([]hostProfile{ { ProfileUUID: profNameToPayload["A1"].ProfileUUID, ProfileIdentifier: profNameToPayload["A1"].Identifier, ProfileName: profNameToPayload["A1"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeSystem), }, { ProfileUUID: profNameToPayload["A2"].ProfileUUID, ProfileIdentifier: profNameToPayload["A2"].Identifier, ProfileName: profNameToPayload["A2"].Name, Status: ptr.String(string(fleet.MDMDeliveryPending)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, { ProfileUUID: profNameToPayload["A3"].ProfileUUID, ProfileIdentifier: profNameToPayload["A3"].Identifier, ProfileName: profNameToPayload["A3"].Name, Status: ptr.String(string(fleet.MDMDeliveryPending)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, }) // verify the profiles, A3 is missing but still within the grace period err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{ profNameToPayload["A1"].Identifier: { DisplayName: profNameToPayload["A1"].Name, Identifier: profNameToPayload["A1"].Identifier, InstallDate: time.Now().UTC(), }, profNameToPayload["A2"].Identifier: { DisplayName: profNameToPayload["A2"].Name, Identifier: profNameToPayload["A2"].Identifier, InstallDate: time.Now().UTC(), }, }) require.NoError(t, err) // A2 is now verified, A3 is still pending assertHostProfiles([]hostProfile{ { ProfileUUID: profNameToPayload["A1"].ProfileUUID, ProfileIdentifier: profNameToPayload["A1"].Identifier, ProfileName: profNameToPayload["A1"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeSystem), }, { ProfileUUID: profNameToPayload["A2"].ProfileUUID, ProfileIdentifier: profNameToPayload["A2"].Identifier, ProfileName: profNameToPayload["A2"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, { ProfileUUID: profNameToPayload["A3"].ProfileUUID, ProfileIdentifier: profNameToPayload["A3"].Identifier, ProfileName: profNameToPayload["A3"].Name, Status: ptr.String(string(fleet.MDMDeliveryPending)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, }) // rewind the uploaded_at timestamp of A3 so it is not in the grace period forceProfileUploadeddAtTimestamp(profNameToPayload["A3"].Identifier, time.Now().Add(-24*time.Hour)) // report as still missing err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{ profNameToPayload["A1"].Identifier: { DisplayName: profNameToPayload["A1"].Name, Identifier: profNameToPayload["A1"].Identifier, InstallDate: time.Now().UTC(), }, profNameToPayload["A2"].Identifier: { DisplayName: profNameToPayload["A2"].Name, Identifier: profNameToPayload["A2"].Identifier, InstallDate: time.Now().UTC(), }, }) require.NoError(t, err) // A3 is now missing and retries assertHostProfiles([]hostProfile{ { ProfileUUID: profNameToPayload["A1"].ProfileUUID, ProfileIdentifier: profNameToPayload["A1"].Identifier, ProfileName: profNameToPayload["A1"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeSystem), }, { ProfileUUID: profNameToPayload["A2"].ProfileUUID, ProfileIdentifier: profNameToPayload["A2"].Identifier, ProfileName: profNameToPayload["A2"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, { ProfileUUID: profNameToPayload["A3"].ProfileUUID, ProfileIdentifier: profNameToPayload["A3"].Identifier, ProfileName: profNameToPayload["A3"].Name, Status: nil, OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 1, Scope: string(fleet.PayloadScopeUser), }, }) s.awaitTriggerProfileSchedule(t) // force-set it to Verifying so that by being missing again it goes to failed // (it doesn't go to failed if it is pending) forceSetAppleHostProfileStatus(t, s.ds, host.UUID, test.ToMDMAppleConfigProfile(profNameToPayload["A3"]), fleet.MDMOperationTypeInstall, fleet.MDMDeliveryVerifying) err = apple_mdm.VerifyHostMDMProfiles(ctx, s.ds, host, map[string]*fleet.HostMacOSProfile{ profNameToPayload["A1"].Identifier: { DisplayName: profNameToPayload["A1"].Name, Identifier: profNameToPayload["A1"].Identifier, InstallDate: time.Now().UTC(), }, profNameToPayload["A2"].Identifier: { DisplayName: profNameToPayload["A2"].Name, Identifier: profNameToPayload["A2"].Identifier, InstallDate: time.Now().UTC(), }, }) require.NoError(t, err) assertHostProfiles([]hostProfile{ { ProfileUUID: profNameToPayload["A1"].ProfileUUID, ProfileIdentifier: profNameToPayload["A1"].Identifier, ProfileName: profNameToPayload["A1"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeSystem), }, { ProfileUUID: profNameToPayload["A2"].ProfileUUID, ProfileIdentifier: profNameToPayload["A2"].Identifier, ProfileName: profNameToPayload["A2"].Name, Status: ptr.String(string(fleet.MDMDeliveryVerified)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 0, Scope: string(fleet.PayloadScopeUser), }, { ProfileUUID: profNameToPayload["A3"].ProfileUUID, ProfileIdentifier: profNameToPayload["A3"].Identifier, ProfileName: profNameToPayload["A3"].Name, Status: ptr.String(string(fleet.MDMDeliveryFailed)), OperationType: ptr.String(string(fleet.MDMOperationTypeInstall)), Retries: 1, Scope: string(fleet.PayloadScopeUser), }, }) } func (s *integrationMDMTestSuite) TestMDMAppleProfileScopeChanges() { t := s.T() ctx := context.Background() // add a couple global profiles payloadScopeSystem := fleet.PayloadScopeSystem payloadScopeUser := fleet.PayloadScopeUser globalProfiles := [][]byte{ mobileconfigForTest("G1", "G1"), scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem), scopedMobileconfigForTest("G3", "G3.user", &payloadScopeUser), scopedMobileconfigForTest("G4", "G4.user-but-actually-system", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) // Create a profile with a scope that is System in the DB but User in the XML. This mimics // our upgrade behavior from versions prior to 4.71 to 4.71+ when we added support for User // scoped profiles mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { stmt := `UPDATE mdm_apple_configuration_profiles SET scope=? WHERE identifier=?;` _, err := q.ExecContext(context.Background(), stmt, fleet.PayloadScopeSystem, "G4.user-but-actually-system") return err }) // create a team with a couple profiles tm1, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_1"}) require.NoError(t, err) tm1Profiles := [][]byte{ mobileconfigForTest("T1.1", "T1.1"), scopedMobileconfigForTest("T1.2", "T1.2", &payloadScopeSystem), scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID)) // create a second team with different profiles tm2, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "team_profile_scope_changes_2"}) require.NoError(t, err) tm2Profiles := [][]byte{ mobileconfigForTest("T2.1", "T2.1"), scopedMobileconfigForTest("T2.2", "T2.2.user", &payloadScopeSystem), scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) // Do a no-op update of each team's profiles, verify no errors are returned s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: globalProfiles}, http.StatusNoContent) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm1Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID)) s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: tm2Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm2.ID)) // Test a modification of an existing global profile with an implicit scope change newGlobalProfiles := [][]byte{ globalProfiles[0], globalProfiles[1], globalProfiles[2], scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser), } response := s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: newGlobalProfiles}, http.StatusBadRequest) errMsg := extractServerErrorText(response.Body) require.Contains(t, errMsg, "Couldn't edit configuration profile (G4.user-but-actually-system) because it was previously delivered to some hosts on the device channel") // Test a conflict of a profile on a team with an existing global profile and an implicit scope change // Should error because "G4.user-but-actually-system" conflicts with global // "G4.user-but-actually-system" profile scope newTm1Profiles := [][]byte{ tm1Profiles[0], // T1.1 tm1Profiles[1], // T1.2 tm1Profiles[2], // T1.3.user scopedMobileconfigForTest("G4-bozo", "G4.user-but-actually-system", &payloadScopeUser), } response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, "team_id", fmt.Sprint(tm1.ID)) errMsg = extractServerErrorText(response.Body) require.Contains(t, errMsg, "Couldn't add configuration profile (G4.user-but-actually-system) because \"PayloadScope\" conflicts") // Test a conflict of a profile on a team with an existing global profile // Should error because "G2" conflicts with global "G2" profile newTm1Profiles = [][]byte{ tm1Profiles[0], // T1.1 tm1Profiles[1], // T1.2 tm1Profiles[2], // T1.3.user scopedMobileconfigForTest("G2", "G2", &payloadScopeUser), } response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, "team_id", fmt.Sprint(tm1.ID)) errMsg = extractServerErrorText(response.Body) require.Contains(t, errMsg, "Couldn't add configuration profile (G2) because \"PayloadScope\" conflicts") // Test a conflict of a profile on a team versus one with the same identifier but different // scope on a different team. // Should error because "T2.3.user" system-scoped profile conflicts with team2 "T2.3.user" user-scoped profile newTm1Profiles = [][]byte{ tm1Profiles[0], // T1.1 tm1Profiles[1], // T1.2 tm1Profiles[2], // T1.3.user scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeSystem), // T2.3.user changed to system scope } response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, "team_id", fmt.Sprint(tm1.ID)) errMsg = extractServerErrorText(response.Body) require.Contains(t, errMsg, "Couldn't add configuration profile (T2.3.user) because \"PayloadScope\" conflicts") // Profile edit of existing profile on team1 with a new scope newTm1Profiles = [][]byte{ tm1Profiles[0], // T1.1 tm1Profiles[1], // T1.2 scopedMobileconfigForTest("T1.3", "T1.3.user", &payloadScopeSystem), // T1.3.user changed to system scope } response = s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusBadRequest, "team_id", fmt.Sprint(tm1.ID)) errMsg = extractServerErrorText(response.Body) require.Contains(t, errMsg, "Couldn't edit configuration profile (T1.3.user) because the profile's \"PayloadScope\" has changed") // Should be able to add these profiles to team1 with the proper scopes newTm1Profiles = [][]byte{ tm1Profiles[0], // T1.1 tm1Profiles[1], // T1.2 tm1Profiles[2], // T1.3.user scopedMobileconfigForTest("G2", "G2", &payloadScopeSystem), scopedMobileconfigForTest("T2.3", "T2.3.user", &payloadScopeUser), // T2.3.user changed to system scope } s.Do("POST", "/api/v1/fleet/mdm/apple/profiles/batch", batchSetMDMAppleProfilesRequest{Profiles: newTm1Profiles}, http.StatusNoContent, "team_id", fmt.Sprint(tm1.ID)) } func (s *integrationMDMTestSuite) TestWindowsProfilesWithFleetVariables() { t := s.T() ctx := t.Context() // Create a team for team-scoped tests tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: "test_windows_fleet_vars"}) require.NoError(t, err) testCases := []struct { name string profiles []fleet.MDMProfileBatchPayload teamID *uint wantStatus int wantErrContains string }{ { name: "HOST_UUID variable accepted for team", profiles: []fleet.MDMProfileBatchPayload{ { Name: "TestHostUUID", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusNoContent, }, { name: "HOST_UUID variable with braces accepted", profiles: []fleet.MDMProfileBatchPayload{ { Name: "TestHostUUIDBraces", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "${FLEET_VAR_HOST_UUID}"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusNoContent, }, { name: "unsupported variable rejected", profiles: []fleet.MDMProfileBatchPayload{ { Name: "TestUnsupported", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Serial", Data: "$FLEET_VAR_HOST_FAKE"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusUnprocessableEntity, wantErrContains: "Fleet variable $FLEET_VAR_HOST_FAKE is not supported in Windows profiles", }, { name: "mixed supported and unsupported variables rejected", profiles: []fleet.MDMProfileBatchPayload{ { Name: "TestMixed", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"}, {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/Email", Data: "$FLEET_VAR_BOGUS"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusUnprocessableEntity, wantErrContains: "Fleet variable $FLEET_VAR_BOGUS is not supported in Windows profiles", }, { name: "HOST_UUID variable accepted globally", profiles: []fleet.MDMProfileBatchPayload{ { Name: "GlobalHostUUID", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"}, }), }, }, teamID: nil, // global profile wantStatus: http.StatusNoContent, }, { name: "batch with regular and variable profiles accepted", profiles: []fleet.MDMProfileBatchPayload{ { Name: "RegularProfile", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"}, }), }, { Name: "ProfileWithVar", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusNoContent, }, { name: "multiple HOST_UUID variables in single profile accepted", profiles: []fleet.MDMProfileBatchPayload{ { Name: "MultipleHostUUID", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "$FLEET_VAR_HOST_UUID"}, {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/BackupID", Data: "${FLEET_VAR_HOST_UUID}"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusNoContent, }, { name: "unknown Fleet variable rejected", profiles: []fleet.MDMProfileBatchPayload{ { Name: "UnknownVar", Contents: syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/SomeValue", Data: "${FLEET_VAR_UNKNOWN_VAR}"}, }), }, }, teamID: &tm.ID, wantStatus: http.StatusUnprocessableEntity, wantErrContains: "Fleet variable $FLEET_VAR_UNKNOWN_VAR is not supported in Windows profiles", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var resp *http.Response // Execute request with or without team_id if tc.teamID != nil { resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: tc.profiles}, tc.wantStatus, "team_id", fmt.Sprint(*tc.teamID)) } else { resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: tc.profiles}, tc.wantStatus) } // Check error message if expected if tc.wantErrContains != "" { errMsg := extractServerErrorText(resp.Body) require.Contains(t, errMsg, tc.wantErrContains) } }) } } func (s *integrationMDMTestSuite) TestWindowsProfilesFleetVariableSubstitution() { t := s.T() ctx := context.Background() // Create a team tm, err := s.ds.NewTeam(ctx, &fleet.Team{Name: t.Name() + "team"}) require.NoError(t, err) // Create and enroll three Windows hosts (two global, one in team) hostGlobal1, device1 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) hostGlobal2, device2 := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) hostTeam, deviceTeam := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) // Add the team host to the team err = s.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(&tm.ID, []uint{hostTeam.ID})) require.NoError(t, err) // Create profiles with HOST_UUID variable for global and team globalProfile := syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/UserSCEP_/SCEP/HostID", Data: "Device ID: $FLEET_VAR_HOST_UUID"}, }) teamProfile := syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/TeamDevice/ID", Data: "Team Device: ${FLEET_VAR_HOST_UUID}"}, }) // Upload global profile s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "GlobalProfileWithVar", Contents: globalProfile}, }}, http.StatusNoContent) // Upload team profile s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "TeamProfileWithVar", Contents: teamProfile}, }}, http.StatusNoContent, "team_id", fmt.Sprint(tm.ID)) // Helper to verify profile contains substituted UUID verifyProfileSubstitution := func(device *mdmtest.TestWindowsMDMClient, expectedUUID string, expectedData string) { s.awaitTriggerProfileSchedule(t) cmds, err := device.StartManagementSession() require.NoError(t, err) // Find the Atomic command containing the profile var foundProfile bool msgID, err := device.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { // Send status response for each command device.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusOK), CmdID: fleet.CmdID{Value: uuid.NewString()}, }) if cmd.Verb == "Atomic" { // Check if the command contains our expected data with UUID substituted for _, replaceCmd := range cmd.Cmd.ReplaceCommands { for _, item := range replaceCmd.Items { if item.Data != nil && item.Data.Content != "" { if strings.Contains(item.Data.Content, expectedData) { // Verify the UUID was substituted correctly require.Contains(t, item.Data.Content, expectedUUID) require.NotContains(t, item.Data.Content, "$FLEET_VAR_HOST_UUID") require.NotContains(t, item.Data.Content, "${FLEET_VAR_HOST_UUID}") foundProfile = true } } } } } } require.True(t, foundProfile, "Expected profile with UUID substitution not found") // Send the response to complete the session cmds, err = device.SendResponse() require.NoError(t, err) // the ack of the message should be the only returned command require.Len(t, cmds, 1) } // Verify global hosts receive profile with their UUID substituted verifyProfileSubstitution(device1, hostGlobal1.UUID, "Device ID: "+hostGlobal1.UUID) verifyProfileSubstitution(device2, hostGlobal2.UUID, "Device ID: "+hostGlobal2.UUID) // Verify team host receives team profile with UUID substituted verifyProfileSubstitution(deviceTeam, hostTeam.UUID, "Team Device: "+hostTeam.UUID) // Check that profile statuses are updated correctly in the database checkHostProfileStatus := func(hostUUID string, profileName string, expectedStatus fleet.MDMDeliveryStatus) { profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, hostUUID) require.NoError(t, err) // Find the specific profile by name var foundProfile *fleet.HostMDMWindowsProfile for _, p := range profiles { if p.Name == profileName { foundProfile = &p break } } require.NotNil(t, foundProfile, "Profile %s not found for host %s", profileName, hostUUID) require.NotNil(t, foundProfile.Status, "Profile %s status is nil for host %s", profileName, hostUUID) assert.Equal(t, expectedStatus, *foundProfile.Status, "Profile %s has unexpected status for host %s", profileName, hostUUID) } checkHostProfileStatus(hostGlobal1.UUID, "GlobalProfileWithVar", fleet.MDMDeliveryVerifying) checkHostProfileStatus(hostGlobal2.UUID, "GlobalProfileWithVar", fleet.MDMDeliveryVerifying) checkHostProfileStatus(hostTeam.UUID, "TeamProfileWithVar", fleet.MDMDeliveryVerifying) // Now let's check profile verification // Also create and test a host without Fleet variables to ensure normal verification still works hostNoVars, deviceNoVars := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) // Create a profile without variables profileNoVars := syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Replace", LocURI: "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/Static/Value", Data: "Static Value: NoSubstitution"}, }) // Upload profile without variables s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "ProfileNoVars", Contents: profileNoVars}, }}, http.StatusNoContent) // Let the host get the profile s.awaitTriggerProfileSchedule(t) cmds, err := deviceNoVars.StartManagementSession() require.NoError(t, err) msgID, err := deviceNoVars.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { deviceNoVars.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusOK), CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } cmds, err = deviceNoVars.SendResponse() require.NoError(t, err) require.Len(t, cmds, 1) // ack checkHostProfileStatus(hostNoVars.UUID, "ProfileNoVars", fleet.MDMDeliveryVerifying) // To ensure any verification failures result in retry (pending) status instead of staying as verifying, // we need to be outside the grace period. The grace period check is: // hostDetailUpdatedAt.Before(profileEarliestInstallDate.Add(1 hour)) // // We need to make sure the host checked in recently (detail_updated_at = now) // but the profiles are old (created more than 1 hour ago) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { // Set profile timestamps to 2 hours ago // IMPORTANT: uploaded_at is used as EarliestInstallDate in verification logic _, err := q.ExecContext(ctx, `UPDATE mdm_windows_configuration_profiles SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR), uploaded_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE name IN (?, ?, ?)`, "GlobalProfileWithVar", "TeamProfileWithVar", "ProfileNoVars") if err != nil { return err } // Also update the host profile associations to have old timestamps (only created_at) _, err = q.ExecContext(ctx, `UPDATE host_mdm_windows_profiles SET created_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE host_uuid IN (?, ?, ?, ?)`, hostGlobal1.UUID, hostGlobal2.UUID, hostTeam.UUID, hostNoVars.UUID) if err != nil { return err } // Set host detail_updated_at to now (recent check-in) _, err = q.ExecContext(ctx, `UPDATE hosts SET detail_updated_at = NOW() WHERE id IN (?, ?, ?, ?)`, hostGlobal1.ID, hostGlobal2.ID, hostTeam.ID, hostNoVars.ID) return err }) // Helper to simulate osquery reporting back profile data simulateOsqueryProfileReport := func(nodeKey string, profileName string, locURI string, reportedData string) { // Build a SyncML response that osquery would send back after reading the profile from Windows cmdRef := microsoft_mdm.HashLocURI(profileName, locURI) var msg fleet.SyncML msg.Xmlns = syncml.SyncCmdNamespace msg.SyncHdr = fleet.SyncHdr{ VerDTD: syncml.SyncMLSupportedVersion, VerProto: syncml.SyncMLVerProto, SessionID: "2", MsgID: "2", } // Add status response (profile was successfully applied) msg.AppendCommand(fleet.MDMRaw, fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, CmdID: fleet.CmdID{Value: uuid.NewString()}, CmdRef: &cmdRef, Data: ptr.String("200"), }) // Add results with the data that osquery read from Windows msg.AppendCommand(fleet.MDMRaw, fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdResults}, CmdID: fleet.CmdID{Value: uuid.NewString()}, CmdRef: &cmdRef, Items: []fleet.CmdItem{ { Target: ptr.String(locURI), Data: &fleet.RawXmlData{ Content: reportedData, }, }, }, }) rawResponse, err := xml.Marshal(msg) require.NoError(t, err) // Submit the results via osquery distributed write endpoint distributedReq := SubmitDistributedQueryResultsRequest{ NodeKey: nodeKey, Results: map[string][]map[string]string{ "fleet_detail_query_mdm_config_profiles_windows": { {"raw_mdm_command_output": string(rawResponse)}, }, }, Statuses: map[string]fleet.OsqueryStatus{ "fleet_detail_query_mdm_config_profiles_windows": 0, }, } distributedResp := submitDistributedQueryResultsResponse{} s.DoJSON("POST", "/api/osquery/distributed/write", distributedReq, http.StatusOK, &distributedResp) } // First verify that normal profile (without variables) verifies correctly simulateOsqueryProfileReport( *hostNoVars.NodeKey, "ProfileNoVars", "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/Static/Value", "Static Value: NoSubstitution", // osquery reports exactly what was sent ) // Normal profile should be verified successfully checkHostProfileStatus(hostNoVars.UUID, "ProfileNoVars", fleet.MDMDeliveryVerified) // Simulate osquery reporting back for team host simulateOsqueryProfileReport( *hostTeam.NodeKey, "TeamProfileWithVar", "./Device/Vendor/MSFT/DMClient/Provider/ProviderID/TeamDevice/ID", "Team Device: "+hostTeam.UUID, // osquery reports the substituted value ) // Team host has TeamProfileWithVar which now correctly verifies with Fleet variables // The fix has been implemented and the profile should be verified successfully checkHostProfileStatus(hostTeam.UUID, "TeamProfileWithVar", fleet.MDMDeliveryVerified) // Hit the host details API and check the status in the mdm.profiles section // Verify team host var hostRespTeam getHostResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", hostTeam.ID), getHostRequest{}, http.StatusOK, &hostRespTeam) require.NotNil(t, hostRespTeam.Host.MDM.Profiles) require.Len(t, *hostRespTeam.Host.MDM.Profiles, 1) require.Equal(t, "TeamProfileWithVar", (*hostRespTeam.Host.MDM.Profiles)[0].Name) require.EqualValues(t, fleet.MDMDeliveryVerified, *(*hostRespTeam.Host.MDM.Profiles)[0].Status, "Profile should be verified in host details API for team host") // Verify no-vars host var hostRespNoVars getHostResponse s.DoJSON("GET", fmt.Sprintf("/api/v1/fleet/hosts/%d", hostNoVars.ID), getHostRequest{}, http.StatusOK, &hostRespNoVars) require.NotNil(t, hostRespNoVars.Host.MDM.Profiles) require.Len(t, *hostRespNoVars.Host.MDM.Profiles, 1) require.Equal(t, "ProfileNoVars", (*hostRespNoVars.Host.MDM.Profiles)[0].Name) require.EqualValues(t, fleet.MDMDeliveryVerified, *(*hostRespNoVars.Host.MDM.Profiles)[0].Status, "Profile should be verified in host details API for no-vars host") } //go:embed testdata/profiles/windows-device-scep.xml var windowsDeviceSCEPProfileBytes []byte func (s *integrationMDMTestSuite) TestWindowsDeviceSCEPProfile() { testWindowsSCEPProfile(s, windowsDeviceSCEPProfileBytes) } //go:embed testdata/profiles/windows-user-scep.xml var windowsUserSCEPProfileBytes []byte func (s *integrationMDMTestSuite) TestWindowsUserSCEPProfile() { testWindowsSCEPProfile(s, windowsUserSCEPProfileBytes) } func testWindowsSCEPProfile(s *integrationMDMTestSuite, windowsScepProfile []byte) { t := s.T() ctx := context.Background() scepServer := scep_server.StartTestSCEPServer(t) scepServerURL := scepServer.URL + "/scep" // Create windows host and enroll in MDM host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) verifyCommands := func(wantProfiles int, status string) { cmds, err := mdmDevice.StartManagementSession() require.NoError(t, err) // profile installs + 2 protocol commands acks require.Len(t, cmds, wantProfiles+2) msgID, err := mdmDevice.GetCurrentMsgID() require.NoError(t, err) atomicCmds := 0 for _, c := range cmds { if c.Verb == "Atomic" { atomicCmds++ } mdmDevice.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: ptr.String(c.Cmd.CmdID.Value), Cmd: ptr.String(c.Verb), Data: ptr.String(status), Items: nil, CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } require.Equal(t, wantProfiles, atomicCmds) cmds, err = mdmDevice.SendResponse() require.NoError(t, err) // the ack of the message should be the only returned command require.Len(t, cmds, 1) } // Upload SCEP profile with missing CA resp := s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "WindowsSCEPProfile", Contents: windowsScepProfile}, }}, http.StatusBadRequest) errMsg := extractServerErrorText(resp.Body) require.Contains(t, errMsg, "Fleet variable $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_INTEGRATION does not exist.") // Create Custom SCEP CA ca := &fleet.CertificateAuthority{ Type: string(fleet.CATypeCustomSCEPProxy), Name: ptr.String("INTEGRATION"), Challenge: ptr.String("integration-test"), URL: ptr.String(scepServerURL), } _, err := s.ds.NewCertificateAuthority(ctx, ca) require.NoError(t, err) // Fail on missing OU resp = s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "WindowsSCEPProfile", Contents: bytes.ReplaceAll(windowsScepProfile, []byte(fleet.FleetVarSCEPRenewalID.WithPrefix()), []byte("BOGUS"))}, }}, http.StatusBadRequest) errMsg = extractServerErrorText(resp.Body) require.Contains(t, errMsg, "SCEP profile for custom SCEP certificate authority requires: $FLEET_VAR_CUSTOM_SCEP_CHALLENGE_, $FLEET_VAR_CUSTOM_SCEP_PROXY_URL_, and $FLEET_VAR_SCEP_RENEWAL_ID variables") s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "WindowsSCEPProfile", Contents: windowsScepProfile}, }}, http.StatusNoContent) // Verify host receives the profile s.awaitTriggerProfileSchedule(t) // Check that profile status is Pending profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) var foundProfile bool for _, p := range profiles { if p.Name == "WindowsSCEPProfile" { foundProfile = true require.NotNil(t, p.Status) assert.EqualValues(t, fleet.MDMDeliveryPending, *p.Status) } } require.True(t, foundProfile, "WindowsSCEPProfile not found for host") verifyCommands(1, syncml.CmdStatusOK) // Verify profile status is Verified due to successful response profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) foundProfile = false for _, p := range profiles { if p.Name == "WindowsSCEPProfile" { foundProfile = true require.NotNil(t, p.Status) assert.EqualValues(t, fleet.MDMDeliveryVerified, *p.Status) } } require.True(t, foundProfile, "WindowsSCEPProfile not found for host") // Report Osquery results indicating SCEP profile was applied successfully s.reportWindowsOSQueryProfiles(ctx, t, host, map[string][]profileData{ "WindowsSCEPProfile": {{"200", "L1", "Bogus"}}, // Report back with SCEP LocURI, but data that does not relate SCEP to support the case that we don't verify the success. }) // Verify profile status is still Verified, and OSQuery does not change it's status. profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) foundProfile = false profileUUID := "" for _, p := range profiles { if p.Name == "WindowsSCEPProfile" { foundProfile = true profileUUID = p.ProfileUUID require.NotNil(t, p.Status) require.EqualValues(t, fleet.MDMDeliveryVerified, *p.Status) } } require.True(t, foundProfile, "WindowsSCEPProfile not found for host") // Attempt simple SCEP call with GetCACaps operation to verify SCEP server is reachable identifier := host.UUID + "," + profileUUID + "," + "INTEGRATION" scepRes := s.DoRawWithHeaders("GET", apple_mdm.SCEPProxyPath+identifier+"/pkiclient.exe", nil, http.StatusOK, nil, "operation", "GetCACaps") body, err := io.ReadAll(scepRes.Body) require.NoError(t, err) assert.Equal(t, scepserver.DefaultCACaps, string(body)) } // This test verifies that there is no longer a race condition in apple profile resending func (s *integrationMDMTestSuite) TestAppleProfileResendRaceCondition() { t := s.T() ctx := context.Background() // Create a host and enroll it in MDM host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) setupPusher(s, t, mdmDevice) scimUserID, err := s.ds.CreateScimUser(ctx, &fleet.ScimUser{UserName: "user@example.com"}) require.NoError(t, err) // Assign scim user to host hostIdStr := fmt.Sprint(host.ID) s.Do("PUT", "/api/latest/fleet/hosts/"+hostIdStr+"/device_mapping", putHostDeviceMappingRequest{ Email: "user@example.com", Source: "idp", }, http.StatusOK) // Create a profile that uses IDP variables profileWithIDPVar := mobileconfigForTestWithContent("TestProfile", "com.test.profile", "com.test.profile.content", "com.test.profile", "Test IDP Variable Profile") // Replace the profile content to include an IDP variable profileContent := string(profileWithIDPVar) profileContent = strings.Replace(profileContent, "ShowRecoveryKey", "TestVariable", 1) profileContent = strings.Replace(profileContent, "", "$FLEET_VAR_HOST_END_USER_IDP_USERNAME", 1) profileWithIDPVar = []byte(profileContent) // Upload the profile using the new endpoint that supports variables s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: "TestProfile", Contents: profileWithIDPVar}, }}, http.StatusNoContent) // Setup SCIM user data for the host // No profiles until reconciler hostProfiles, err := s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) require.Empty(t, hostProfiles, "Host should not have any profiles before sync") // Trigger initial profile sync - profile should be set to pending/installing s.awaitTriggerProfileSchedule(t) // Check that install command was sent, but do not acknowledge it yet cmd, err := mdmDevice.Idle() require.NoError(t, err) seenProfile := false profileCmdID := "" for cmd != nil { if cmd.Command.RequestType != "InstallProfile" { cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) continue } var fullCmd micromdm.CommandPayload err = plist.Unmarshal(cmd.Raw, &fullCmd) require.NoError(t, err) if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") { seenProfile = true profileCmdID = cmd.CommandUUID break } // Acknowledge other commands cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) } require.True(t, seenProfile, "Expected install command for TestProfile not found") // Verify profile is in pending status hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) var testProfile *fleet.HostMDMAppleProfile for _, p := range hostProfiles { if p.Identifier == "com.test.profile" { testProfile = &p break } } require.NotNil(t, testProfile) require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status) // Now simulate the race condition: // we trigger a resend before the acknowledgement comes back // 1. Trigger an IDP variable change by updating SCIM user err = s.ds.ReplaceScimUser(ctx, &fleet.ScimUser{ID: scimUserID, UserName: "newuser@example.com"}) require.NoError(t, err) // 2. At this point, the profile should be marked for resend (status = NULL) hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) for _, p := range hostProfiles { if p.Identifier == "com.test.profile" { fmt.Printf("%v\n", p.Status) testProfile = &p break } } require.NotNil(t, testProfile) require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status) // Should be NULL (pending for the user) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var status *fleet.MDMDeliveryStatus err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier) require.Nil(t, status) return err }) // Acknowledge the original install command now, simulating the device response cmd, err = mdmDevice.Acknowledge(profileCmdID) require.NoError(t, err) // Now check if we see any new TestProfile cmds (we need to ack them here, since we might have skipped some above.) seenProfile = false for cmd != nil { if cmd.Command.RequestType != "InstallProfile" { cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) continue } var fullCmd micromdm.CommandPayload err = plist.Unmarshal(cmd.Raw, &fullCmd) require.NoError(t, err) if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") { seenProfile = true // Acknowledge the resend command cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) } else { // Acknowledge other commands cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) } } require.Nil(t, cmd, "No further commands should be pending after acknowledging install") require.False(t, seenProfile, "No resend install command for TestProfile should be sent due to race condition") // Verify the profile is still in pending status (null in the DB) // aka. no race condition. hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) for _, p := range hostProfiles { if p.Identifier == "com.test.profile" { testProfile = &p break } } require.NotNil(t, testProfile) require.EqualValues(t, fleet.MDMDeliveryPending, *testProfile.Status) mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var status *fleet.MDMDeliveryStatus err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_apple_profiles WHERE profile_identifier = ?`, testProfile.Identifier) require.Nil(t, status) return err }) // run reconciler to resend any pending profiles s.awaitTriggerProfileSchedule(t) cmd, err = mdmDevice.Idle() require.NoError(t, err) // Now we should see the resend command seenProfile = false for cmd != nil { if cmd.Command.RequestType != "InstallProfile" { cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) continue } var fullCmd micromdm.CommandPayload err = plist.Unmarshal(cmd.Raw, &fullCmd) require.NoError(t, err) if strings.Contains(string(fullCmd.Command.InstallProfile.Payload), "TestProfile") { seenProfile = true // Acknowledge the resend command cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) } else { // Acknowledge other commands cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) require.NoError(t, err) } } require.True(t, seenProfile, "Resend install command for TestProfile should be sent after reconciler runs") // And we should now also see the profile being marked as verifying hostProfiles, err = s.ds.GetHostMDMAppleProfiles(ctx, host.UUID) require.NoError(t, err) for _, p := range hostProfiles { if p.Identifier == "com.test.profile" { testProfile = &p break } } require.NotNil(t, testProfile) require.EqualValues(t, fleet.MDMDeliveryVerifying, *testProfile.Status) } func (s *integrationMDMTestSuite) TestWindowsProfileRetry() { t := s.T() ctx := t.Context() // Create a host and enroll it in MDM host, mdmDevice := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) t.Run("Command gets retried with Replace after 418", func(t *testing.T) { profilePayload := syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowLocation", Data: "1"}, }) profileName := "RetryProfile" s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: profileName, Contents: profilePayload}, }}, http.StatusNoContent) expectRetry := func(profileName string, expectedRetries int) { mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var retryCount int err := sqlx.GetContext(t.Context(), q, &retryCount, `SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`, host.UUID, profileName) require.NoError(t, err) require.Equal(t, expectedRetries, retryCount, "Unexpected retry count for profile %s", profileName) return nil }) } // Trigger profile schedule s.awaitTriggerProfileSchedule(t) // Get initial host profile profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) var initialProfile fleet.HostMDMWindowsProfile for _, p := range profiles { if p.Name == profileName { initialProfile = p break } } require.NotNil(t, initialProfile) require.Equal(t, fleet.MDMDeliveryPending, *initialProfile.Status) cmds, err := mdmDevice.StartManagementSession() require.NoError(t, err) msgID, err := mdmDevice.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { if cmd.Verb == "Status" { continue } syncCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusAtomicFailed), CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(syncCmd) for _, addCmd := range cmd.Cmd.AddCommands { for range addCmd.Items { itemCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &addCmd.CmdID.Value, Cmd: ptr.String(fleet.CmdStatus), // 418 triggers Replace resend logic Data: ptr.String(syncml.CmdStatusAlreadyExists), CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(itemCmd) } } } cmds, err = mdmDevice.SendResponse() // we have atomic replace (resend after 418 attempt in this cmd list here) require.NoError(t, err) require.Len(t, cmds, 2) // stsatus + atomic replace // After initial 418 resend: pending, empty detail, retries = 0. profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) var updatedProfile fleet.HostMDMWindowsProfile for _, p := range profiles { if p.Name == profileName { updatedProfile = p break } } require.NotNil(t, updatedProfile) require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status) require.Empty(t, updatedProfile.Detail) expectRetry(profileName, 0) // Second session: fail Atomic to trigger normal retry (status NULL, retries++ -> 1). cmds, err = mdmDevice.StartManagementSession() require.NoError(t, err) msgID, err = mdmDevice.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { if cmd.Verb == "Status" { continue } syncCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusAtomicFailed), CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(syncCmd) } _, err = mdmDevice.SendResponse() require.NoError(t, err) // Verify raw DB status is NULL and retries = 1. mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { var status sql.NullString var retries int err := sqlx.GetContext(t.Context(), q, &status, `SELECT status FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`, host.UUID, profileName) require.NoError(t, err) require.False(t, status.Valid, "status should be NULL") err = sqlx.GetContext(t.Context(), q, &retries, `SELECT retries FROM host_mdm_windows_profiles WHERE host_uuid = ? AND profile_name = ?`, host.UUID, profileName) require.NoError(t, err) require.Equal(t, 1, retries) return nil }) // Third session: Add 418 again to requeue Replace; retries decremented back to 0. s.awaitTriggerProfileSchedule(t) cmds, err = mdmDevice.StartManagementSession() require.NoError(t, err) msgID, err = mdmDevice.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { if cmd.Verb == "Status" { continue } syncCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusAtomicFailed), CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(syncCmd) for _, addCmd := range cmd.Cmd.AddCommands { for range addCmd.Items { mdmDevice.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &addCmd.CmdID.Value, Cmd: ptr.String(fleet.CmdStatus), Data: ptr.String(syncml.CmdStatusAlreadyExists), // 418 CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } } } newCmds, err := mdmDevice.SendResponse() require.NoError(t, err) require.Len(t, newCmds, 2) // status + atomic replace // Pending and retries back to 0. profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) for _, p := range profiles { if p.Name == profileName { updatedProfile = p break } } require.NotNil(t, updatedProfile) require.Equal(t, fleet.MDMDeliveryPending, *updatedProfile.Status) require.Empty(t, updatedProfile.Detail) expectRetry(profileName, 1) // Fourth session: Replace succeeds (Atomic OK + item 200) → verifying. s.awaitTriggerProfileSchedule(t) cmds, err = mdmDevice.StartManagementSession() require.NoError(t, err) msgID, err = mdmDevice.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { syncCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusOK), CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(syncCmd) for _, repCmd := range cmd.Cmd.ReplaceCommands { for _, item := range repCmd.Items { itemCmdRef := microsoft_mdm.HashLocURI(profileName, *item.Target) mdmDevice.AppendResponse(fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &itemCmdRef, Cmd: ptr.String(fleet.CmdStatus), Data: ptr.String(syncml.CmdStatusOK), // 200 CmdID: fleet.CmdID{Value: uuid.NewString()}, }) } } } _, err = mdmDevice.SendResponse() require.NoError(t, err) profiles, err = s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) for _, p := range profiles { if p.Name == profileName { updatedProfile = p break } } require.NotNil(t, updatedProfile) require.Equal(t, fleet.MDMDeliveryVerifying, *updatedProfile.Status) require.Empty(t, updatedProfile.Detail) expectRetry(profileName, 1) }) t.Run("No resend on non-retryable error", func(t *testing.T) { profilePayload2 := syncml.ForTestWithData([]syncml.TestCommand{ {Verb: "Add", LocURI: "./Device/Vendor/MSFT/Policy/Config/System/AllowCamera", Data: "1"}, }) profileName2 := "NonRetryProfile" s.Do("POST", "/api/v1/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{Profiles: []fleet.MDMProfileBatchPayload{ {Name: profileName2, Contents: profilePayload2}, }}, http.StatusNoContent) // Trigger profile schedule s.awaitTriggerProfileSchedule(t) // Get initial host profile profiles, err := s.ds.GetHostMDMWindowsProfiles(ctx, host.UUID) require.NoError(t, err) var initialProfile2 fleet.HostMDMWindowsProfile for _, p := range profiles { if p.Name == profileName2 { initialProfile2 = p break } } require.NotNil(t, initialProfile2) require.Equal(t, fleet.MDMDeliveryPending, *initialProfile2.Status) cmds, err := mdmDevice.StartManagementSession() require.NoError(t, err) msgID, err := mdmDevice.GetCurrentMsgID() require.NoError(t, err) for _, cmd := range cmds { if cmd.Verb == "Status" { continue } syncCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &cmd.Cmd.CmdID.Value, Cmd: ptr.String(cmd.Verb), Data: ptr.String(syncml.CmdStatusAtomicFailed), // Generic failure, should not retry CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(syncCmd) for _, addCmd := range cmd.Cmd.AddCommands { for range addCmd.Items { itemCmd := fleet.SyncMLCmd{ XMLName: xml.Name{Local: fleet.CmdStatus}, MsgRef: &msgID, CmdRef: &addCmd.CmdID.Value, Cmd: ptr.String(fleet.CmdStatus), // 500 generic failure Data: ptr.String(syncml.CmdStatusBadRequest), CmdID: fleet.CmdID{Value: uuid.NewString()}, } mdmDevice.AppendResponse(itemCmd) } } } cmds, err = mdmDevice.SendResponse() require.NoError(t, err) require.Len(t, cmds, 1) // only ack returned }) }