45640 Add activity feed entry when a custom Apple or Windows MDM command is run (#47743)
**Related issue:** Resolves #45640 - [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. ## 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 ## Release Notes * **New Features** * Running custom MDM commands on Apple or Windows devices now creates activity log entries that appear in both the global activity feed and host-specific activity feeds. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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.
|
||||
@@ -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"`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+38
-2
@@ -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.
|
||||
|
||||
@@ -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 := `<Exec>
|
||||
<CmdID>1</CmdID>
|
||||
<Item>
|
||||
<Target>
|
||||
<LocURI>./FooBar</LocURI>
|
||||
</Target>
|
||||
</Item>
|
||||
</Exec>`
|
||||
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 := `<?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>CommandUUID</key>
|
||||
<string>test-partial-fail-001</string>
|
||||
<key>Command</key>
|
||||
<dict>
|
||||
<key>RequestType</key>
|
||||
<string>ShutDownDevice</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>`
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user