add activities when a host is enrolled/unenrolled from MDM (#9127)

#8996
This commit is contained in:
Roberto Dip 2022-12-28 16:41:18 -03:00 committed by GitHub
parent aedb0424a2
commit 1b47f9e700
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 426 additions and 120 deletions

View file

@ -0,0 +1 @@
- Added new activities to the activities API when a host is enrolled/unenrolled from Fleet's MDM.

View file

@ -35,12 +35,12 @@ func registerAppleMDMProtocolServices(
mdmStorage *mysql.NanoMDMStorage,
scepStorage *apple_mdm.SCEPMySQLDepot,
logger kitlog.Logger,
mdmHostIngester fleet.MDMHostIngester,
ds fleet.Datastore,
) error {
if err := registerSCEP(mux, scepConfig, scepCertPEM, scepKeyPEM, scepStorage, logger); err != nil {
return fmt.Errorf("scep: %w", err)
}
if err := registerMDM(mux, scepCertPEM, mdmStorage, logger, mdmHostIngester); err != nil {
if err := registerMDM(mux, scepCertPEM, mdmStorage, ds, logger); err != nil {
return fmt.Errorf("mdm: %w", err)
}
return nil
@ -123,8 +123,8 @@ func registerMDM(
mux *http.ServeMux,
scepCAPEM []byte,
mdmStorage *mysql.NanoMDMStorage,
ds fleet.Datastore,
logger kitlog.Logger,
mdmHostIngester fleet.MDMHostIngester,
) error {
certVerifier, err := certverify.NewPoolVerifier(scepCAPEM, x509.ExtKeyUsageClientAuth)
if err != nil {
@ -143,22 +143,23 @@ func registerMDM(
var mdmService nanomdm_service.CheckinAndCommandService = nanomdm.New(mdmStorage, nanomdm.WithLogger(mdmLogger))
mdmService = certauth.New(mdmService, mdmStorage)
var mdmHandler http.Handler = httpmdm.CheckinAndCommandHandler(mdmService, mdmLogger.With("handler", "checkin-command"))
mdmHandler = MDMHostIngesterMiddleware(mdmHandler, mdmHostIngester, logger)
mdmHandler = MDMCheckinMiddleware(mdmHandler, ds, logger)
mdmHandler = httpmdm.CertVerifyMiddleware(mdmHandler, certVerifier, mdmLogger.With("handler", "cert-verify"))
mdmHandler = httpmdm.CertExtractMdmSignatureMiddleware(mdmHandler, mdmLogger.With("handler", "cert-extract"))
mux.Handle(apple_mdm.MDMPath, mdmHandler)
return nil
}
// MDMHostIngesterMiddleware watches incoming requests in order to ingest new Fleet hosts from pending
// MDM enrollments. It updates the Fleet hosts table accordingly with the UDID and serial number of
// the device.
func MDMHostIngesterMiddleware(next http.Handler, ingester fleet.MDMHostIngester, logger kitlog.Logger) http.HandlerFunc {
// MDMCheckinMiddleware watches incoming requests in order to
// take actions on the different MDM check-in lifecycle
// events, this might include enrolling a new host during
// Authentication or adding activities on CheckOut.
func MDMCheckinMiddleware(next http.Handler, ds fleet.Datastore, logger kitlog.Logger) http.HandlerFunc {
logger = kitlog.With(logger, "component", "mdm-apple-host-ingester")
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := ingester.Ingest(ctx, r); err != nil {
if err := fleet.HandleMDMCheckinRequest(ctx, r, ds); err != nil {
level.Error(logger).Log("err", "ingest checkin request", "details", err)
sentry.CaptureException(err)
ctxerr.Handle(ctx, err)

View file

@ -756,7 +756,7 @@ the way that the Fleet server works.
mdmStorage,
scepStorage,
logger,
fleet.NewMDMAppleHostIngester(ds, logger),
ds,
); err != nil {
initFatal(err, "setup mdm apple services")
}

View file

@ -840,7 +840,7 @@ func TestCronActivitiesStreaming(t *testing.T) {
jsonRawMessage := json.RawMessage(details)
return &fleet.Activity{
ID: id,
ActorFullName: actorName,
ActorFullName: &actorName,
ActorID: &actorID,
ActorGravatar: &actorGravatar,
ActorEmail: &actorEmail,

View file

@ -507,6 +507,40 @@ This activity contains the following fields:
}
```
### Type `mdm_enrolled`
Generated when a host is enrolled in Fleet's MDM.
This activity contains the following fields:
- "host_serial": Serial number of the host.
- "installed_from_dep": Whether the host was enrolled via DEP.
#### Example
```json
{
"host_serial": "C08VQ2AXHT96",
"installed_from_dep": true
}
```
### Type `mdm_unenrolled`
Generated when a host is unenrolled from Fleet's MDM.
This activity contains the following fields:
- "host_serial": Serial number of the host.
- "installed_from_dep": Whether the host was enrolled via DEP.
#### Example
```json
{
"host_serial": "C08VQ2AXHT96",
"installed_from_dep": true
}
```
<meta name="pageOrderInSection" value="1400">

View file

@ -16,10 +16,18 @@ func (ds *Datastore) NewActivity(ctx context.Context, user *fleet.User, activity
if err != nil {
return ctxerr.Wrap(ctx, err, "marshaling activity details")
}
var userID *uint
var userName *string
if user != nil {
userID = &user.ID
userName = &user.Name
}
_, err = ds.writer.ExecContext(ctx,
`INSERT INTO activities (user_id, user_name, activity_type, details) VALUES(?,?,?,?)`,
user.ID,
user.Name,
userID,
userName,
activity.ActivityName(),
detailsBytes,
)

View file

@ -22,6 +22,7 @@ func TestActivity(t *testing.T) {
{"UsernameChange", testActivityUsernameChange},
{"New", testActivityNew},
{"ListActivitiesStreamed", testListActivitiesStreamed},
{"EmptyUser", testActivityEmptyUser},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -75,7 +76,7 @@ func testActivityUsernameChange(t *testing.T, ds *Datastore) {
activities, err := ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
assert.Len(t, activities, 2)
assert.Equal(t, "fullname", activities[0].ActorFullName)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
u.Name = "newname"
err = ds.SaveUser(context.Background(), u)
@ -84,7 +85,7 @@ func testActivityUsernameChange(t *testing.T, ds *Datastore) {
activities, err = ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
assert.Len(t, activities, 2)
assert.Equal(t, "newname", activities[0].ActorFullName)
assert.Equal(t, "newname", *activities[0].ActorFullName)
assert.Equal(t, "http://asd.com", *activities[0].ActorGravatar)
assert.Equal(t, "email@asd.com", *activities[0].ActorEmail)
@ -94,7 +95,7 @@ func testActivityUsernameChange(t *testing.T, ds *Datastore) {
activities, err = ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
assert.Len(t, activities, 2)
assert.Equal(t, "fullname", activities[0].ActorFullName)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Nil(t, activities[0].ActorGravatar)
}
@ -125,7 +126,7 @@ func testActivityNew(t *testing.T, ds *Datastore) {
activities, err := ds.ListActivities(context.Background(), opt)
require.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, "fullname", activities[0].ActorFullName)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Equal(t, "test1", activities[0].Type)
opt = fleet.ListActivitiesOptions{
@ -137,7 +138,7 @@ func testActivityNew(t *testing.T, ds *Datastore) {
activities, err = ds.ListActivities(context.Background(), opt)
require.NoError(t, err)
assert.Len(t, activities, 1)
assert.Equal(t, "fullname", activities[0].ActorFullName)
assert.Equal(t, "fullname", *activities[0].ActorFullName)
assert.Equal(t, "test2", activities[0].Type)
opt = fleet.ListActivitiesOptions{
@ -208,3 +209,13 @@ func testListActivitiesStreamed(t *testing.T, ds *Datastore) {
assert.Len(t, streamed, 1)
require.Equal(t, streamed[0], activities[0])
}
func testActivityEmptyUser(t *testing.T, ds *Datastore) {
require.NoError(t, ds.NewActivity(context.Background(), nil, dummyActivity{
name: "test1",
details: map[string]interface{}{"detail": 1, "sometext": "aaa"},
}))
activities, err := ds.ListActivities(context.Background(), fleet.ListActivitiesOptions{})
require.NoError(t, err)
assert.Len(t, activities, 1)
}

View file

@ -239,8 +239,12 @@ WHERE
}
func (ds *Datastore) IngestMDMAppleDeviceFromCheckin(ctx context.Context, mdmHost fleet.MDMAppleHostDetails) error {
appCfg, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config")
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
return ingestMDMAppleDeviceFromCheckinDB(ctx, tx, mdmHost, ds.logger)
return ingestMDMAppleDeviceFromCheckinDB(ctx, tx, mdmHost, ds.logger, appCfg)
})
}
@ -249,6 +253,7 @@ func ingestMDMAppleDeviceFromCheckinDB(
tx sqlx.ExtContext,
mdmHost fleet.MDMAppleHostDetails,
logger log.Logger,
appCfg *fleet.AppConfig,
) error {
if mdmHost.SerialNumber == "" {
return ctxerr.New(ctx, "ingest mdm apple host from checkin expected device serial number but got empty string")
@ -263,7 +268,7 @@ func ingestMDMAppleDeviceFromCheckinDB(
err := sqlx.GetContext(ctx, tx, &foundHost, stmt, mdmHost.UDID, mdmHost.SerialNumber)
switch {
case errors.Is(err, sql.ErrNoRows):
return insertMDMAppleHostDB(ctx, tx, mdmHost, logger)
return insertMDMAppleHostDB(ctx, tx, mdmHost, logger, appCfg)
case err != nil:
return ctxerr.Wrap(ctx, err, "get mdm apple host by serial number or udid")
@ -308,7 +313,13 @@ func updateMDMAppleHostDB(ctx context.Context, tx sqlx.ExtContext, hostID uint,
return nil
}
func insertMDMAppleHostDB(ctx context.Context, tx sqlx.ExtContext, mdmHost fleet.MDMAppleHostDetails, logger log.Logger) error {
func insertMDMAppleHostDB(
ctx context.Context,
tx sqlx.ExtContext,
mdmHost fleet.MDMAppleHostDetails,
logger log.Logger,
appCfg *fleet.AppConfig,
) error {
insertStmt := `
INSERT INTO hosts (
hardware_serial,
@ -346,13 +357,16 @@ func insertMDMAppleHostDB(ctx context.Context, tx sqlx.ExtContext, mdmHost fleet
}
if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, uint(id)); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert related tables")
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names")
}
if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, logger, uint(id)); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert related tables")
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership")
}
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg.ServerSettings.ServerURL, false, uint(id)); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info")
}
return nil
}
@ -365,8 +379,13 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devic
return 0, nil
}
appCfg, err := ds.AppConfig(ctx)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "ingest mdm apple host get app config")
}
var resCount int64
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
err = ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
us, args := unionSelectDevices(filteredDevices)
stmt := fmt.Sprintf(`
@ -416,11 +435,15 @@ func (ds *Datastore) IngestMDMAppleDevicesFromDEPSync(ctx context.Context, devic
}
if err := upsertMDMAppleHostDisplayNamesDB(ctx, tx, hostIDs...); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert related tables")
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert display names")
}
if err := upsertMDMAppleHostLabelMembershipDB(ctx, tx, ds.logger, hostIDs...); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert related tables")
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert label membership")
}
if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg.ServerSettings.ServerURL, true, hostIDs...); err != nil {
return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info")
}
return nil
@ -448,6 +471,38 @@ func upsertMDMAppleHostDisplayNamesDB(ctx context.Context, tx sqlx.ExtContext, h
return nil
}
func upsertMDMAppleHostMDMInfoDB(ctx context.Context, tx sqlx.ExtContext, serverURL string, fromSync bool, hostIDs ...uint) error {
result, err := tx.ExecContext(ctx, `
INSERT INTO mobile_device_management_solutions (name, server_url) VALUES (?, ?)
ON DUPLICATE KEY UPDATE server_url = VALUES(server_url)`,
fleet.WellKnownMDMFleet, serverURL)
if err != nil {
return ctxerr.Wrap(ctx, err, "last insert id mdm apple host")
}
mdmID, err := result.LastInsertId()
if err != nil {
return ctxerr.Wrap(ctx, err, "last insert id mdm apple host")
}
// if the device is coming from the DEP sync, we don't consider it enrolled
// yet.
enrolled := !fromSync
args := []interface{}{}
parts := []string{}
for _, id := range hostIDs {
args = append(args, enrolled, serverURL, fromSync, mdmID, false, id)
parts = append(parts, "(?, ?, ?, ?, ?, ?)")
}
_, err = tx.ExecContext(ctx, fmt.Sprintf(`
INSERT INTO host_mdm (enrolled, server_url, installed_from_dep, mdm_id, is_server, host_id) VALUES %s
ON DUPLICATE KEY UPDATE enrolled = VALUES(enrolled)`, strings.Join(parts, ",")), args...)
return ctxerr.Wrap(ctx, err, "upsert host mdm info")
}
func upsertMDMAppleHostLabelMembershipDB(ctx context.Context, tx sqlx.ExtContext, logger log.Logger, hostIDs ...uint) error {
// Builtin label memberships are usually inserted when the first distributed
// query results are received; however, we want to insert pending MDM hosts
@ -484,6 +539,16 @@ func upsertMDMAppleHostLabelMembershipDB(ctx context.Context, tx sqlx.ExtContext
return nil
}
func (ds *Datastore) UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid string) error {
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
_, err := tx.ExecContext(ctx, `
UPDATE host_mdm
SET enrolled = 0
WHERE host_id = (SELECT id FROM hosts WHERE uuid = ?)`, uuid)
return err
})
}
func filterMDMAppleDevices(devices []godep.Device) []godep.Device {
var filtered []godep.Device
for _, device := range devices {

View file

@ -12,7 +12,6 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
kitlog "github.com/go-kit/kit/log"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/godep"
"github.com/stretchr/testify/require"
@ -32,7 +31,7 @@ func TestIngestMDMAppleDevicesFromDEPSync(t *testing.T) {
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(fmt.Sprintf("uuid_%d", i)),
UUID: fmt.Sprintf("uuid_%d", i),
HardwareSerial: fmt.Sprintf("serial_%d", i),
})
require.NoError(t, err)
@ -72,17 +71,18 @@ func TestIngestMDMAppleDevicesFromDEPSync(t *testing.T) {
require.ElementsMatch(t, wantSerials, gotSerials)
}
func TestIngestMDMAppleDeviceFromCheckin(t *testing.T) {
func TestHandleMDMCheckinRequest(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"TestIngestMDMAppleCheckinHostAlreadyExistsInFleet", testIngestMDMAppleHostAlreadyExistsInFleet},
{"TestIngestMDMApplCheckinAfterDEPSync", testIngestMDMAppleCheckinAfterDEPSync},
{"TestIngestMDMApplCheckinBeforeDEPSync", testIngestMDMAppleCheckinBeforeDEPSync},
{"TestIngestMDMAppleCheckinMultipleAuthenticateRequests", testIngestMDMAppleCheckinMultipleAuthenticateRequests},
{"TestHostAlreadyExistsInFleet", testIngestMDMAppleHostAlreadyExistsInFleet},
{"TestCheckinAfterDEPSync", testIngestMDMAppleCheckinAfterDEPSync},
{"TestBeforeDEPSync", testIngestMDMAppleCheckinBeforeDEPSync},
{"TestMultipleAuthenticateRequests", testIngestMDMAppleCheckinMultipleAuthenticateRequests},
{"TestCheckOutRequests", testIngestMDMAppleCheckinCheckoutRequests},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -96,12 +96,10 @@ func TestIngestMDMAppleDeviceFromCheckin(t *testing.T) {
func testIngestMDMAppleHostAlreadyExistsInFleet(t *testing.T, ds *Datastore) {
ctx := context.Background()
ingester := fleet.NewMDMAppleHostIngester(ds, kitlog.NewNopLogger())
testSerial := "test-serial"
testUUID := "test-uuid"
_, err := ds.NewHost(ctx, &fleet.Host{
host, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "test-host-name",
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
@ -113,27 +111,38 @@ func testIngestMDMAppleHostAlreadyExistsInFleet(t *testing.T, ds *Datastore) {
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 = ingester.Ingest(ctx, &http.Request{
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()
ingester := fleet.NewMDMAppleHostIngester(ds, kitlog.NewNopLogger())
testSerial := "test-serial"
testUUID := "test-uuid"
@ -152,37 +161,57 @@ func testIngestMDMAppleCheckinAfterDEPSync(t *testing.T, ds *Datastore) {
checkMDMHostRelatedTables(t, ds, hosts[0].ID)
// now simulate the initial MDM checkin by that same host
err = ingester.Ingest(ctx, &http.Request{
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)
// 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()
ingester := fleet.NewMDMAppleHostIngester(ds, kitlog.NewNopLogger())
testSerial := "test-serial"
testUUID := "test-uuid"
// ingest host on initial mdm checkin
err := ingester.Ingest(ctx, &http.Request{
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)
@ -203,37 +232,70 @@ func testIngestMDMAppleCheckinBeforeDEPSync(t *testing.T, ds *Datastore) {
func testIngestMDMAppleCheckinMultipleAuthenticateRequests(t *testing.T, ds *Datastore) {
ctx := context.Background()
ingester := fleet.NewMDMAppleHostIngester(ds, kitlog.NewNopLogger())
testSerial := "test-serial"
testUUID := "test-uuid"
err := ingester.Ingest(ctx, &http.Request{
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 = ingester.Ingest(ctx, &http.Request{
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
@ -250,6 +312,21 @@ func checkMDMHostRelatedTables(t *testing.T, ds *Datastore, hostID uint) {
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 {

View file

@ -2234,6 +2234,26 @@ func (ds *Datastore) GetHostMDM(ctx context.Context, hostID uint) (*fleet.HostMD
return &hmdm, nil
}
func (ds *Datastore) GetHostMDMCheckinInfo(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
var hmdm fleet.HostMDMCheckinInfo
err := sqlx.GetContext(ctx, ds.reader, &hmdm, `
SELECT
h.hardware_serial, hm.installed_from_dep
FROM
hosts h
JOIN
host_mdm hm
ON h.id = hm.host_id
WHERE h.uuid = ?`, hostUUID)
if err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("MDM").WithMessage(hostUUID))
}
return nil, ctxerr.Wrapf(ctx, err, "getting data from host_mdm for host_uuid %s", hostUUID)
}
return &hmdm, nil
}
func (ds *Datastore) GetMDMSolution(ctx context.Context, mdmID uint) (*fleet.MDMSolution, error) {
var solution fleet.MDMSolution
err := sqlx.GetContext(ctx, ds.reader, &solution, `

View file

@ -131,6 +131,7 @@ func TestHosts(t *testing.T) {
{"HostIDsByOSID", testHostIDsByOSID},
{"SetOrUpdateHostDisksEncryption", testHostsSetOrUpdateHostDisksEncryption},
{"TestHostOrder", testHostOrder},
{"GetHostMDMCheckinInfo", testHostsGetHostMDMCheckinInfo},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@ -5561,3 +5562,28 @@ func testHostsSetOrUpdateHostDisksEncryption(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, *h.DiskEncryptionEnabled)
}
func testHostsGetHostMDMCheckinInfo(t *testing.T, ds *Datastore) {
ctx := context.Background()
host, err := ds.NewHost(context.Background(), &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
NodeKey: ptr.String("1"),
UUID: "1",
OsqueryHostID: ptr.String("1"),
Hostname: "foo.local",
PrimaryIP: "192.168.1.1",
PrimaryMac: "30-65-EC-6F-C4-58",
HardwareSerial: "123456789",
})
require.NoError(t, err)
err = ds.SetOrUpdateMDMData(ctx, host.ID, false, true, "https://fleetdm.com", true, fleet.WellKnownMDMFleet)
require.NoError(t, err)
info, err := ds.GetHostMDMCheckinInfo(ctx, host.UUID)
require.NoError(t, err)
require.Equal(t, host.HardwareSerial, info.HardwareSerial)
require.Equal(t, true, info.InstalledFromDEP)
}

View file

@ -41,6 +41,9 @@ var ActivityDetailsList = []ActivityDetails{
ActivityTypeDeletedUserGlobalRole{},
ActivityTypeChangedUserTeamRole{},
ActivityTypeDeletedUserTeamRole{},
ActivityTypeMDMEnrolled{},
ActivityTypeMDMUnenrolled{},
}
type ActivityDetails interface {
@ -429,7 +432,7 @@ func (a ActivityTypeUserAddedBySSO) Documentation() (activity string, details st
type Activity struct {
CreateTimestamp
ID uint `json:"id" db:"id"`
ActorFullName string `json:"actor_full_name" db:"name"`
ActorFullName *string `json:"actor_full_name" db:"name"`
ActorID *uint `json:"actor_id" db:"user_id"`
ActorGravatar *string `json:"actor_gravatar" db:"gravatar_url"`
ActorEmail *string `json:"actor_email" db:"email"`
@ -610,6 +613,44 @@ func (a ActivityTypeDeletedUserTeamRole) Documentation() (activity string, detai
}`
}
type ActivityTypeMDMEnrolled struct {
HostSerial string `json:"host_serial"`
InstalledFromDEP bool `json:"installed_from_dep"`
}
func (a ActivityTypeMDMEnrolled) ActivityName() string {
return "mdm_enrolled"
}
func (a ActivityTypeMDMEnrolled) Documentation() (activity string, details string, detailsExample string) {
return `Generated when a host is enrolled in Fleet's MDM.`,
`This activity contains the following fields:
- "host_serial": Serial number of the host.
- "installed_from_dep": Whether the host was enrolled via DEP.`, `{
"host_serial": "C08VQ2AXHT96",
"installed_from_dep": true
}`
}
type ActivityTypeMDMUnenrolled struct {
HostSerial string `json:"host_serial"`
InstalledFromDEP bool `json:"installed_from_dep"`
}
func (a ActivityTypeMDMUnenrolled) ActivityName() string {
return "mdm_unenrolled"
}
func (a ActivityTypeMDMUnenrolled) Documentation() (activity string, details string, detailsExample string) {
return `Generated when a host is unenrolled from Fleet's MDM.`,
`This activity contains the following fields:
- "host_serial": Serial number of the host.
- "installed_from_dep": Whether the host was enrolled via DEP.`, `{
"host_serial": "C08VQ2AXHT96",
"installed_from_dep": true
}`
}
// AuthzType implement AuthzTyper to be able to verify access to activities
func (*Activity) AuthzType() string {
return "activity"

View file

@ -7,7 +7,6 @@ import (
"net/http"
"strings"
kitlog "github.com/go-kit/kit/log"
"github.com/micromdm/nanodep/godep"
nanohttp "github.com/micromdm/nanomdm/http"
"github.com/micromdm/nanomdm/mdm"
@ -188,43 +187,59 @@ type MDMAppleHostDetails struct {
Model string
}
// MDMHostIngester represents the interface used to ingest an MDM device as a Fleet host pending enrollment.
type MDMHostIngester interface {
Ingest(context.Context, *http.Request) error
}
// MDMAppleHostIngester implements the MDMHostIngester interface in connection with Apple MDM services.
type MDMAppleHostIngester struct {
ds Datastore
logger kitlog.Logger
}
// NewMDMAppleHostIngester returns a new instance of an MDMAppleHostIngester.
func NewMDMAppleHostIngester(ds Datastore, logger kitlog.Logger) *MDMAppleHostIngester {
return &MDMAppleHostIngester{ds: ds, logger: logger}
}
// Ingest handles incoming http requests that follow Apple's MDM checkin protocol. For valid
// checkin requests, Ingest decodes the XML body and ingests new host details into the associated
// datastore. See also https://developer.apple.com/documentation/devicemanagement/check-in.
func (ingester *MDMAppleHostIngester) Ingest(ctx context.Context, r *http.Request) error {
// HandleMDMCheckinRequest handles incoming http requests
// that follow Apple's MDM checkin protocol.
//
// - For valid Authenticate checkin requests, it decodes
// the XML body and ingests new host details into the
// associated datastore.
// - For TokenUpdate and CheckOut requests it creates
// activity logs.
//
// See also
// https://developer.apple.com/documentation/devicemanagement/check-in.
func HandleMDMCheckinRequest(ctx context.Context, r *http.Request, ds Datastore) error {
if isMDMAppleCheckinReq(r) {
host := MDMAppleHostDetails{}
ok, err := decodeMDMAppleCheckinReq(r, &host)
switch {
case err != nil:
msg, err := decodeMDMAppleCheckinReq(r)
if err != nil {
return fmt.Errorf("decode checkin request: %w", err)
case !ok:
return nil
default:
// continue
}
if err := ingester.ds.IngestMDMAppleDeviceFromCheckin(ctx, host); err != nil {
return err
switch m := msg.(type) {
case *mdm.Authenticate:
host := MDMAppleHostDetails{}
host.SerialNumber = m.SerialNumber
host.UDID = m.UDID
host.Model = m.Model
if err := ds.IngestMDMAppleDeviceFromCheckin(ctx, host); err != nil {
return err
}
info, err := ds.GetHostMDMCheckinInfo(ctx, m.Enrollment.UDID)
if err != nil {
return err
}
return ds.NewActivity(ctx, nil, &ActivityTypeMDMEnrolled{
HostSerial: info.HardwareSerial,
InstalledFromDEP: info.InstalledFromDEP,
})
case *mdm.CheckOut:
if err := ds.UpdateHostTablesOnMDMUnenroll(ctx, m.UDID); err != nil {
return err
}
info, err := ds.GetHostMDMCheckinInfo(ctx, m.Enrollment.UDID)
if err != nil {
return err
}
return ds.NewActivity(ctx, nil, &ActivityTypeMDMUnenrolled{
HostSerial: info.HardwareSerial,
InstalledFromDEP: info.InstalledFromDEP,
})
default:
// these aren't the requests you're looking for, move along
return nil
}
}
return nil
}
@ -233,23 +248,15 @@ func isMDMAppleCheckinReq(r *http.Request) bool {
return strings.HasPrefix(contentType, "application/x-apple-aspen-mdm-checkin")
}
func decodeMDMAppleCheckinReq(r *http.Request, dest *MDMAppleHostDetails) (bool, error) {
func decodeMDMAppleCheckinReq(r *http.Request) (interface{}, error) {
bodyBytes, err := nanohttp.ReadAllAndReplaceBody(r)
if err != nil {
return false, err
return nil, err
}
msg, err := mdm.DecodeCheckin(bodyBytes)
if err != nil {
return false, err
}
switch m := msg.(type) {
case *mdm.Authenticate:
dest.SerialNumber = m.SerialNumber
dest.UDID = m.UDID
dest.Model = m.Model
return true, nil
default:
// these aren't the requests you're looking for, move along
return false, nil
return nil, err
}
return msg, nil
}

View file

@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/micromdm/nanomdm/mdm"
"github.com/stretchr/testify/require"
)
@ -34,7 +35,6 @@ func TestDecodeMDMAppleCheckinRequest(t *testing.T) {
testSerial := "test-serial"
testUDID := "test-udid"
// decode host details from request XML if MessageType is "Authenticate"
req := &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
@ -42,28 +42,14 @@ func TestDecodeMDMAppleCheckinRequest(t *testing.T) {
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("Authenticate", testSerial, testUDID, "MacBook Pro"))),
}
host := &MDMAppleHostDetails{}
ok, err := decodeMDMAppleCheckinReq(req, host)
msg, err := decodeMDMAppleCheckinReq(req)
require.NoError(t, err)
require.NotNil(t, msg)
msgAuth, ok := msg.(*mdm.Authenticate)
require.True(t, ok)
require.Equal(t, testSerial, host.SerialNumber)
require.Equal(t, testUDID, host.UDID)
require.Equal(t, "MacBook Pro", host.Model)
// do nothing if MessageType is not "Authenticate"
req = &http.Request{
Header: map[string][]string{
"Content-Type": {"application/x-apple-aspen-mdm-checkin"},
},
Method: http.MethodPost,
Body: io.NopCloser(strings.NewReader(xmlForTest("TokenUpdate", testSerial, testUDID, "MacBook Pro"))),
}
host = &MDMAppleHostDetails{}
ok, err = decodeMDMAppleCheckinReq(req, host)
require.NoError(t, err)
require.True(t, !ok)
require.Empty(t, host.SerialNumber)
require.Empty(t, host.UDID)
require.Equal(t, testSerial, msgAuth.SerialNumber)
require.Equal(t, testUDID, msgAuth.UDID)
require.Equal(t, "MacBook Pro", msgAuth.Model)
}
func xmlForTest(msgType string, serial string, udid string, model string) string {

View file

@ -254,6 +254,7 @@ type Datastore interface {
GetHostMunkiVersion(ctx context.Context, hostID uint) (string, error)
GetHostMunkiIssues(ctx context.Context, hostID uint) ([]*HostMunkiIssue, error)
GetHostMDM(ctx context.Context, hostID uint) (*HostMDM, error)
GetHostMDMCheckinInfo(ctx context.Context, hostUUID string) (*HostMDMCheckinInfo, error)
AggregatedMunkiVersion(ctx context.Context, teamID *uint) ([]AggregatedMunkiVersion, time.Time, error)
AggregatedMunkiIssues(ctx context.Context, teamID *uint) ([]AggregatedMunkiIssue, time.Time, error)
@ -437,6 +438,8 @@ type Datastore interface {
// upgraded from a prior version).
CleanupHostOperatingSystems(ctx context.Context) error
UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid string) error
///////////////////////////////////////////////////////////////////////////////
// ActivitiesStore

View file

@ -398,6 +398,7 @@ const (
WellKnownMDMVMWare = "VMware Workspace ONE"
WellKnownMDMIntune = "Intune"
WellKnownMDMSimpleMDM = "SimpleMDM"
WellKnownMDMFleet = "Fleet"
)
var mdmNameFromServerURLChecks = map[string]string{
@ -564,3 +565,8 @@ type EnrollHostLimiter interface {
CanEnrollNewHost(ctx context.Context) (ok bool, err error)
SyncEnrolledHostIDs(ctx context.Context) error
}
type HostMDMCheckinInfo struct {
HardwareSerial string `json:"hardware_serial" db:"hardware_serial"`
InstalledFromDEP bool `json:"installed_from_dep" db:"installed_from_dep"`
}

View file

@ -200,6 +200,8 @@ type GetHostMunkiIssuesFunc func(ctx context.Context, hostID uint) ([]*fleet.Hos
type GetHostMDMFunc func(ctx context.Context, hostID uint) (*fleet.HostMDM, error)
type GetHostMDMCheckinInfoFunc func(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error)
type AggregatedMunkiVersionFunc func(ctx context.Context, teamID *uint) ([]fleet.AggregatedMunkiVersion, time.Time, error)
type AggregatedMunkiIssuesFunc func(ctx context.Context, teamID *uint) ([]fleet.AggregatedMunkiIssue, time.Time, error)
@ -336,6 +338,8 @@ type UpdateHostOperatingSystemFunc func(ctx context.Context, hostID uint, hostOS
type CleanupHostOperatingSystemsFunc func(ctx context.Context) error
type UpdateHostTablesOnMDMUnenrollFunc func(ctx context.Context, uuid string) error
type NewActivityFunc func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error
type ListActivitiesFunc func(ctx context.Context, opt fleet.ListActivitiesOptions) ([]*fleet.Activity, error)
@ -800,6 +804,9 @@ type DataStore struct {
GetHostMDMFunc GetHostMDMFunc
GetHostMDMFuncInvoked bool
GetHostMDMCheckinInfoFunc GetHostMDMCheckinInfoFunc
GetHostMDMCheckinInfoFuncInvoked bool
AggregatedMunkiVersionFunc AggregatedMunkiVersionFunc
AggregatedMunkiVersionFuncInvoked bool
@ -1004,6 +1011,9 @@ type DataStore struct {
CleanupHostOperatingSystemsFunc CleanupHostOperatingSystemsFunc
CleanupHostOperatingSystemsFuncInvoked bool
UpdateHostTablesOnMDMUnenrollFunc UpdateHostTablesOnMDMUnenrollFunc
UpdateHostTablesOnMDMUnenrollFuncInvoked bool
NewActivityFunc NewActivityFunc
NewActivityFuncInvoked bool
@ -1746,6 +1756,11 @@ func (s *DataStore) GetHostMDM(ctx context.Context, hostID uint) (*fleet.HostMDM
return s.GetHostMDMFunc(ctx, hostID)
}
func (s *DataStore) GetHostMDMCheckinInfo(ctx context.Context, hostUUID string) (*fleet.HostMDMCheckinInfo, error) {
s.GetHostMDMCheckinInfoFuncInvoked = true
return s.GetHostMDMCheckinInfoFunc(ctx, hostUUID)
}
func (s *DataStore) AggregatedMunkiVersion(ctx context.Context, teamID *uint) ([]fleet.AggregatedMunkiVersion, time.Time, error) {
s.AggregatedMunkiVersionFuncInvoked = true
return s.AggregatedMunkiVersionFunc(ctx, teamID)
@ -2086,6 +2101,11 @@ func (s *DataStore) CleanupHostOperatingSystems(ctx context.Context) error {
return s.CleanupHostOperatingSystemsFunc(ctx)
}
func (s *DataStore) UpdateHostTablesOnMDMUnenroll(ctx context.Context, uuid string) error {
s.UpdateHostTablesOnMDMUnenrollFuncInvoked = true
return s.UpdateHostTablesOnMDMUnenrollFunc(ctx, uuid)
}
func (s *DataStore) NewActivity(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
s.NewActivityFuncInvoked = true
return s.NewActivityFunc(ctx, user, activity)

View file

@ -190,7 +190,7 @@ func (s *integrationTestSuite) TestQueryCreationLogsActivity() {
for _, activity := range activities.Activities {
if activity.Type == "created_saved_query" {
found = true
assert.Equal(t, "Test Name admin1@example.com", activity.ActorFullName)
assert.Equal(t, "Test Name admin1@example.com", *activity.ActorFullName)
require.NotNil(t, activity.ActorGravatar)
assert.Equal(t, "http://iii.com", *activity.ActorGravatar)
}