fleet/server/service/integration_mdm_lifecycle_test.go
Magnus Jensen a8c9e261d7
speed up macOS profile delivery for initial enrollments (#41960)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #34433 

It speeds up the cron, meaning fleetd, bootstrap and now profiles should
be sent within 10 seconds of being known to fleet, compared to the
previous 1 minute.

It's heavily based on my last PR, so the structure and changes are close
to identical, with some small differences.
**I did not do the redis key part in this PR, as I think that should
come in it's own PR, to avoid overlooking logic bugs with that code, and
since this one is already quite sized since we're moving core pieces of
code around.**

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.


## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Faster macOS onboarding: device profiles are delivered and installed
as part of DEP enrollment, shortening initial setup.
* Improved profile handling: per-host profile preprocessing, secret
detection, and clearer failure marking.

* **Improvements**
  * Consolidated SCEP/NDES error messaging for clearer diagnostics.
  * Cron/work scheduling tuned to prioritize Apple MDM profile delivery.

* **Tests**
* Expanded MDM unit and integration tests, including
DeclarativeManagement handling.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-03-19 14:58:10 -05:00

1544 lines
52 KiB
Go

package service
import (
"context"
"crypto/x509"
"database/sql"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/pkg/fleetdbase"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"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"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/ptr"
"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"
)
// NOTE: the mantra for lifecycle events is:
// - Noah: When MDM is turned on, install fleetd, bootstrap package (if DEP),
// and profiles. Don't clear host vitals (everything you see on the Host
// details page)
// - Noah: On re-enrollment, don't clear host vitals.
// - Noah: On lock and wipe, don't clear host vitals.
// - Noah: On delete, clear host vitals.
// NOTE: ADE lifecycle events are part of the integration_mdm_dep_test.go file
type mdmLifecycleAssertion[T any] func(t *testing.T, host *fleet.Host, device T)
func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsApple() {
t := s.T()
s.setSkipWorkerJobs(t)
s.setupLifecycleSettings()
testCases := []struct {
Name string
Action mdmLifecycleAssertion[*mdmtest.TestAppleMDMClient]
}{
{
"wiped host turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
s.Do(
"POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID),
nil,
http.StatusOK,
)
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.NoError(t, device.Enroll())
},
},
{
"locked host turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
s.Do(
"POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID),
nil,
http.StatusOK,
)
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.NoError(t, device.Enroll())
},
},
{
"host turns on MDM features out of the blue",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
require.NoError(t, device.Enroll())
},
},
{
"IT admin turns off MDM for a host via the UI then host turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
originalPushMock := s.pushProvider.PushFunc
defer func() { s.pushProvider.PushFunc = originalPushMock }()
s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
res, err := mockSuccessfulPush(ctx, pushes)
require.NoError(t, err)
err = device.Checkout()
require.NoError(t, err)
return res, err
}
s.Do(
"DELETE",
fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID),
nil,
http.StatusNoContent,
)
require.NoError(t, device.Enroll())
},
},
{
"host is deleted then turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
var delResp deleteHostResponse
s.DoJSON(
"DELETE",
fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID),
nil,
http.StatusOK,
&delResp,
)
dupeClient := mdmtest.NewTestMDMClientAppleDirect(
mdmtest.AppleEnrollInfo{
SCEPChallenge: s.scepChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
}, "MacBookPro16,1")
dupeClient.UUID = device.UUID
dupeClient.SerialNumber = device.SerialNumber
dupeClient.Model = device.Model
require.NoError(t, dupeClient.Enroll())
*device = *dupeClient
},
},
{
"host is deleted in bulk then turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
req := deleteHostsRequest{
IDs: []uint{host.ID},
}
resp := deleteHostsResponse{}
s.DoJSON("POST", "/api/latest/fleet/hosts/delete", req, http.StatusOK, &resp)
dupeClient := mdmtest.NewTestMDMClientAppleDirect(
mdmtest.AppleEnrollInfo{
SCEPChallenge: s.scepChallenge,
SCEPURL: s.server.URL + apple_mdm.SCEPPath,
MDMURL: s.server.URL + apple_mdm.MDMPath,
}, "MacBookPro16,1")
dupeClient.UUID = device.UUID
dupeClient.SerialNumber = device.SerialNumber
dupeClient.Model = device.Model
require.NoError(t, dupeClient.Enroll())
*device = *dupeClient
},
},
{
"host is deleted then osquery enrolls then turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient) {
var delResp deleteHostResponse
s.DoJSON(
"DELETE",
fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID),
nil,
http.StatusOK,
&delResp,
)
var err error
host.OsqueryHostID = ptr.String(t.Name())
host, err = s.ds.NewHost(context.Background(), host)
require.NoError(t, err)
setOrbitEnrollment(t, host, s.ds)
deviceToken := uuid.NewString()
err = s.ds.SetOrUpdateDeviceAuthToken(context.Background(), host.ID, deviceToken)
require.NoError(t, err)
device.SetDesktopToken(deviceToken)
require.NoError(t, device.Enroll())
},
},
}
assertAction := func(t *testing.T, host *fleet.Host, device *mdmtest.TestAppleMDMClient, action mdmLifecycleAssertion[*mdmtest.TestAppleMDMClient]) {
fCmds, fSumm, fHostMDM := s.recordAppleHostStatus(host, device)
action(t, host, device)
// reload the host by identifier, tests might
// delete hosts and create new records with different IDs
var err error
host, err = s.ds.HostByIdentifier(context.Background(), host.UUID)
require.NoError(t, err)
sCmds, sSumm, sHostMDM := s.recordAppleHostStatus(host, device)
// post asssertions
require.ElementsMatch(t, fCmds, sCmds)
require.Equal(t, fSumm, sSumm)
require.Equal(t, fHostMDM, sHostMDM)
}
for _, tt := range testCases {
t.Run(tt.Name, func(t *testing.T) {
t.Run("manual enrollment", func(t *testing.T) {
host, device := createHostThenEnrollMDM(s.ds, s.server.URL, t)
assertAction(t, host, device, tt.Action)
})
t.Run("automatic enrollment", func(t *testing.T) {
device := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, "")
s.enableABM(t.Name())
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: "abc"})
require.NoError(t, err)
case "/profile/devices":
err := encoder.Encode(godep.ProfileResponse{
ProfileUUID: "abc",
Devices: map[string]string{},
})
require.NoError(t, err)
case "/server/devices", "/devices/sync":
err := encoder.Encode(godep.DeviceResponse{
Devices: []godep.Device{
{
SerialNumber: device.SerialNumber,
Model: device.Model,
OS: "osx",
OpType: "added",
},
},
})
require.NoError(t, err)
}
}))
s.runDEPSchedule()
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
device.SetDEPToken(depURLToken)
var err error
host, err := s.ds.HostByIdentifier(context.Background(), device.SerialNumber)
require.NoError(t, err)
require.NoError(t, device.Enroll())
assertAction(t, host, device, tt.Action)
})
})
}
}
func (s *integrationMDMTestSuite) TestTurnOnLifecycleEventsWindows() {
t := s.T()
s.setupLifecycleSettings()
testCases := []struct {
Name string
Action mdmLifecycleAssertion[*mdmtest.TestWindowsMDMClient]
}{
{
"wiped host turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) {
s.Do(
"POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID),
json.RawMessage(`{ "windows": {"wipe_type": "doWipe"}}`),
http.StatusOK,
)
status, err := s.ds.GetHostLockWipeStatus(context.Background(), host)
require.NoError(t, err)
cmds, err := device.StartManagementSession()
require.NoError(t, err)
// two status + the wipe command we enqueued
require.Len(t, cmds, 3)
wipeCmd := cmds[status.WipeMDMCommand.CommandUUID]
require.NotNil(t, wipeCmd)
require.Equal(t, wipeCmd.Verb, fleet.CmdExec)
require.Len(t, wipeCmd.Cmd.Items, 1)
require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipe", *wipeCmd.Cmd.Items[0].Target)
msgID, err := device.GetCurrentMsgID()
require.NoError(t, err)
device.AppendResponse(fleet.SyncMLCmd{
XMLName: xml.Name{Local: fleet.CmdStatus},
MsgRef: &msgID,
CmdRef: &status.WipeMDMCommand.CommandUUID,
Cmd: ptr.String("Exec"),
Data: ptr.String("200"),
Items: nil,
CmdID: fleet.CmdID{Value: uuid.NewString()},
})
cmds, err = device.SendResponse()
require.NoError(t, err)
// the ack of the message should be the only returned command
require.Len(t, cmds, 1)
// Simulate the host having fleetd installed and reporting back in as un-enrolled
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `
UPDATE host_mdm
SET enrolled = 0, server_url = ''
WHERE host_id = ?
`, host.ID)
return err
})
// re-enroll
require.NoError(t, device.Enroll())
},
},
{
"locked host turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) {
s.Do(
"POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID),
nil,
http.StatusOK,
)
status, err := s.ds.GetHostLockWipeStatus(context.Background(), host)
require.NoError(t, err)
var orbitScriptResp orbitPostScriptResultResponse
s.DoJSON(
"POST",
"/api/fleet/orbit/scripts/result",
json.RawMessage(
fmt.Sprintf(
`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`,
*host.OrbitNodeKey,
status.LockScript.ExecutionID,
),
),
http.StatusOK,
&orbitScriptResp,
)
// Simulate the host having fleetd installed after being wiped and reporting back in as un-enrolled
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `
UPDATE host_mdm
SET enrolled = 0, server_url = ''
WHERE host_id = ?
`, host.ID)
return err
})
require.NoError(t, device.Enroll())
},
},
{
"host turns on MDM features out of the blue",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) {
if strings.Contains(t.Name(), "automatic") {
require.NoError(t, device.Enroll())
} else {
// A programatically-enrolled host that randomly turns on MDM after already enabled will get a SOAP fault
require.Error(t, device.Enroll())
}
},
},
{
"host is deleted then osquery enrolls then turns on MDM",
func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient) {
var delResp deleteHostResponse
s.DoJSON(
"DELETE",
fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID),
nil,
http.StatusOK,
&delResp,
)
var err error
host.OsqueryHostID = ptr.String(t.Name())
host, err = s.ds.NewHost(context.Background(), host)
require.NoError(t, err)
orbitKey := setOrbitEnrollment(t, host, s.ds)
host.OrbitNodeKey = &orbitKey
if !strings.Contains(device.TokenIdentifier, "@") {
device.TokenIdentifier = orbitKey
}
device.HardwareID = host.UUID
device.DeviceID = host.UUID
require.NoError(t, device.Enroll())
},
},
}
assertAction := func(t *testing.T, host *fleet.Host, device *mdmtest.TestWindowsMDMClient, action mdmLifecycleAssertion[*mdmtest.TestWindowsMDMClient]) {
fCmds, fSumm, fHostMDM := s.recordWindowsHostStatus(host, device)
action(t, host, device)
// reload the host by identifier, tests might
// delete hosts and create new records with different IDs
var err error
host, err = s.ds.HostByIdentifier(context.Background(), host.UUID)
require.NoError(t, err)
sCmds, sSumm, sHostMDM := s.recordWindowsHostStatus(host, device)
// post asssertions
require.Len(t, sCmds, len(fCmds))
require.ElementsMatch(t, fCmds, sCmds)
require.Equal(t, fSumm, sSumm)
require.Equal(t, fHostMDM, sHostMDM)
}
for _, tt := range testCases {
t.Run(tt.Name, func(t *testing.T) {
t.Run("programmatic enrollment", func(t *testing.T) {
host, device := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t)
err := s.ds.SetOrUpdateMDMData(context.Background(), host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "", false)
require.NoError(t, err)
assertAction(t, host, device, tt.Action)
})
t.Run("automatic enrollment", func(t *testing.T) {
if strings.Contains(tt.Name, "wipe") {
t.Skip("wipe tests are not supported for windows automatic enrollment until we fix #TODO")
}
tenantID := uuid.New().String()
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{ "mdm": { "windows_entra_tenant_ids": ["`+tenantID+`"] } }`), http.StatusOK, &acResp)
err := s.ds.ApplyEnrollSecrets(context.Background(), nil, []*fleet.EnrollSecret{{Secret: t.Name()}})
require.NoError(t, err)
host := createOrbitEnrolledHost(t, "windows", "windows_automatic", s.ds)
azureMail := "foo.bar.baz@example.com"
device := mdmtest.NewTestMDMClientWindowsAutomatic(s.server.URL, azureMail, mdmtest.TestWindowsMDMClientWithSigningKeyAndTenantID(s.jwtSigningKey, defaultFakeJWTKeyID, tenantID))
device.HardwareID = host.UUID
device.DeviceID = host.UUID
require.NoError(t, device.Enroll())
err = s.ds.SetOrUpdateMDMData(context.Background(), host.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "", false)
require.NoError(t, err)
assertAction(t, host, device, tt.Action)
})
})
}
}
// Hardcode response type because we are using a custom json marshaling so
// using getHostMDMResponse fails with "JSON unmarshaling is not supported for HostMDM".
type jsonMDM struct {
EnrollmentStatus string `json:"enrollment_status"`
ServerURL string `json:"server_url"`
Name string `json:"name,omitempty"`
ID *uint `json:"id,omitempty"`
}
type getHostMDMResponseTest struct {
HostMDM *jsonMDM
Err error `json:"error,omitempty"`
}
func (s *integrationMDMTestSuite) recordWindowsHostStatus(
host *fleet.Host,
device *mdmtest.TestWindowsMDMClient,
) ([]fleet.ProtoCmdOperation, getHostMDMSummaryResponse, getHostMDMResponseTest) {
t := s.T()
var recordedCmds []fleet.ProtoCmdOperation
cmds, err := device.StartManagementSession()
require.NoError(t, err)
msgID, err := device.GetCurrentMsgID()
require.NoError(t, err)
for _, c := range cmds {
cmdID := c.Cmd.CmdID
status := syncml.CmdStatusOK
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()},
})
c.Cmd.CmdID.Value = ""
c.Cmd.CmdRef = nil
recordedCmds = append(recordedCmds, c)
}
_, err = device.SendResponse()
require.NoError(t, err)
mdmAgg := getHostMDMSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/summary/mdm", nil, http.StatusOK, &mdmAgg)
ghr := getHostMDMResponseTest{}
s.DoJSON(
"GET",
fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID),
nil,
http.StatusOK,
&ghr,
)
return recordedCmds, mdmAgg, ghr
}
func (s *integrationMDMTestSuite) recordAppleHostStatus(
host *fleet.Host,
device *mdmtest.TestAppleMDMClient,
) ([]*micromdm.CommandPayload, getHostMDMSummaryResponse, getHostMDMResponseTest) {
t := s.T()
s.runWorkerUntilDone()
s.awaitTriggerProfileSchedule(t)
var cmds []*micromdm.CommandPayload
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
// command uuid is a random value, we only care that's set
require.NotEmpty(t, cmd.CommandUUID)
// strip the signature of the profiles so they can be easily compared
if cmd.Command.RequestType == "InstallProfile" {
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
fullCmd.CommandUUID = ""
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
require.NoError(t, err)
fullCmd.Command.InstallProfile.Payload = p7.Content
}
cmds = append(cmds, &fullCmd)
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
mdmAgg := getHostMDMSummaryResponse{}
s.DoJSON("GET", "/api/latest/fleet/hosts/summary/mdm", nil, http.StatusOK, &mdmAgg)
ghr := getHostMDMResponseTest{}
s.DoJSON(
"GET",
fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID),
nil,
http.StatusOK,
&ghr,
)
return cmds, mdmAgg, ghr
}
func (s *integrationMDMTestSuite) setupLifecycleSettings() {
t := s.T()
ctx := context.Background()
// add bootstrap package
_ = s.ds.DeleteMDMAppleBootstrapPackage(ctx, 0)
bp, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
require.NoError(t, err)
s.uploadBootstrapPackage(
&fleet.MDMAppleBootstrapPackage{Bytes: bp, Name: "pkg.pkg", TeamID: 0},
http.StatusOK,
"",
false,
)
// enable disk encryption
acResp := appConfigResponse{}
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
"mdm": { "macos_settings": {"enable_disk_encryption": true} }
}`), http.StatusOK, &acResp)
require.True(t, acResp.MDM.EnableDiskEncryption.Value)
// add profiles (windows, mac)
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: "N3", Contents: declarationForTest("D1")},
}},
http.StatusNoContent,
)
}
// Host is renewing SCEP certificates
func (s *integrationMDMTestSuite) TestLifecycleSCEPCertExpiration() {
t := s.T()
ctx := context.Background()
s.setSkipWorkerJobs(t)
// helper functions
getEnrollRef := func(hostUUID string) string {
var foundRef string
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &foundRef, `
SELECT fleet_enroll_ref FROM host_mdm
WHERE host_id = (SELECT id FROM hosts WHERE uuid = ?)
`, hostUUID)
})
return foundRef
}
existsRefetchCmd := func(hostUUID string) bool {
var foundRefetchCmd bool
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &foundRefetchCmd, `
SELECT 1 FROM host_mdm_commands
WHERE host_id = (SELECT id FROM hosts WHERE uuid = ?)
AND command_type = 'REFETCH-DEVICE-'
`, hostUUID)
})
return foundRefetchCmd
}
getRenewCmdUUID := func(hostUUID string) sql.NullString {
var renewCmdUUID sql.NullString
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &renewCmdUUID, `
SELECT renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?
`, hostUUID)
})
return renewCmdUUID
}
reportResultsRefetchCmds := func(device *mdmtest.TestAppleMDMClient) {
var gotRefetchCmd *micromdm.CommandPayload
gotCmdTypes := []string{}
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
gotCmdTypes = append(gotCmdTypes, cmd.Command.RequestType)
switch fullCmd.Command.RequestType {
case "DeviceInformation":
gotRefetchCmd = &fullCmd
// respond with some basic info
cmd, err = device.AcknowledgeDeviceInformation(device.UUID, cmd.CommandUUID, "Test iPad", "iPad Pro", "America/Los_Angeles")
require.NoError(t, err)
continue
case "InstalledApplicationList":
// respond with empty list
cmd, err = device.AcknowledgeInstalledApplicationList(device.UUID, cmd.CommandUUID, []fleet.Software{})
require.NoError(t, err)
continue
case "CertificateList":
// respond with empty list
cmd, err = device.AcknowledgeCertificateList(device.UUID, cmd.CommandUUID, []*x509.Certificate{})
require.NoError(t, err)
continue
default:
t.Fatalf("unexpected command: %s", fullCmd.Command.RequestType)
}
}
require.Len(t, gotCmdTypes, 3) // expect DeviceInformation, InstalledApplicationList, CertificateList
require.Contains(t, gotCmdTypes, "DeviceInformation")
require.NotNil(t, gotRefetchCmd)
}
// grab global enroll secrets for later
enrollSecrets, err := s.ds.GetEnrollSecrets(ctx, nil)
require.NoError(t, err)
require.NotEmpty(t, enrollSecrets)
// ensure there's a token for automatic enrollments
s.enableABM(t.Name())
// for our tests, we'll crete two ABM devices and some manual ones
devices := []godep.Device{
{SerialNumber: "serial-1", Model: "MacBook Pro", OS: "osx", OpType: "added"},
{SerialNumber: "serial-2", Model: "MacBook Pro", OS: "osx", OpType: "added"},
}
s.mockDEPResponse(t.Name(), http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
switch r.URL.Path {
case "/session":
err := encoder.Encode(map[string]string{"auth_session_token": "xyz"})
require.NoError(t, err)
case "/profile":
err := encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()})
require.NoError(t, err)
case "/server/devices":
// This endpoint is used to get an initial list of
// devices, return a single device
err := encoder.Encode(godep.DeviceResponse{Devices: devices})
require.NoError(t, err)
case "/devices/sync":
// This endpoint is polled over time to sync devices from
// ABM, send a repeated serial and a new one
err := encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"})
require.NoError(t, err)
case "/profile/devices":
b, err := io.ReadAll(r.Body)
require.NoError(t, err)
var prof profileAssignmentReq
require.NoError(t, json.Unmarshal(b, &prof))
var resp godep.ProfileResponse
resp.ProfileUUID = prof.ProfileUUID
resp.Devices = make(map[string]string, len(prof.Devices))
for _, device := range prof.Devices {
resp.Devices[device] = string(fleet.DEPAssignProfileResponseSuccess)
}
err = encoder.Encode(resp)
require.NoError(t, err)
default:
_, _ = w.Write([]byte(`{}`))
}
}))
s.runDEPSchedule()
// pending DEP hosts now exist
hostsBySerial := make(map[string]fleet.Host)
for _, device := range devices {
host, err := s.ds.HostByIdentifier(context.Background(), device.SerialNumber)
require.NoError(t, err)
require.NotNil(t, host)
hostsBySerial[device.SerialNumber] = *host
}
// add a valid bootstrap package
b, err := os.ReadFile(filepath.Join("testdata", "bootstrap-packages", "signed.pkg"))
require.NoError(t, err)
signedPkg := b
s.uploadBootstrapPackage(&fleet.MDMAppleBootstrapPackage{Bytes: signedPkg, Name: "bs.pkg", TeamID: 0}, http.StatusOK, "", false)
// add a device that's manually enrolled
desktopToken := uuid.New().String()
manualHost := createOrbitEnrolledHost(t, "darwin", "h1", s.ds)
err = s.ds.SetOrUpdateDeviceAuthToken(context.Background(), manualHost.ID, desktopToken)
require.NoError(t, err)
manualEnrolledDevice := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, desktopToken)
manualEnrolledDevice.UUID = manualHost.UUID
manualEnrolledDevice.SerialNumber = manualHost.HardwareSerial
err = manualEnrolledDevice.Enroll()
require.NoError(t, err)
// add devices that are automatically enrolled
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
automaticEnrolledDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
automaticEnrolledDevice.SerialNumber = devices[0].SerialNumber
require.NoError(t, automaticEnrolledDevice.Enroll())
// add a device that's automatically enrolled with a server ref
automaticEnrolledDeviceWithRef := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
automaticEnrolledDeviceWithRef.SerialNumber = devices[1].SerialNumber
require.NoError(t, automaticEnrolledDeviceWithRef.Enroll())
require.NoError(
t,
s.ds.SetOrUpdateMDMData(
ctx,
hostsBySerial[devices[1].SerialNumber].ID,
false,
true,
s.server.URL,
true,
fleet.WellKnownMDMFleet,
"foo",
false,
),
)
require.NoError(t, err)
// add a device that was migrated from a third party mdm via
// "touchless" migration
migratedHost := createOrbitEnrolledHost(t, "darwin", "h4", s.ds)
migratedDevice := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, desktopToken)
migratedDevice.UUID = migratedHost.UUID
migratedDevice.SerialNumber = migratedHost.HardwareSerial
err = migratedDevice.Enroll()
require.NoError(t, err)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE nano_enrollments
SET enrolled_from_migration = 1
WHERE id = ?
`, migratedDevice.UUID)
return err
})
// Add an account driven user enrollment device
iPhoneHwModel := "iPhone14,2"
iphoneUser := &fleet.MDMIdPAccount{
Email: "iphone_user@example.com",
Fullname: "iPhone User",
Username: "iphone_user@example.com",
}
err = s.ds.InsertMDMIdPAccount(ctx, iphoneUser)
require.NoError(t, err)
iphoneUser, err = s.ds.GetMDMIdPAccountByEmail(ctx, iphoneUser.Email)
require.NoError(t, err)
require.NotNil(t, iphoneUser)
require.Equal(t, iphoneUser.Email, "iphone_user@example.com")
iPhoneMdmDevice := mdmtest.NewTestMDMClientAppleAccountDrivenUserEnrollment(
s.server.URL,
iPhoneHwModel,
iphoneUser.UUID,
)
require.NoError(t, iPhoneMdmDevice.Enroll())
assert.Equal(t, iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID, iphoneUser.Email)
// add global profiles
globalProfiles := [][]byte{
mobileconfigForTest("N1", "I1"),
mobileconfigForTest("N2", "I2"),
}
s.Do(
"POST",
"/api/v1/fleet/mdm/apple/profiles/batch",
batchSetMDMAppleProfilesRequest{Profiles: globalProfiles},
http.StatusNoContent,
)
expectedProfiles := 4 // Fleetd configuration, Fleet root cert, N1, N2
s.runWorker()
s.awaitTriggerProfileSchedule(t)
ackAllCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, wantFleetdInstall, wantBootstrapInstall bool) int {
var count int
var foundFleetdInstall, foundBootstrapInstall bool
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
if cmd.Command.RequestType == "DeclarativeManagement" {
// skip declarative management commands
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
continue
}
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
count++
switch cmd.Command.RequestType {
case "InstallEnterpriseApplication":
if murl := fullCmd.Command.InstallEnterpriseApplication.ManifestURL; murl != nil {
require.Contains(t, *murl, fleetdbase.GetPKGManifestURL())
foundFleetdInstall = true
} else {
manifest := fullCmd.Command.InstallEnterpriseApplication.Manifest
require.NotNil(t, manifest)
require.Len(t, manifest.ManifestItems, 1)
require.Len(t, manifest.ManifestItems[0].Assets, 1)
require.Contains(t, manifest.ManifestItems[0].Assets[0].URL, "fleet/mdm/bootstrap")
foundBootstrapInstall = true
}
case "InstallProfile":
// ok
default:
t.Errorf("unexpected command: %s", cmd.Command.RequestType)
}
cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.Equal(t, wantFleetdInstall, foundFleetdInstall)
require.Equal(t, wantBootstrapInstall, foundBootstrapInstall)
return count
}
// ack all commands to install profiles
require.Equal(t, expectedProfiles+1, ackAllCommands(manualEnrolledDevice, true, false))
require.Equal(t, expectedProfiles+2, ackAllCommands(automaticEnrolledDevice, true, true))
require.Equal(t, expectedProfiles+2, ackAllCommands(automaticEnrolledDeviceWithRef, true, true))
require.Equal(t, expectedProfiles+1, ackAllCommands(migratedDevice, true, false))
require.Equal(t, expectedProfiles-1, ackAllCommands(iPhoneMdmDevice, false, false)) // one less profile because no iOS means no fleetd
// simulate a device with two certificates by re-enrolling one of them
err = manualEnrolledDevice.Enroll()
require.NoError(t, err)
s.runWorker()
s.awaitTriggerProfileSchedule(t)
require.Equal(t, expectedProfiles+1, ackAllCommands(manualEnrolledDevice, true, false)) // re-enrolled device gets the same commands as before
require.Equal(t, 0, ackAllCommands(automaticEnrolledDevice, false, false))
require.Equal(t, 0, ackAllCommands(automaticEnrolledDeviceWithRef, false, false))
require.Equal(t, 0, ackAllCommands(migratedDevice, false, false))
cert, key, err := generateCertWithAPNsTopic()
require.NoError(t, err)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, cert, key, "")
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// run without expired certs, no command enqueued
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
cmd, err := manualEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDeviceWithRef.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = migratedDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = iPhoneMdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
expireCerts := func() {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE nano_cert_auth_associations
SET cert_not_valid_after = DATE_SUB(CURDATE(), INTERVAL 1 YEAR)
WHERE id IN (?, ?, ?, ?, ?)
`, manualHost.UUID, automaticEnrolledDevice.UUID, automaticEnrolledDeviceWithRef.UUID, migratedDevice.UUID, iPhoneMdmDevice.EnrollmentID())
return err
})
}
// expire all the certs we just created
expireCerts()
// generate a new config here so we can manipulate the certs.
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
checkRenewCertCommand := func(device *mdmtest.TestAppleMDMClient, enrollRef string, wantProfile string, wantManagedAppleID string) {
var renewCmd *mdm.Command
cmd, err := device.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
renewCmd = cmd
require.NotNil(t, renewCmd)
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(renewCmd.Raw, &fullCmd))
if wantProfile == "" {
s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef, wantManagedAppleID)
} else {
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
require.NoError(t, err)
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))
require.NoError(t, p7.VerifyWithChain(rootCA))
require.Equal(t, wantProfile, string(p7.Content))
}
// for testing convenience, we'll acknowledge the command right away, but in practice the
// device completes the enroll steps (SCEP, Autheniticate, TokenUpdate) before it sends
// the Acknowledge for the enrollment profile command
cmd, err = device.Acknowledge(renewCmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd)
}
checkRenewCertCommand(manualEnrolledDevice, "", "", "")
checkRenewCertCommand(automaticEnrolledDevice, "", "", "")
checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo", "", "")
checkRenewCertCommand(iPhoneMdmDevice, "", "", iphoneUser.Email)
// migrated device doesn't receive any commands because
// `FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE` is not set
cmd, err = migratedDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// set the env var, and run the cron
t.Setenv("FLEET_SILENT_MIGRATION_ENROLLMENT_PROFILE", base64.StdEncoding.EncodeToString([]byte("<foo></foo>")))
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
checkRenewCertCommand(migratedDevice, "", "<foo></foo>", "")
// another cron run shouldn't enqueue more commands
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
cmd, err = manualEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDeviceWithRef.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = migratedDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = iPhoneMdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// devices renew their SCEP cert by re-enrolling.
require.NoError(t, manualEnrolledDevice.Reenroll())
require.NoError(t, automaticEnrolledDevice.Reenroll())
require.NoError(t, automaticEnrolledDeviceWithRef.Reenroll())
require.NoError(t, migratedDevice.Reenroll())
require.NoError(t, iPhoneMdmDevice.Reenroll())
// no new commands are enqueued right after enrollment
cmd, err = manualEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDeviceWithRef.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = migratedDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = iPhoneMdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// run crons again, no new commands are enqueued
s.runWorker()
s.awaitTriggerProfileSchedule(t)
require.Equal(t, 0, ackAllCommands(manualEnrolledDevice, false, false))
require.Equal(t, 0, ackAllCommands(automaticEnrolledDevice, false, false))
require.Equal(t, 0, ackAllCommands(automaticEnrolledDeviceWithRef, false, false))
require.Equal(t, 0, ackAllCommands(migratedDevice, false, false))
require.Equal(t, 0, ackAllCommands(iPhoneMdmDevice, false, false))
// handle the case of a host being deleted, see https://github.com/fleetdm/fleet/issues/19149
expireCerts()
req := deleteHostsRequest{
IDs: []uint{manualHost.ID},
}
resp := deleteHostsResponse{}
s.DoJSON("POST", "/api/latest/fleet/hosts/delete", req, http.StatusOK, &resp)
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
checkRenewCertCommand(automaticEnrolledDevice, "", "", "")
checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo", "", "")
checkRenewCertCommand(iPhoneMdmDevice, "", "", iphoneUser.Email)
// migrated device is still marked as migrated
var stillMigrated bool
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &stillMigrated, `
SELECT enrolled_from_migration FROM nano_enrollments
WHERE id = ?
`, migratedDevice.UUID)
})
require.True(t, stillMigrated)
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// iDevice enrolled with legacy enroll reference #37880
iPadMdmDevice := mdmtest.NewTestMDMClientAppleOTA(s.server.URL, enrollSecrets[0].Secret, "iPad8,1", mdmtest.WithLegacyIDeviceEnrollRef("some-legacy-ref"))
require.NoError(t, iPadMdmDevice.Enroll())
s.runWorker()
s.awaitTriggerProfileSchedule(t)
require.Equal(t, expectedProfiles-1, ackAllCommands(iPadMdmDevice, false, false))
// enroll reference wasn't captured for iDevices during enrollment
require.Empty(t, getEnrollRef(iPadMdmDevice.UUID))
// enqueue refetch commands and report results
require.NoError(t, apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger, func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
}))
require.True(t, existsRefetchCmd(iPadMdmDevice.UUID))
reportResultsRefetchCmds(iPadMdmDevice)
// verify that the enroll reference is now stored in the db
require.Equal(t, "some-legacy-ref", getEnrollRef(iPadMdmDevice.UUID))
// expire cert
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE nano_cert_auth_associations
SET cert_not_valid_after = DATE_SUB(CURDATE(), INTERVAL 1 YEAR)
WHERE id = ?
`, iPadMdmDevice.UUID)
return err
})
// confirm that there's no renew command prior to running the cron
renewCmdUUID := getRenewCmdUUID(iPadMdmDevice.UUID)
require.Empty(t, renewCmdUUID.String)
require.False(t, renewCmdUUID.Valid)
// running cron enqueues the renew command
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
// we now have a renew command uuid
renewCmdUUID = getRenewCmdUUID(iPadMdmDevice.UUID)
require.NotEmpty(t, renewCmdUUID.String)
require.True(t, renewCmdUUID.Valid)
wantCmdUUID := renewCmdUUID.String
// verify the renew command includes the enroll reference
cmd, err = iPadMdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
require.Equal(t, wantCmdUUID, cmd.CommandUUID)
s.verifyEnrollmentProfile(cmd.Raw, "some-legacy-ref", "")
// fail the command so we can test new flow that ensures renew commands are cleared
// whenever we reset enroll ref
cmd, err = iPadMdmDevice.Err(cmd.CommandUUID, nil)
require.NoError(t, err)
require.Nil(t, cmd) // error doesn't trigger new command immediately
// running cron again doesn't change anything if the renew command failed
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
cmd, err = iPadMdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd) // no new command is enqueued
// confirm that the renew command uuid is still the same after failure and cron run
renewCmdUUID = getRenewCmdUUID(iPadMdmDevice.UUID)
require.NotEmpty(t, renewCmdUUID.String)
require.True(t, renewCmdUUID.Valid)
require.Equal(t, wantCmdUUID, renewCmdUUID.String)
// now clear the enroll_ref to simulate device that failed prior to #37880
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE host_mdm
SET fleet_enroll_ref = ''
WHERE host_id = (SELECT id FROM hosts WHERE uuid = ?)
`, iPadMdmDevice.UUID)
return err
})
require.Empty(t, getEnrollRef(iPadMdmDevice.UUID))
// running cron again doesn't change anything until refetch is done
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
cmd, err = iPadMdmDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd) // no new command is enqueued
// confirm that the renew command uuid is still the same
renewCmdUUID = getRenewCmdUUID(iPadMdmDevice.UUID)
require.NotEmpty(t, renewCmdUUID.String)
require.True(t, renewCmdUUID.Valid)
require.Equal(t, wantCmdUUID, renewCmdUUID.String)
// backdate host.detail_updated_at so we can do another refetch
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE hosts
SET detail_updated_at = DATE_SUB(NOW(), INTERVAL 2 HOUR)
WHERE uuid = ?
`, iPadMdmDevice.UUID)
return err
})
// enqueue refetch commands and report results
require.NoError(t, apple_mdm.IOSiPadOSRefetch(ctx, s.ds, s.mdmCommander, s.logger, func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
}))
require.True(t, existsRefetchCmd(iPadMdmDevice.UUID))
reportResultsRefetchCmds(iPadMdmDevice)
// refetch triggers new enroll reference to be set and renew commands to be cleared/deactivated
require.Equal(t, "some-legacy-ref", getEnrollRef(iPadMdmDevice.UUID))
// renew command cleared
renewCmdUUID = getRenewCmdUUID(iPadMdmDevice.UUID)
require.Empty(t, renewCmdUUID.String)
require.False(t, renewCmdUUID.Valid)
// nano_enrollment_queue is deactivated
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
var active bool
err := sqlx.GetContext(ctx, q, &active, `
SELECT active FROM nano_enrollment_queue WHERE id = ? AND command_uuid = ?
`, iPadMdmDevice.UUID, wantCmdUUID)
require.NoError(t, err)
require.False(t, active)
return nil
})
// now run renewal cron to issue new command
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
renewCmdUUID = getRenewCmdUUID(iPadMdmDevice.UUID)
require.NotEmpty(t, renewCmdUUID.String)
require.True(t, renewCmdUUID.Valid)
require.NotEqual(t, wantCmdUUID, renewCmdUUID.String)
wantCmdUUID = renewCmdUUID.String // updated with new command UUID
cmd, err = iPadMdmDevice.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
require.Equal(t, wantCmdUUID, cmd.CommandUUID)
s.verifyEnrollmentProfile(cmd.Raw, "some-legacy-ref", "")
cmd, err = iPadMdmDevice.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
require.Nil(t, cmd) // no further commands
}
func (s *integrationMDMTestSuite) TestRefetchAfterReenrollIOSNoDelete() {
t := s.T()
triggerRefetchCron := func(hostID uint) {
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `UPDATE hosts SET detail_updated_at = DATE_SUB(NOW(), INTERVAL 2 HOUR) WHERE id = ?`, hostID)
return err
})
trigger := triggerRequest{
Name: string(fleet.CronAppleMDMIPhoneIPadRefetcher),
}
s.Do("POST", "/api/latest/fleet/trigger", trigger, http.StatusOK)
}
awaitRefetchCommands := func(hostID uint, expectCmds int) {
// Wait until MDM commands are set up
done := make(chan struct{})
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
commands, err := s.ds.GetHostMDMCommands(context.Background(), hostID)
require.NoError(t, err)
if len(commands) >= expectCmds {
done <- struct{}{}
return
}
}
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Error("Timeout: MDM commands not queued up")
}
}
acknowledgeRefetchCommands := func(mdmDevice *mdmtest.TestAppleMDMClient, expectCmds int) {
cmd, err := mdmDevice.Idle()
require.NoError(t, err)
for cmd != nil {
switch cmd.Command.RequestType {
case "InstalledApplicationList":
cmd, err = mdmDevice.AcknowledgeInstalledApplicationList(mdmDevice.UUID, cmd.CommandUUID, []fleet.Software{})
require.NoError(t, err)
case "CertificateList":
cmd, err = mdmDevice.AcknowledgeCertificateList(mdmDevice.UUID, cmd.CommandUUID, []*x509.Certificate{})
require.NoError(t, err)
case "DeviceInformation":
cmd, err = mdmDevice.AcknowledgeDeviceInformation(mdmDevice.UUID, cmd.CommandUUID, "Test Name", "iPhone 16", "America/Los_Angeles")
require.NoError(t, err)
default:
require.Fail(t, "unexpected command", cmd.Command.RequestType)
}
}
}
// // we're going to modify this mock, make sure we restore its default
// originalPushMock := s.pushProvider.PushFunc
// defer func() { s.pushProvider.PushFunc = originalPushMock }()
// // FIXME: Figure out the best way to test pushes in the test suite. Can we make this more
// // user-friendly and reusable?
// var recordedPushes []*mdm.Push
// var mu sync.Mutex
// s.pushProvider.PushFunc = func(ctx context.Context, pushes []*mdm.Push) (map[string]*push.Response, error) {
// mu.Lock()
// defer mu.Unlock()
// recordedPushes = pushes
// return mockSuccessfulPush(ctx, pushes)
// }
// create a global enroll secret
globalSecret := "global_secret"
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalSecret}},
},
}, http.StatusOK, &applyResp)
hwModel := "iPad13,16"
mdmDevice := mdmtest.NewTestMDMClientAppleOTA(
s.server.URL,
"global_secret",
hwModel,
)
require.NoError(t, mdmDevice.Enroll())
s.runWorker()
checkInstallFleetdCommandSent(t, mdmDevice, false)
// mu.Lock()
// require.Len(t, recordedPushes, 1)
// mu.Unlock()
hostByIdentifierResp := getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp)
require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel)
require.Equal(t, "ipados", hostByIdentifierResp.Host.Platform)
require.False(t, hostByIdentifierResp.Host.RefetchRequested)
hostID := hostByIdentifierResp.Host.ID
triggerRefetchCron(hostID)
awaitRefetchCommands(hostID, 3) // expect three commands: refetch UUID, apps, certs
hostByIdentifierResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp)
require.False(t, hostByIdentifierResp.Host.RefetchRequested) // refetch cron doesn't set the refetch_requested flag
// mu.Lock()
// require.Len(t, recordedPushes, 4)
// mu.Unlock()
acknowledgeRefetchCommands(mdmDevice, 3)
commands, err := s.ds.GetHostMDMCommands(context.Background(), hostID)
require.NoError(t, err)
require.Len(t, commands, 0) // after acknowledging the commands, there should be no more commands
triggerRefetchCron(hostID)
awaitRefetchCommands(hostID, 3) // expect three new commands from the cron
commands, err = s.ds.GetHostMDMCommands(context.Background(), hostID)
require.NoError(t, err)
require.Len(t, commands, 3) // three new refetch commands from the cron
cmdTypes := make([]string, 0, len(commands))
for _, cmd := range commands {
cmdTypes = append(cmdTypes, cmd.CommandType)
}
require.ElementsMatch(t, []string{fleet.RefetchDeviceCommandUUIDPrefix, fleet.RefetchAppsCommandUUIDPrefix, fleet.RefetchCertsCommandUUIDPrefix}, cmdTypes)
// re-enroll the device
require.NoError(t, mdmDevice.Enroll())
commands, err = s.ds.GetHostMDMCommands(context.Background(), hostID)
require.NoError(t, err)
require.Len(t, commands, 0) // re-enrollment clears existing commands
s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/refetch", hostID), nil, http.StatusOK)
hostByIdentifierResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp)
require.True(t, hostByIdentifierResp.Host.RefetchRequested)
awaitRefetchCommands(hostID, 3) // expect three commands: refetch UUID, apps, certs
commands, err = s.ds.GetHostMDMCommands(context.Background(), hostID)
require.NoError(t, err)
require.Len(t, commands, 3)
// re-enroll the device
require.NoError(t, mdmDevice.Enroll())
commands, err = s.ds.GetHostMDMCommands(context.Background(), hostID)
require.NoError(t, err)
require.Len(t, commands, 0)
hostByIdentifierResp = getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp)
require.False(t, hostByIdentifierResp.Host.RefetchRequested) // re-enrollment also clears the refetch_requested flag
}
// TestMDMLockHostUnenrolled tests that we cannot lock a macOS host that is not enrolled in MDM.
// See https://github.com/fleetdm/fleet/issues/30192
func (s *integrationMDMTestSuite) TestMDMLockHostUnenrolled() {
t := s.T()
// create a global enroll secret
globalSecret := "global_secret"
var applyResp applyEnrollSecretSpecResponse
s.DoJSON("POST", "/api/latest/fleet/spec/enroll_secret", applyEnrollSecretSpecRequest{
Spec: &fleet.EnrollSecretSpec{
Secrets: []*fleet.EnrollSecret{{Secret: globalSecret}},
},
}, http.StatusOK, &applyResp)
hwModel := "MacBookPro14,3"
mdmDevice := mdmtest.NewTestMDMClientAppleOTA(
s.server.URL,
"global_secret",
hwModel,
)
require.NoError(t, mdmDevice.Enroll())
hostByIdentifierResp := getHostResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/identifier/%s", mdmDevice.UUID), nil, http.StatusOK, &hostByIdentifierResp)
require.Equal(t, hwModel, hostByIdentifierResp.Host.HardwareModel)
hostID := hostByIdentifierResp.Host.ID
// mark the host as unenrolled in MDM
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(), `
UPDATE nano_enrollments
SET enabled = 0
WHERE id = ?
`, mdmDevice.UUID)
return err
})
// try to lock the host, it should fail because the host is not enrolled
res := s.Do(
"POST",
fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", hostID),
nil,
http.StatusUnprocessableEntity,
)
defer res.Body.Close()
e := extractServerErrorText(res.Body)
require.Contains(t, e, "Can't lock the host because it doesn't have MDM turned on")
}
// Test case for https://github.com/fleetdm/fleet/issues/33074
func (s *integrationMDMTestSuite) TestFileVaultProfileUpdatedOnMDMToggle() {
t := s.T()
// Setup Apple MDM
s.appleCoreCertsSetup()
// Turn on disk encryption for no team
s.Do("POST", "/api/latest/fleet/disk_encryption", json.RawMessage(`{
"enable_disk_encryption": true
}`), http.StatusNoContent)
// Check that FileVault profile exists in the database
var initialProfileID uint
var initialTimestamp time.Time
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return q.QueryRowxContext(context.Background(),
`SELECT profile_id, uploaded_at FROM mdm_apple_configuration_profiles
WHERE identifier = ? AND team_id = 0`,
mobileconfig.FleetFileVaultPayloadIdentifier).Scan(&initialProfileID, &initialTimestamp)
})
require.NotZero(t, initialProfileID, "FileVault profile should exist in database after enabling disk encryption")
// Wait a moment to ensure timestamp difference will be detectable
time.Sleep(100 * time.Millisecond)
// Turn off Apple MDM
s.Do("DELETE", "/api/latest/fleet/mdm/apple/apns_certificate", nil, http.StatusOK)
// Turn back on Apple MDM
s.appleCoreCertsSetup()
// Check that FileVault profile still exists and has been updated
var updatedProfileID uint
var updatedTimestamp time.Time
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return q.QueryRowxContext(context.Background(),
`SELECT profile_id, uploaded_at FROM mdm_apple_configuration_profiles
WHERE identifier = ? AND team_id = 0`,
mobileconfig.FleetFileVaultPayloadIdentifier).Scan(&updatedProfileID, &updatedTimestamp)
})
require.NotZero(t, updatedProfileID, "FileVault profile should exist in database after re-enabling MDM")
// Verify the profile has been updated (newer timestamp or different profile ID)
profileWasUpdated := updatedTimestamp.After(initialTimestamp) || updatedProfileID != initialProfileID
require.True(t, profileWasUpdated,
"FileVault profile should have been updated when MDM was re-enabled. Initial ID: %d (time: %v), Updated ID: %d (time: %v)",
initialProfileID, initialTimestamp, updatedProfileID, updatedTimestamp)
// Disable MDM and remove filevault profile, then re-enable
s.Do("DELETE", "/api/latest/fleet/mdm/apple/apns_certificate", nil, http.StatusOK)
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(),
`DELETE FROM mdm_apple_configuration_profiles
WHERE identifier = ? AND team_id = 0`,
mobileconfig.FleetFileVaultPayloadIdentifier)
return err
})
// Turn back on MDM, and see it succesfully creates the profile without fail
s.appleCoreCertsSetup()
var finalProfileID uint
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return q.QueryRowxContext(context.Background(),
`SELECT profile_id FROM mdm_apple_configuration_profiles
WHERE identifier = ? AND team_id = 0`,
mobileconfig.FleetFileVaultPayloadIdentifier).Scan(&finalProfileID)
})
require.NotZero(t, finalProfileID, "FileVault profile should exist in database after re-enabling MDM when it was previously deleted")
}