diff --git a/changes/45640-apple-windows-mdm-command-activities b/changes/45640-apple-windows-mdm-command-activities new file mode 100644 index 0000000000..262d91e57d --- /dev/null +++ b/changes/45640-apple-windows-mdm-command-activities @@ -0,0 +1 @@ +* Add activity feed entry when a user runs a custom Apple or Windows MDM command, visible in both the global activity feed and the host's activity feed. diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 0ca5689ab7..04b42dfeab 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -836,6 +836,27 @@ func (a ActivityTypeRanScript) WasFromAutomation() bool { return a.PolicyID != nil || a.FromSetupExperience } +type ActivityTypeRanCustomMDMCommand struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` + HostUUID string `json:"host_uuid"` + CommandUUID string `json:"command_uuid"` + RequestType string `json:"request_type"` + Platform string `json:"platform"` +} + +func (a ActivityTypeRanCustomMDMCommand) ActivityName() string { + return "ran_custom_mdm_command" +} + +func (a ActivityTypeRanCustomMDMCommand) HostIDs() []uint { + return []uint{a.HostID} +} + +func (a ActivityTypeRanCustomMDMCommand) HostOnly() bool { + return false +} + type ActivityTypeAddedScript struct { ScriptName string `json:"script_name"` TeamID *uint `json:"team_id" renameto:"fleet_id"` diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index 7da8283292..a78f304799 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -9796,6 +9796,14 @@ func (s *integrationMDMTestSuite) TestRunMDMCommands() { require.NotEmpty(t, runResp.CommandUUID) require.Equal(t, "windows", runResp.Platform) require.Equal(t, "./SetValues", runResp.RequestType) + s.lastActivityMatches(fleet.ActivityTypeRanCustomMDMCommand{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": %q, + "host_uuid": %q, + "command_uuid": %q, + "request_type": "./SetValues", + "platform": "windows" + }`, enrolledWindows.ID, enrolledWindows.DisplayName(), enrolledWindows.UUID, runResp.CommandUUID), 0) // valid macOS runResp = runMDMCommandResponse{} @@ -9806,6 +9814,14 @@ func (s *integrationMDMTestSuite) TestRunMDMCommands() { require.NotEmpty(t, runResp.CommandUUID) require.Equal(t, "darwin", runResp.Platform) require.Equal(t, "ShutDownDevice", runResp.RequestType) + s.lastActivityMatches(fleet.ActivityTypeRanCustomMDMCommand{}.ActivityName(), fmt.Sprintf(`{ + "host_id": %d, + "host_display_name": %q, + "host_uuid": %q, + "command_uuid": %q, + "request_type": "ShutDownDevice", + "platform": "darwin" + }`, enrolledMac.ID, enrolledMac.DisplayName(), enrolledMac.UUID, runResp.CommandUUID), 0) } func (s *integrationMDMTestSuite) TestUpdateMDMWindowsEnrollmentsHostUUID() { diff --git a/server/service/mdm.go b/server/service/mdm.go index 80ce6054d4..1280c38de6 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -579,13 +579,49 @@ func (svc *Service) RunMDMCommand(ctx context.Context, rawBase64Cmd string, host } } + // Use UUIDs from the resolved hosts so the enqueue and activity creation + // operate on the same validated set, not the raw (potentially duplicate or + // unknown) request input. + resolvedUUIDs := make([]string, len(hosts)) + for i, h := range hosts { + resolvedUUIDs[i] = h.UUID + } + // the rest is platform-specific (validation of command payload, enqueueing, etc.) switch commandPlatform { case "windows": - return svc.enqueueMicrosoftMDMCommand(ctx, rawXMLCmd, hostUUIDs) + result, err = svc.enqueueMicrosoftMDMCommand(ctx, rawXMLCmd, resolvedUUIDs) default: - return svc.enqueueAppleMDMCommand(ctx, rawXMLCmd, hostUUIDs) + result, err = svc.enqueueAppleMDMCommand(ctx, rawXMLCmd, resolvedUUIDs) } + if err != nil { + return nil, err + } + + failedUUIDs := make(map[string]struct{}, len(result.FailedUUIDs)) + for _, uuid := range result.FailedUUIDs { + failedUUIDs[uuid] = struct{}{} + } + for _, h := range hosts { + if _, failed := failedUUIDs[h.UUID]; failed { + continue + } + if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), &fleet.ActivityTypeRanCustomMDMCommand{ + HostID: h.ID, + HostDisplayName: h.DisplayName(), + HostUUID: h.UUID, + CommandUUID: result.CommandUUID, + RequestType: result.RequestType, + Platform: commandPlatform, + }); err != nil { + // Activity logging is best-effort: the command was already enqueued + // successfully, so returning an error here could cause clients to retry + // and send duplicate MDM commands to devices. + svc.logger.ErrorContext(ctx, "failed to log activity for ran custom mdm command", "err", err, "host_uuid", h.UUID) + } + } + + return result, nil } // validateAppleMDMCommand validates an Apple MDM command before it is enqueued. diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index 076340a3f7..5de2be15df 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -29,7 +29,10 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/microsoft/syncml" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" "github.com/fleetdm/fleet/v4/server/mdm/nanodep/tokenpki" + nanomdm_mdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" + mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" @@ -649,6 +652,159 @@ func TestRunMDMCommandValidations(t *testing.T) { } } +func TestRunMDMCommandCreatesActivity(t *testing.T) { + ds := new(mock.Store) + opts := &TestServerOpts{SkipCreateTestUsers: true} + svc, ctx := newTestService(t, ds, nil, nil, opts) + ctx = test.UserContext(ctx, test.UserAdmin) + + windowsHost := &fleet.Host{ + ID: 42, + UUID: "win-uuid-1", + Platform: "windows", + Hostname: "DESKTOP-TEST", + ComputerName: "DESKTOP-TEST", + } + + ds.ListHostsLiteByUUIDsFunc = func(_ context.Context, _ fleet.TeamFilter, _ []string) ([]*fleet.Host, error) { + return []*fleet.Host{windowsHost}, nil + } + ds.AreHostsConnectedToFleetMDMFunc = func(_ context.Context, _ []*fleet.Host) (map[string]bool, error) { + return map[string]bool{windowsHost.UUID: true}, nil + } + ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{WindowsEnabledAndConfigured: true}, + }, nil + } + ds.MDMWindowsInsertCommandForHostsFunc = func(_ context.Context, _ []string, _ *fleet.MDMWindowsCommand) error { + return nil + } + + var capturedUser *activity_api.User + var capturedActivity activity_api.ActivityDetails + opts.ActivityMock.NewActivityFunc = func(_ context.Context, u *activity_api.User, act activity_api.ActivityDetails) error { + capturedUser = u + capturedActivity = act + return nil + } + + rawCmd := ` + 1 + + + ./FooBar + + + ` + encoded := base64.StdEncoding.EncodeToString([]byte(rawCmd)) + + _, err := svc.RunMDMCommand(ctx, encoded, []string{windowsHost.UUID}) + require.NoError(t, err) + + require.True(t, opts.ActivityMock.NewActivityFuncInvoked) + require.NotNil(t, capturedActivity) + + act, ok := capturedActivity.(*fleet.ActivityTypeRanCustomMDMCommand) + require.True(t, ok, "expected *fleet.ActivityTypeRanCustomMDMCommand, got %T", capturedActivity) + assert.Equal(t, windowsHost.ID, act.HostID) + assert.Equal(t, windowsHost.DisplayName(), act.HostDisplayName) + assert.Equal(t, windowsHost.UUID, act.HostUUID) + assert.Equal(t, "./FooBar", act.RequestType) + assert.Equal(t, "windows", act.Platform) + assert.NotEmpty(t, act.CommandUUID) + + require.NotNil(t, capturedUser) + assert.Equal(t, test.UserAdmin.ID, capturedUser.ID) + assert.Equal(t, test.UserAdmin.Email, capturedUser.Email) +} + +// mockAPNSPusher implements nanomdm_push.Pusher for unit tests, returning a +// push failure for any UUID in failUUIDs and success for all others. +type mockAPNSPusher struct { + failUUIDs map[string]bool +} + +func (m *mockAPNSPusher) Push(_ context.Context, ids []string) (map[string]*nanomdm_push.Response, error) { + result := make(map[string]*nanomdm_push.Response, len(ids)) + for _, id := range ids { + if m.failUUIDs[id] { + result[id] = &nanomdm_push.Response{Err: errors.New("push failed")} + } else { + result[id] = &nanomdm_push.Response{} + } + } + return result, nil +} + +func TestRunMDMCommandSkipsActivityForFailedHosts(t *testing.T) { + ds := new(mock.Store) + + mdmStorage := &mdmmock.MDMAppleStore{} + mdmStorage.EnqueueCommandFunc = func(_ context.Context, _ []string, _ *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) { + return nil, nil + } + + host1 := &fleet.Host{ID: 1, UUID: "apple-uuid-1", Platform: "darwin", Hostname: "mac1", ComputerName: "mac1"} + host2 := &fleet.Host{ID: 2, UUID: "apple-uuid-2", Platform: "darwin", Hostname: "mac2", ComputerName: "mac2"} + + opts := &TestServerOpts{ + SkipCreateTestUsers: true, + MDMStorage: mdmStorage, + MDMPusher: &mockAPNSPusher{failUUIDs: map[string]bool{host2.UUID: true}}, + } + svc, ctx := newTestService(t, ds, nil, nil, opts) + ctx = test.UserContext(ctx, test.UserAdmin) + + ds.ListHostsLiteByUUIDsFunc = func(_ context.Context, _ fleet.TeamFilter, _ []string) ([]*fleet.Host, error) { + return []*fleet.Host{host1, host2}, nil + } + ds.AreHostsConnectedToFleetMDMFunc = func(_ context.Context, _ []*fleet.Host) (map[string]bool, error) { + return map[string]bool{host1.UUID: true, host2.UUID: true}, nil + } + ds.AppConfigFunc = func(_ context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + + var capturedActivities []*fleet.ActivityTypeRanCustomMDMCommand + var capturedUsers []*activity_api.User + opts.ActivityMock.NewActivityFunc = func(_ context.Context, u *activity_api.User, act activity_api.ActivityDetails) error { + if a, ok := act.(*fleet.ActivityTypeRanCustomMDMCommand); ok { + capturedActivities = append(capturedActivities, a) + capturedUsers = append(capturedUsers, u) + } + return nil + } + + rawCmd := ` + + + + CommandUUID + test-partial-fail-001 + Command + + RequestType + ShutDownDevice + + +` + encoded := base64.StdEncoding.EncodeToString([]byte(rawCmd)) + + _, err := svc.RunMDMCommand(ctx, encoded, []string{host1.UUID, host2.UUID}) + require.NoError(t, err) + + require.Len(t, capturedActivities, 1, "expected activity for 1 successful host only") + assert.Equal(t, host1.ID, capturedActivities[0].HostID) + assert.Equal(t, host1.UUID, capturedActivities[0].HostUUID) + assert.Equal(t, "ShutDownDevice", capturedActivities[0].RequestType) + assert.Equal(t, "darwin", capturedActivities[0].Platform) + + require.NotNil(t, capturedUsers[0]) + assert.Equal(t, test.UserAdmin.ID, capturedUsers[0].ID) + assert.Equal(t, test.UserAdmin.Email, capturedUsers[0].Email) +} + func TestRunMDMCommandSetRecoveryLockBlocked(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil)