fleet/server/datastore/mysql/apple_mdm_test.go

374 lines
14 KiB
Go

package mysql
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/require"
)
func TestIngestMDMAppleDevicesFromDEPSync(t *testing.T) {
ds := CreateMySQLDS(t)
ctx := context.Background()
createBuiltinLabels(t, ds)
for i := 0; i < 10; i++ {
_, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("hostname_%d", i),
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now().Add(-time.Duration(i) * time.Minute),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-host-id_%d", i)),
NodeKey: ptr.String(fmt.Sprintf("node-key_%d", i)),
UUID: fmt.Sprintf("uuid_%d", i),
HardwareSerial: fmt.Sprintf("serial_%d", i),
})
require.NoError(t, err)
}
hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 10)
wantSerials := []string{}
for _, h := range hosts {
wantSerials = append(wantSerials, h.HardwareSerial)
}
// mock results incoming from depsync.Syncer
depDevices := []godep.Device{
{SerialNumber: "abc", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, // ingested; new serial, macOS, "added" op type
{SerialNumber: "abc", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, // not ingested; duplicate serial
{SerialNumber: hosts[0].HardwareSerial, Model: "MacBook Pro", OS: "OSX", OpType: "added"}, // not ingested; existing serial
{SerialNumber: "ijk", Model: "IPad Pro", OS: "iOS", OpType: "added"}, // not ingested; iOS
{SerialNumber: "tuv", Model: "Apple TV", OS: "tvOS", OpType: "added"}, // not ingested; tvOS
{SerialNumber: "xyz", Model: "MacBook Pro", OS: "OSX", OpType: "updated"}, // not ingested; op type "updated"
{SerialNumber: "xyz", Model: "MacBook Pro", OS: "OSX", OpType: "deleted"}, // not ingested; op type "deleted"
{SerialNumber: "xyz", Model: "MacBook Pro", OS: "OSX", OpType: "added"}, // ingested; new serial, macOS, "added" op type
}
wantSerials = append(wantSerials, "abc", "xyz")
n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, depDevices)
require.NoError(t, err)
require.Equal(t, int64(2), n) // 2 new hosts ("abc", "xyz")
hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, len(wantSerials))
gotSerials := []string{}
for _, h := range hosts {
gotSerials = append(gotSerials, h.HardwareSerial)
if hs := h.HardwareSerial; hs == "abc" || hs == "xyz" {
checkMDMHostRelatedTables(t, ds, h.ID, hs, "MacBook Pro")
}
}
require.ElementsMatch(t, wantSerials, gotSerials)
}
func TestHandleMDMCheckinRequest(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"TestHostAlreadyExistsInFleet", testIngestMDMAppleHostAlreadyExistsInFleet},
{"TestCheckinAfterDEPSync", testIngestMDMAppleCheckinAfterDEPSync},
{"TestBeforeDEPSync", testIngestMDMAppleCheckinBeforeDEPSync},
{"TestMultipleAuthenticateRequests", testIngestMDMAppleCheckinMultipleAuthenticateRequests},
{"TestCheckOutRequests", testIngestMDMAppleCheckinCheckoutRequests},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
createBuiltinLabels(t, ds)
c.fn(t, ds)
})
}
}
func testIngestMDMAppleHostAlreadyExistsInFleet(t *testing.T, ds *Datastore) {
ctx := context.Background()
testSerial := "test-serial"
testUUID := "test-uuid"
host, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "test-host-name",
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("1337"),
NodeKey: ptr.String("1337"),
UUID: testUUID,
HardwareSerial: testSerial,
})
require.NoError(t, err)
err = ds.SetOrUpdateMDMData(ctx, host.ID, false, false, "https://fleetdm.com", true, "Fleet MDM")
require.NoError(t, err)
hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
err = fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUUID, "MacBook Pro"))),
}, ds)
require.NoError(t, err)
hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
// an activity is created
activities, err := ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
require.Len(t, activities, 1)
require.Empty(t, activities[0].ActorID)
require.JSONEq(
t,
`{"host_serial":"test-serial", "installed_from_dep":true}`,
string(*activities[0].Details),
)
}
func testIngestMDMAppleCheckinAfterDEPSync(t *testing.T, ds *Datastore) {
ctx := context.Background()
testSerial := "test-serial"
testUUID := "test-uuid"
// simulate a host that is first ingested via DEP (e.g., the device was added via Apple Business Manager)
n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{
{SerialNumber: testSerial, Model: "MacBook Pro", OS: "OSX", OpType: "added"},
})
require.NoError(t, err)
require.Equal(t, int64(1), n)
hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
// hosts that are first ingested via DEP will have a serial number but not a UUID because UUID
// is not available from the DEP sync endpoint
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, "", hosts[0].UUID)
checkMDMHostRelatedTables(t, ds, hosts[0].ID, testSerial, "MacBook Pro")
// now simulate the initial MDM checkin by that same host
err = fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUUID, "MacBook Pro"))),
}, ds)
require.NoError(t, err)
hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
checkMDMHostRelatedTables(t, ds, hosts[0].ID, testSerial, "MacBook Pro")
// an activity is created
activities, err := ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
require.Len(t, activities, 1)
require.Empty(t, activities[0].ActorID)
require.JSONEq(
t,
`{"host_serial":"test-serial", "installed_from_dep":true}`,
string(*activities[0].Details),
)
}
func testIngestMDMAppleCheckinBeforeDEPSync(t *testing.T, ds *Datastore) {
ctx := context.Background()
testSerial := "test-serial"
testUUID := "test-uuid"
// ingest host on initial mdm checkin
err := fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUUID, "MacBook Pro"))),
}, ds)
require.NoError(t, err)
// an activity is created
activities, err := ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
require.Len(t, activities, 1)
require.Empty(t, activities[0].ActorID)
require.JSONEq(
t,
`{"host_serial":"test-serial", "installed_from_dep":false}`,
string(*activities[0].Details),
)
hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
checkMDMHostRelatedTables(t, ds, hosts[0].ID, testSerial, "MacBook Pro")
// no effect if same host appears in DEP sync
n, err := ds.IngestMDMAppleDevicesFromDEPSync(ctx, []godep.Device{
{SerialNumber: testSerial, Model: "MacBook Pro", OS: "OSX", OpType: "added"},
})
require.NoError(t, err)
require.Equal(t, int64(0), n)
hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
checkMDMHostRelatedTables(t, ds, hosts[0].ID, testSerial, "MacBook Pro")
}
func testIngestMDMAppleCheckinMultipleAuthenticateRequests(t *testing.T, ds *Datastore) {
ctx := context.Background()
testSerial := "test-serial"
testUUID := "test-uuid"
err := fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUUID, "MacBook Pro"))),
}, ds)
require.NoError(t, err)
hosts := listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
// duplicate Authenticate request has no effect
err = fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUUID, "MacBook Pro"))),
}, ds)
require.NoError(t, err)
hosts = listHostsCheckCount(t, ds, fleet.TeamFilter{User: test.UserAdmin}, fleet.HostListOptions{}, 1)
require.Equal(t, testSerial, hosts[0].HardwareSerial)
require.Equal(t, testUUID, hosts[0].UUID)
}
func testIngestMDMAppleCheckinCheckoutRequests(t *testing.T, ds *Datastore) {
ctx := context.Background()
testSerial := "test-serial"
testUUID := "test-uuid"
err := fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUUID, "MacBook Pro"))),
}, ds)
require.NoError(t, err)
// CheckOut request updates MDM data and adds an activity
err = fleet.HandleMDMCheckinRequest(ctx, &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("CheckOut", "", testUUID, ""))),
}, ds)
require.NoError(t, err)
activities, err := ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
require.Len(t, activities, 2)
require.Equal(t, "mdm_unenrolled", activities[1].Type)
require.Empty(t, activities[1].ActorID)
require.JSONEq(
t,
`{"host_serial":"test-serial", "installed_from_dep":false}`,
string(*activities[1].Details),
)
}
// checkMDMHostRelatedTables checks that rows are inserted for new MDM hosts in each of
// host_display_names, host_seen_times, and label_membership. Note that related tables records for
// pre-existing hosts are created outside of the MDM enrollment flows so they are not checked in
// some tests above (e.g., testIngestMDMAppleHostAlreadyExistsInFleet)
func checkMDMHostRelatedTables(t *testing.T, ds *Datastore, hostID uint, expectedSerial string, expectedModel string) {
var displayName string
err := sqlx.GetContext(context.Background(), ds.reader, &displayName, `SELECT display_name FROM host_display_names WHERE host_id = ?`, hostID)
require.NoError(t, err)
require.Equal(t, fmt.Sprintf("%s (%s)", expectedModel, expectedSerial), displayName)
var labelsOK []bool
err = sqlx.SelectContext(context.Background(), ds.reader, &labelsOK, `SELECT 1 FROM label_membership WHERE host_id = ?`, hostID)
require.NoError(t, err)
require.Len(t, labelsOK, 2)
require.True(t, labelsOK[0])
require.True(t, labelsOK[1])
appCfg, err := ds.AppConfig(context.Background())
require.NoError(t, err)
var hmdm fleet.HostMDM
err = sqlx.GetContext(context.Background(), ds.reader, &hmdm, `SELECT host_id, server_url, mdm_id FROM host_mdm WHERE host_id = ?`, hostID)
require.NoError(t, err)
require.Equal(t, hostID, hmdm.HostID)
require.Equal(t, appCfg.ServerSettings.ServerURL, hmdm.ServerURL)
require.NotEmpty(t, hmdm.MDMID)
var mdmSolution fleet.MDMSolution
err = sqlx.GetContext(context.Background(), ds.reader, &mdmSolution, `SELECT name, server_url FROM mobile_device_management_solutions WHERE id = ?`, hmdm.MDMID)
require.NoError(t, err)
require.Equal(t, fleet.WellKnownMDMFleet, mdmSolution.Name)
require.Equal(t, appCfg.ServerSettings.ServerURL, mdmSolution.ServerURL)
}
func xmlForTest(msgType string, serial string, udid string, model string) string {
return fmt.Sprintf(`
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>MessageType</key>
<string>%s</string>
<key>SerialNumber</key>
<string>%s</string>
<key>UDID</key>
<string>%s</string>
<key>Model</key>
<string>%s</string>
</dict>
</plist>`, msgType, serial, udid, model)
}
// createBuiltinLabels creates entries for "All Hosts" and "macOS" labels, which are assumed to be
// extant for MDM flows
func createBuiltinLabels(t *testing.T, ds *Datastore) {
_, err := ds.writer.Exec(`
INSERT INTO labels (
name,
description,
query,
platform,
label_type
) VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`,
"All Hosts",
"",
"",
"",
fleet.LabelTypeBuiltIn,
"macOS",
"",
"",
"",
fleet.LabelTypeBuiltIn,
)
require.NoError(t, err)
}