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)