<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #48486 # 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. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops - [x] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Apple MDM devices manually re-enrolled during a pending SCEP renewal are now handled as a fresh enrollment, so enrollment steps run correctly. * Renewal and re-enrollment flows are now better distinguished, reducing cases where profile or app setup could be skipped. * Enrollment certificates now carry clearer markers to help the system apply the right lifecycle behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1804 lines
65 KiB
Go
1804 lines
65 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"
|
|
"regexp"
|
|
"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/mysqltest"
|
|
"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]
|
|
// Background: assertAction records one management session before the action (fCmds) and one after (sCmds)
|
|
// and requires them equal. Each session CONSUMES the commands it receives. Enrolling a host queues its
|
|
// profile installs immediately (per-host reconciler), so test setup leaves a one-time batch of installs in
|
|
// the queue, and whichever session runs first eats it.
|
|
//
|
|
// DrainEnrollBacklog=true runs one extra throwaway session before recording. Set it for actions that do NOT
|
|
// (re-)enroll: there, the pre-action session would consume the enroll-time installs (fCmds=statuses+installs),
|
|
// the action queues nothing, and the post-action session comes up short (sCmds=statuses only). Draining first
|
|
// makes both sessions observe the steady state.
|
|
//
|
|
// Leave it false for actions that DO re-enroll (e.g. delete + re-enroll): re-enrollment queues the same
|
|
// installs again, so fCmds (enroll delivery) equaling sCmds (re-enroll delivery) is exactly the lifecycle
|
|
// invariant the test exists to prove. Draining would turn that into statuses-vs-installs and fail spuriously.
|
|
DrainEnrollBacklog bool
|
|
}{
|
|
{
|
|
"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
|
|
mysqltest.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())
|
|
},
|
|
false,
|
|
},
|
|
{
|
|
"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 fleet.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
|
|
mysqltest.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())
|
|
},
|
|
false,
|
|
},
|
|
{
|
|
"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())
|
|
}
|
|
},
|
|
true, // action does not re-enroll, so consume the enroll-time backlog before recording
|
|
},
|
|
{
|
|
"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())
|
|
},
|
|
false,
|
|
},
|
|
}
|
|
|
|
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)
|
|
if tt.DrainEnrollBacklog {
|
|
s.recordWindowsHostStatus(host, device)
|
|
}
|
|
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)
|
|
|
|
if tt.DrainEnrollBacklog {
|
|
s.recordWindowsHostStatus(host, device)
|
|
}
|
|
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)
|
|
euaTokenRe := regexp.MustCompile(`EUA_TOKEN="[^"]*"`)
|
|
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
|
|
for i := range c.Cmd.Items {
|
|
if c.Cmd.Items[i].Data != nil {
|
|
c.Cmd.Items[i].Data.Content = euaTokenRe.ReplaceAllString(
|
|
c.Cmd.Items[i].Data.Content, `EUA_TOKEN="<redacted>"`)
|
|
}
|
|
}
|
|
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()
|
|
|
|
// ensure fleet profiles
|
|
s.awaitTriggerProfileSchedule(t)
|
|
// run worker to process the enroll request
|
|
s.awaitRunAppleMDMWorkerSchedule()
|
|
s.runWorkerUntilDone()
|
|
|
|
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
|
|
mysqltest.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
|
|
mysqltest.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
|
|
mysqltest.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
|
|
abmToken := 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)
|
|
mysqltest.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")
|
|
|
|
originalServerURL := s.server.URL
|
|
var acResp appConfigResponse
|
|
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(fmt.Sprintf(`{
|
|
"server_settings": {
|
|
"server_url": "https://localhost:8080"
|
|
},
|
|
"mdm": {
|
|
"end_user_authentication": {
|
|
"entity_id": "mdm.test.com",
|
|
"idp_name": "SimpleSAML",
|
|
"metadata_url": "%s"
|
|
}
|
|
}
|
|
}`, testSAMLIDPMetadataURL)), http.StatusOK, &acResp)
|
|
|
|
iPhoneBYODToken := s.LoginAccountDrivenEnrollUser("sso_user", "user123#", string(abmToken.EnrollmentURLToken))
|
|
loc, err := iPhoneBYODToken.Location()
|
|
require.NoError(t, err)
|
|
require.NotNil(t, loc)
|
|
require.True(t, strings.HasPrefix(loc.String(), "apple-remotemanagement-user-login://authentication-results?access-token="))
|
|
accessToken := strings.Split(loc.String(), "apple-remotemanagement-user-login://authentication-results?access-token=")[1]
|
|
|
|
acResp = appConfigResponse{}
|
|
s.DoJSON("PATCH", "/api/latest/fleet/config", json.RawMessage(`{
|
|
"server_settings": {
|
|
"server_url": "`+originalServerURL+`"
|
|
}
|
|
}`), http.StatusOK, &acResp)
|
|
|
|
iPhoneMdmDevice := mdmtest.NewTestMDMClientAppleAccountDrivenUserEnrollment(
|
|
s.server.URL,
|
|
iPhoneHwModel,
|
|
accessToken,
|
|
)
|
|
require.NoError(t, iPhoneMdmDevice.Enroll())
|
|
assert.Equal(t, "sso_user@example.com", iPhoneMdmDevice.EnrollInfo.AssignedManagedAppleID)
|
|
|
|
// 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.awaitTriggerProfileSchedule(t)
|
|
s.awaitRunAppleMDMWorkerSchedule()
|
|
|
|
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.awaitRunAppleMDMWorkerSchedule()
|
|
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, s.acmeSvc)
|
|
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() {
|
|
mysqltest.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, s.acmeSvc)
|
|
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 == "" {
|
|
enrollProfile := s.verifyEnrollmentProfile(fullCmd.Command.InstallProfile.Payload, enrollRef, wantManagedAppleID)
|
|
if wantManagedAppleID != "" {
|
|
// we see this as byod, so we update the enrollInfo to avoid fetching the profile againt from the account_driven_enroll path, as that is not how it works.
|
|
// and with the new challenge based setup, does not mimic the real flow well.
|
|
for _, payload := range enrollProfile.PayloadContent {
|
|
switch payload.PayloadType {
|
|
case "com.apple.security.scep":
|
|
device.EnrollInfo.SCEPURL = payload.PayloadContent.URL
|
|
device.EnrollInfo.SCEPChallenge = payload.PayloadContent.Challenge
|
|
case "com.apple.mdm":
|
|
device.EnrollInfo.MDMURL = payload.ServerURL
|
|
}
|
|
}
|
|
}
|
|
} 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, "", "", "sso_user@example.com")
|
|
|
|
// 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, s.acmeSvc)
|
|
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, s.acmeSvc)
|
|
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. A genuine renewal re-keys from a pushed renewal
|
|
// profile, which (unlike a freshly-fetched enrollment profile) carries no new-enrollment Subject OU;
|
|
// SimulateSCEPRenewal replays the full re-enroll flow but omits that marker so the checkin is treated
|
|
// as a renewal rather than a fresh enrollment.
|
|
for _, d := range []*mdmtest.TestAppleMDMClient{
|
|
manualEnrolledDevice, automaticEnrolledDevice, automaticEnrolledDeviceWithRef, migratedDevice, iPhoneMdmDevice,
|
|
} {
|
|
d.SimulateSCEPRenewal = true
|
|
}
|
|
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, s.acmeSvc)
|
|
require.NoError(t, err)
|
|
checkRenewCertCommand(automaticEnrolledDevice, "", "", "")
|
|
checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo", "", "")
|
|
checkRenewCertCommand(iPhoneMdmDevice, "", "", "sso_user@example.com")
|
|
|
|
// migrated device is still marked as migrated
|
|
var stillMigrated bool
|
|
mysqltest.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.awaitRunAppleMDMWorkerSchedule()
|
|
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
|
|
mysqltest.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, s.acmeSvc)
|
|
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, s.acmeSvc)
|
|
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
|
|
mysqltest.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, s.acmeSvc)
|
|
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
|
|
mysqltest.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
|
|
mysqltest.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, s.acmeSvc)
|
|
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) {
|
|
mysqltest.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.awaitRunAppleMDMWorkerSchedule()
|
|
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)
|
|
// iOS/iPadOS enrollment sets refetch_requested=true so the iphone_ipad_refetcher
|
|
// cron picks the host up; the flag is cleared by the DeviceInformation ack handler.
|
|
require.True(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)
|
|
// The cron queues commands but doesn't clear refetch_requested — that happens
|
|
// only after the device acks the DeviceInformation command below.
|
|
require.True(t, hostByIdentifierResp.Host.RefetchRequested)
|
|
|
|
// 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)
|
|
// Re-enrollment re-flags iOS/iPadOS hosts with refetch_requested=true so the cron
|
|
// will pick them up again; the flag is cleared by the next DeviceInformation ack.
|
|
require.True(t, hostByIdentifierResp.Host.RefetchRequested)
|
|
}
|
|
|
|
// 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
|
|
mysqltest.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
|
|
mysqltest.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
|
|
mysqltest.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)
|
|
mysqltest.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
|
|
mysqltest.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")
|
|
}
|
|
|
|
// TestSCEPRenewalVsFreshEnrollment verifies the fresh-enrollment-vs-SCEP-renewal disambiguation end to
|
|
// end: a device that re-enrolls (fetching a fresh profile whose SCEP Subject carries the new-enrollment
|
|
// marker OU) while a stale SCEP renewal is pending is treated as a fresh enrollment — the renewal refs
|
|
// are cleared and mdm_enrolled re-fires. A genuine renewal (cert without the OU) keeps today's
|
|
// short-circuit: no new mdm_enrolled activity.
|
|
func (s *integrationMDMTestSuite) TestSCEPRenewalVsFreshEnrollment() {
|
|
t := s.T()
|
|
ctx := context.Background()
|
|
s.setSkipWorkerJobs(t)
|
|
|
|
cert, key, err := generateCertWithAPNsTopic()
|
|
require.NoError(t, err)
|
|
fleetCfg := config.TestConfig()
|
|
config.SetTestMDMConfig(s.T(), &fleetCfg, cert, key, "")
|
|
logger := slog.New(slog.DiscardHandler)
|
|
|
|
// renewalPending reports whether any cert association for the host has a pending renewal command.
|
|
// This mirrors how GetHostMDMCheckinInfo derives SCEPRenewalInProgress and is robust to a host
|
|
// having multiple association rows (one per issued cert) after a re-key.
|
|
renewalPending := func(hostUUID string) bool {
|
|
var pending bool
|
|
mysqltest.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
|
|
return sqlx.GetContext(ctx, q, &pending,
|
|
`SELECT EXISTS(SELECT 1 FROM nano_cert_auth_associations WHERE id = ? AND renew_command_uuid IS NOT NULL)`, hostUUID)
|
|
})
|
|
return pending
|
|
}
|
|
forcePendingRenewal := func(hostUUID string) {
|
|
mysqltest.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 = ?`, hostUUID)
|
|
return err
|
|
})
|
|
require.NoError(t, RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander, s.acmeSvc))
|
|
require.True(t, renewalPending(hostUUID), "a SCEP renewal command should be pending after the renewal cron")
|
|
}
|
|
lastEnrolledActivityID := func() uint {
|
|
return s.lastActivityOfTypeMatches(fleet.ActivityTypeMDMEnrolled{}.ActivityName(), "", 0)
|
|
}
|
|
newManualDevice := func(suffix string) (*fleet.Host, *mdmtest.TestAppleMDMClient) {
|
|
token := uuid.New().String()
|
|
host := createOrbitEnrolledHost(t, "darwin", suffix, s.ds)
|
|
require.NoError(t, s.ds.SetOrUpdateDeviceAuthToken(ctx, host.ID, token))
|
|
dev := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, token)
|
|
dev.UUID = host.UUID
|
|
dev.SerialNumber = host.HardwareSerial
|
|
return host, dev
|
|
}
|
|
|
|
t.Run("fresh re-enroll while a renewal is pending is treated as fresh", func(t *testing.T) {
|
|
host, dev := newManualDevice("scep-fresh-reenroll")
|
|
require.NoError(t, dev.Enroll())
|
|
// Fleet's fresh enrollment profile carries the new-enrollment marker OU, which the simulated
|
|
// device puts in its CSR and Fleet's SCEP signer preserves into the identity cert.
|
|
require.Contains(t, dev.EnrollInfo.SCEPSubjectOUs, apple_mdm.FleetEnrollmentSubjectOU)
|
|
firstEnrollID := lastEnrolledActivityID()
|
|
|
|
forcePendingRenewal(host.UUID)
|
|
|
|
// The device re-enrolls fresh (hits the enroll endpoint again) rather than processing the
|
|
// pending renewal command.
|
|
require.NoError(t, dev.Reenroll())
|
|
|
|
// The stale renewal ref is cleared and a NEW mdm_enrolled activity fired.
|
|
require.False(t, renewalPending(host.UUID), "renew refs should be cleared for a fresh re-enrollment")
|
|
require.Greater(t, lastEnrolledActivityID(), firstEnrollID, "a fresh re-enrollment must emit a new mdm_enrolled activity")
|
|
})
|
|
|
|
t.Run("genuine renewal keeps the short-circuit", func(t *testing.T) {
|
|
host, dev := newManualDevice("scep-genuine-renewal")
|
|
require.NoError(t, dev.Enroll())
|
|
firstEnrollID := lastEnrolledActivityID()
|
|
|
|
forcePendingRenewal(host.UUID)
|
|
|
|
// Simulate the device processing the renewal: it re-keys from a renewal profile, whose SCEP
|
|
// Subject carries no new-enrollment OU (verified in the server/mdm/apple unit tests), so the
|
|
// issued cert lacks the marker.
|
|
dev.EnrollInfo.SCEPSubjectOUs = nil
|
|
require.NoError(t, dev.SCEPEnroll())
|
|
require.NoError(t, dev.Authenticate())
|
|
require.NoError(t, dev.TokenUpdate(false))
|
|
|
|
// The renewal is short-circuited: refs cleared, but NO new mdm_enrolled activity.
|
|
require.False(t, renewalPending(host.UUID), "renew refs should be cleared after a renewal checkin")
|
|
require.Equal(t, firstEnrollID, lastEnrolledActivityID(), "a genuine renewal must not emit a new mdm_enrolled activity")
|
|
})
|
|
|
|
t.Run("ACME hardware-attested: renewal short-circuits, fresh re-enroll is treated as fresh", func(t *testing.T) {
|
|
// ACME enrollment requires an Apple Silicon Mac (macOS 14+), a DEP assignment, and
|
|
// apple_require_hardware_attestation enabled. The marker OU rides the ACME cert the same way it
|
|
// rides a SCEP cert (Fleet's ACME signer is the same depot signer).
|
|
mysqltest.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.apple_require_hardware_attestation', true)`)
|
|
return err
|
|
})
|
|
t.Cleanup(func() {
|
|
mysqltest.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.apple_require_hardware_attestation', false)`)
|
|
return err
|
|
})
|
|
})
|
|
|
|
s.enableABM(t.Name())
|
|
devices := []godep.Device{{SerialNumber: uuid.New().String(), Model: "MacBookPro17,1", 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)
|
|
// Use assert (not require) inside the handler: it runs on a separate goroutine, where
|
|
// require's FailNow is unsafe.
|
|
switch r.URL.Path {
|
|
case "/session":
|
|
assert.NoError(t, encoder.Encode(map[string]string{"auth_session_token": "xyz"}))
|
|
case "/profile":
|
|
assert.NoError(t, encoder.Encode(godep.ProfileResponse{ProfileUUID: uuid.New().String()}))
|
|
case "/server/devices":
|
|
assert.NoError(t, encoder.Encode(godep.DeviceResponse{Devices: devices}))
|
|
case "/devices/sync":
|
|
assert.NoError(t, encoder.Encode(godep.DeviceResponse{Devices: devices, Cursor: "foo"}))
|
|
case "/profile/devices":
|
|
b, err := io.ReadAll(r.Body)
|
|
assert.NoError(t, err)
|
|
var prof profileAssignmentReq
|
|
assert.NoError(t, json.Unmarshal(b, &prof))
|
|
resp := godep.ProfileResponse{ProfileUUID: prof.ProfileUUID, Devices: make(map[string]string, len(prof.Devices))}
|
|
for _, d := range prof.Devices {
|
|
resp.Devices[d] = string(fleet.DEPAssignProfileResponseSuccess)
|
|
}
|
|
assert.NoError(t, encoder.Encode(resp))
|
|
default:
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|
|
}))
|
|
s.runDEPSchedule()
|
|
|
|
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
|
|
dev := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken, mdmtest.WithACMECerts(s.acmeCertCA, s.acmeCertKey))
|
|
dev.SerialNumber = devices[0].SerialNumber
|
|
dev.Model = devices[0].Model
|
|
dev.OSVersion = "14.0"
|
|
require.NoError(t, dev.Enroll())
|
|
|
|
// Sanity: the device enrolled via ACME and its fresh profile carried the marker OU.
|
|
require.NotEmpty(t, dev.EnrollInfo.ACMEURL, "device should have enrolled via ACME")
|
|
require.Contains(t, dev.EnrollInfo.SCEPSubjectOUs, apple_mdm.FleetEnrollmentSubjectOU)
|
|
|
|
host, err := s.ds.HostByIdentifier(ctx, dev.SerialNumber)
|
|
require.NoError(t, err)
|
|
firstEnrollID := lastEnrolledActivityID()
|
|
|
|
// Genuine ACME renewal (re-keyed cert without the marker OU) short-circuits: no new mdm_enrolled.
|
|
forcePendingRenewal(host.UUID)
|
|
dev.SimulateSCEPRenewal = true
|
|
require.NoError(t, dev.Reenroll())
|
|
require.False(t, renewalPending(host.UUID), "renew refs should be cleared after an ACME renewal checkin")
|
|
require.Equal(t, firstEnrollID, lastEnrolledActivityID(), "an ACME renewal must not emit a new mdm_enrolled activity")
|
|
|
|
// Fresh ACME re-enroll while a renewal is pending is treated as fresh: refs cleared + new mdm_enrolled.
|
|
forcePendingRenewal(host.UUID)
|
|
dev.SimulateSCEPRenewal = false
|
|
require.NoError(t, dev.Reenroll())
|
|
require.False(t, renewalPending(host.UUID), "renew refs should be cleared for a fresh ACME re-enrollment")
|
|
require.Greater(t, lastEnrolledActivityID(), firstEnrollID, "a fresh ACME re-enrollment must emit a new mdm_enrolled activity")
|
|
})
|
|
}
|