diff --git a/changes/10488-remote-wipe b/changes/10488-remote-wipe new file mode 100644 index 0000000000..e180b89c90 --- /dev/null +++ b/changes/10488-remote-wipe @@ -0,0 +1 @@ +* Added the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host. diff --git a/changes/issue-10489-ui-for-wiping-host b/changes/issue-10489-ui-for-wiping-host new file mode 100644 index 0000000000..066cc8f06e --- /dev/null +++ b/changes/issue-10489-ui-for-wiping-host @@ -0,0 +1 @@ +- add UI for wiping a host with fleet mdm. diff --git a/changes/issue-10494-add-wipe-cli b/changes/issue-10494-add-wipe-cli new file mode 100644 index 0000000000..5184225b79 --- /dev/null +++ b/changes/issue-10494-add-wipe-cli @@ -0,0 +1 @@ +- add wipe command to fleetctl diff --git a/cmd/fleetctl/debug.test3290534544 b/cmd/fleetctl/debug.test3290534544 new file mode 100755 index 0000000000..335bd1b2f1 Binary files /dev/null and b/cmd/fleetctl/debug.test3290534544 differ diff --git a/cmd/fleetctl/debug.test698732484 b/cmd/fleetctl/debug.test698732484 new file mode 100755 index 0000000000..0afd2bc157 Binary files /dev/null and b/cmd/fleetctl/debug.test698732484 differ diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index f1808cbdf9..8bf99d7742 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -360,7 +360,7 @@ func TestGetHosts(t *testing.T) { }, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } diff --git a/cmd/fleetctl/mdm.go b/cmd/fleetctl/mdm.go index e230c91434..ce5841dab6 100644 --- a/cmd/fleetctl/mdm.go +++ b/cmd/fleetctl/mdm.go @@ -27,6 +27,7 @@ func mdmCommand() *cli.Command { mdmRunCommand(), mdmLockCommand(), mdmUnlockCommand(), + mdmWipeCommand(), }, } } @@ -179,38 +180,11 @@ func mdmLockCommand() *cli.Command { Action: func(c *cli.Context) error { hostIdent := c.String("host") - if len(hostIdent) == 0 { - return errors.New("No host targeted. Please provide --host.") - } - - client, err := clientFromCLI(c) + client, host, err := hostMdmActionSetup(c, hostIdent, "lock") if err != nil { - return fmt.Errorf("create client: %w", err) - } - - host, err := client.HostByIdentifier(hostIdent) - if err != nil { - var nfe service.NotFoundErr - if errors.As(err, &nfe) { - return errors.New("The host doesn't exist. Please provide a valid host identifier.") - } - - var sce kithttp.StatusCoder - if errors.As(err, &sce) { - if sce.StatusCode() == http.StatusForbidden { - return errors.New("Permission denied. You don't have permission to lock this host.") - } - } return err } - if host.Platform == "windows" || host.Platform == "darwin" { - if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || - host.MDM.Name != fleet.WellKnownMDMFleet { - return errors.New(`Can't lock the host because it doesn't have MDM turned on.`) - } - } - if err := client.MDMLockHost(host.ID); err != nil { return fmt.Errorf("Failed to lock host: %w", err) } @@ -245,38 +219,11 @@ func mdmUnlockCommand() *cli.Command { Action: func(c *cli.Context) error { hostIdent := c.String("host") - if len(hostIdent) == 0 { - return errors.New("No host targeted. Please provide --host.") - } - - client, err := clientFromCLI(c) + client, host, err := hostMdmActionSetup(c, hostIdent, "unlock") if err != nil { - return fmt.Errorf("create client: %w", err) - } - - host, err := client.HostByIdentifier(hostIdent) - if err != nil { - var nfe service.NotFoundErr - if errors.As(err, &nfe) { - return errors.New("The host doesn't exist. Please provide a valid host identifier.") - } - - var sce kithttp.StatusCoder - if errors.As(err, &sce) { - if sce.StatusCode() == http.StatusForbidden { - return errors.New("Permission denied. You don't have permission to unlock this host.") - } - } return err } - if host.Platform == "windows" || host.Platform == "darwin" { - if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || - host.MDM.Name != fleet.WellKnownMDMFleet { - return errors.New(`Can't unlock the host because it doesn't have MDM turned on.`) - } - } - pin, err := client.MDMUnlockHost(host.ID) if err != nil { return fmt.Errorf("Failed to unlock host: %w", err) @@ -306,3 +253,88 @@ fleetctl get host %s }, } } + +// create a mdm command to wipe the device +func mdmWipeCommand() *cli.Command { + return &cli.Command{ + Name: "wipe", + Usage: "Wipe a host to erase all content on a workstation.", + Flags: []cli.Flag{contextFlag(), debugFlag(), &cli.StringFlag{ + Name: "host", + Usage: "The host, specified by identifier, that you want to wipe.", + Required: true, + }}, + Action: func(c *cli.Context) error { + hostIdent := c.String("host") + + client, host, err := hostMdmActionSetup(c, hostIdent, "wipe") + if err != nil { + return err + } + + config, err := client.GetAppConfig() + if err != nil { + return err + } + + // linux hosts need scripts to be enabled in the org settings to wipe. + if host.Platform == "linux" && config.ServerSettings.ScriptsDisabled { + return errors.New("Can't wipe host because running scripts is disabled in organization settings.") + } + + if err := client.MDMWipeHost(host.ID); err != nil { + return fmt.Errorf("Failed to wipe host: %w", err) + } + + fmt.Fprintf(c.App.Writer, ` +The host will wipe when it comes online. + +Copy and run this command to see results: + +fleetctl get host %s`, hostIdent) + + return nil + }, + } +} + +// Does some common setup for the host mdm actions such as validating the host, +// creating the client, getting the desired host, checking permissions, and +// ensuring MDM is turned on for the host. +func hostMdmActionSetup(c *cli.Context, hostIdent string, actionType string) (client *service.Client, host *service.HostDetailResponse, err error) { + if len(hostIdent) == 0 { + return nil, nil, errors.New("No host targeted. Please provide --host.") + } + + client, err = clientFromCLI(c) + if err != nil { + return nil, nil, fmt.Errorf("create client: %w", err) + } + + host, err = client.HostByIdentifier(hostIdent) + if err != nil { + var nfe service.NotFoundErr + if errors.As(err, &nfe) { + fmt.Println(hostIdent) + return nil, nil, errors.New("The host doesn't exist. Please provide a valid host identifier.") + } + + var sce kithttp.StatusCoder + if errors.As(err, &sce) { + if sce.StatusCode() == http.StatusForbidden { + return nil, nil, fmt.Errorf("Permission denied. You don't have permission to %s this host.", actionType) + } + } + return nil, nil, err + } + + // check mdm is on for the host + if host.Platform == "windows" || host.Platform == "darwin" { + if host.MDM.EnrollmentStatus == nil || !strings.HasPrefix(*host.MDM.EnrollmentStatus, "On") || + host.MDM.Name != fleet.WellKnownMDMFleet { + return nil, nil, fmt.Errorf("Can't %s the host because it doesn't have MDM turned on.", actionType) + } + } + + return client, host, nil +} diff --git a/cmd/fleetctl/mdm_test.go b/cmd/fleetctl/mdm_test.go index 797e05cc11..d275e15aab 100644 --- a/cmd/fleetctl/mdm_test.go +++ b/cmd/fleetctl/mdm_test.go @@ -196,7 +196,7 @@ func TestMDMRunCommand(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } ds.ListHostsLiteByUUIDsFunc = func(ctx context.Context, filter fleet.TeamFilter, uuids []string) ([]*fleet.Host, error) { @@ -370,6 +370,21 @@ func TestMDMLockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } + winEnrolledWP := &fleet.Host{ + ID: 12, + UUID: "win-enrolled-wp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWP := &fleet.Host{ + ID: 13, + UUID: "mac-enrolled-wp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + hostByUUID := make(map[string]*fleet.Host) hostsByID := make(map[uint]*fleet.Host) for _, h := range []*fleet.Host{ @@ -384,6 +399,8 @@ func TestMDMLockCommand(t *testing.T) { macEnrolledUP, winEnrolledLP, macEnrolledLP, + winEnrolledWP, + macEnrolledWP, } { hostByUUID[h.UUID] = h hostsByID[h.ID] = h @@ -393,58 +410,28 @@ func TestMDMLockCommand(t *testing.T) { winEnrolledUP.ID: winEnrolledUP, macEnrolledUP.ID: macEnrolledUP, } + lockPending := map[uint]*fleet.Host{ winEnrolledLP.ID: winEnrolledLP, macEnrolledLP.ID: macEnrolledLP, } - enqueuer := new(mock.MDMAppleStore) - license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + wipePending := map[uint]*fleet.Host{ + winEnrolledWP.ID: winEnrolledWP, + macEnrolledWP.ID: macEnrolledWP, + } - _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ - MDMStorage: enqueuer, - MDMPusher: mockPusher{}, - License: license, - NoCacheDatastore: true, - }) + ds := setupTestServer(t) + setupDSMocks(ds, hostByUUID, hostsByID) + + // custom ds mocks for these tests + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + fleetPlatform := host.FleetPlatform() - // Mock datastore funcs - ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { - h, ok := hostByUUID[identifier] - if !ok { - return nil, ¬FoundError{} - } - return h, nil - } - ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { - return nil - } - ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { - return nil, nil - } - ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { - return nil, nil - } - ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { - return nil, nil - } - ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { - return nil, nil - } - ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { - return nil, nil - } - ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { - return nil, nil - } - ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { - return nil, nil - } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { var status fleet.HostLockWipeStatus status.HostFleetPlatform = fleetPlatform - if _, ok := unlockPending[hostID]; ok { + if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.UnlockPIN = "1234" status.UnlockRequestedAt = time.Now() @@ -454,7 +441,7 @@ func TestMDMLockCommand(t *testing.T) { status.UnlockScript = &fleet.HostScriptResult{} } - if _, ok := lockPending[hostID]; ok { + if _, ok := lockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.LockMDMCommand = &fleet.MDMCommand{} return &status, nil @@ -463,38 +450,24 @@ func TestMDMLockCommand(t *testing.T) { status.LockScript = &fleet.HostScriptResult{} } + if _, ok := wipePending[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: nil} + return &status, nil + } + + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = nil + return &status, nil + } + return &status, nil } - ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error { - return nil - } - ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h, nil - } - ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { - return nil, nil - } - ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h.MDMInfo, nil - } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, platform string) error { return nil } - appCfgAllMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}} - appCfgWinMDM := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}} - appCfgMacMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} - appCfgNoMDM := &fleet.AppConfig{MDM: fleet.MDM{}} + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() successfulOutput := func(ident string) string { return fmt.Sprintf(` @@ -535,25 +508,11 @@ fleetctl mdm unlock --host=%s {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, - // TODO: add test for wipe once implemented + {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid macos but pending wipe", []string{"--host", macEnrolledWP.UUID}, "Host has pending wipe request."}, } - for _, c := range cases { - ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return c.appCfg, nil - } - enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { - return nil - } - buf, err := runAppNoChecks(append([]string{"mdm", "lock"}, c.flags...)) - if c.wantErr != "" { - require.Error(t, err, c.desc) - require.ErrorContains(t, err, c.wantErr, c.desc) - } else { - require.NoError(t, err, c.desc) - require.Equal(t, buf.String(), successfulOutput(c.flags[1]), c.desc) - } - } + runTestCases(t, ds, "lock", successfulOutput, cases) } func TestMDMUnlockCommand(t *testing.T) { @@ -614,7 +573,6 @@ func TestMDMUnlockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } - winEnrolledLP := &fleet.Host{ ID: 10, UUID: "win-enrolled-lp", @@ -629,6 +587,20 @@ func TestMDMUnlockCommand(t *testing.T) { MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } + winEnrolledWP := &fleet.Host{ + ID: 12, + UUID: "win-enrolled-wp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWP := &fleet.Host{ + ID: 13, + UUID: "mac-enrolled-wp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } hostByUUID := make(map[string]*fleet.Host) hostsByID := make(map[uint]*fleet.Host) @@ -644,6 +616,8 @@ func TestMDMUnlockCommand(t *testing.T) { macEnrolledUP, winEnrolledLP, macEnrolledLP, + winEnrolledWP, + macEnrolledWP, } { hostByUUID[h.UUID] = h hostsByID[h.ID] = h @@ -664,56 +638,21 @@ func TestMDMUnlockCommand(t *testing.T) { macEnrolledLP.ID: macEnrolledLP, } - enqueuer := new(mock.MDMAppleStore) - license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} - - enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { - return nil + wipePending := map[uint]*fleet.Host{ + winEnrolledWP.ID: winEnrolledWP, + macEnrolledWP.ID: macEnrolledWP, } - _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ - MDMStorage: enqueuer, - MDMPusher: mockPusher{}, - License: license, - NoCacheDatastore: true, - }) + ds := setupTestServer(t) + setupDSMocks(ds, hostByUUID, hostsByID) + + // custom mocks for these test + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + fleetPlatform := host.FleetPlatform() - // Mock datastore funcs - ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { - h, ok := hostByUUID[identifier] - if !ok { - return nil, ¬FoundError{} - } - return h, nil - } - ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { - return nil - } - ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { - return nil, nil - } - ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { - return nil, nil - } - ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { - return nil, nil - } - ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { - return nil, nil - } - ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { - return nil, nil - } - ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { - return nil, nil - } - ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { - return nil, nil - } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { var status fleet.HostLockWipeStatus status.HostFleetPlatform = fleetPlatform - if _, ok := locked[hostID]; ok { + if _, ok := locked[host.ID]; ok { if fleetPlatform == "darwin" { status.LockMDMCommand = &fleet.MDMCommand{} status.LockMDMCommandResult = &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged} @@ -723,7 +662,7 @@ func TestMDMUnlockCommand(t *testing.T) { status.LockScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)} } - if _, ok := unlockPending[hostID]; ok { + if _, ok := unlockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.UnlockPIN = "1234" status.UnlockRequestedAt = time.Now() @@ -733,7 +672,7 @@ func TestMDMUnlockCommand(t *testing.T) { status.UnlockScript = &fleet.HostScriptResult{} } - if _, ok := lockPending[hostID]; ok { + if _, ok := lockPending[host.ID]; ok { if fleetPlatform == "darwin" { status.LockMDMCommand = &fleet.MDMCommand{} return &status, nil @@ -742,41 +681,27 @@ func TestMDMUnlockCommand(t *testing.T) { status.LockScript = &fleet.HostScriptResult{} } + if _, ok := wipePending[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: nil} + return &status, nil + } + + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = nil + return &status, nil + } + return &status, nil } - ds.UnlockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error { + ds.UnlockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, platform string) error { return nil } - ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, ts time.Time) error { - return nil - } - ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h, nil - } - ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { - return nil, nil - } - ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { - h, ok := hostsByID[hostID] - if !ok { - return nil, ¬FoundError{} - } - - return h.MDMInfo, nil - } - ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, platform string, ts time.Time) error { return nil } - appCfgAllMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}} - appCfgWinMDM := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}} - appCfgMacMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} - appCfgNoMDM := &fleet.AppConfig{MDM: fleet.MDM{}} + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() successfulOutput := func(ident string) string { h := hostByUUID[ident] @@ -801,7 +726,7 @@ fleetctl get host %s }{ {appCfgAllMDM, "no flags", nil, `Required flag "host" not set`}, {appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`}, - {appCfgAllMDM, "lock non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`}, + {appCfgAllMDM, "unlock non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`}, {appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, {appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.UUID}, ""}, {appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.UUID}, ""}, @@ -817,22 +742,310 @@ fleetctl get host %s {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, ""}, {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, - // TODO: add test for wipe once implemented + {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid macos but pending wipe", []string{"--host", macEnrolledWP.UUID}, "Host has pending wipe request."}, } - for _, c := range cases { - ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { - return c.appCfg, nil - } - buf, err := runAppNoChecks(append([]string{"mdm", "unlock"}, c.flags...)) - if c.wantErr != "" { - require.Error(t, err, c.desc) - require.ErrorContains(t, err, c.wantErr, c.desc) - } else { - require.NoError(t, err, c.desc) - require.Contains(t, buf.String(), successfulOutput(c.flags[1]), c.desc) - } + runTestCases(t, ds, "unlock", successfulOutput, cases) +} + +func TestMDMWipeCommand(t *testing.T) { + macEnrolled := &fleet.Host{ + ID: 1, + UUID: "mac-enrolled", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, } + winEnrolled := &fleet.Host{ + ID: 2, + UUID: "win-enrolled", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + linuxEnrolled := &fleet.Host{ + ID: 3, + UUID: "linux-enrolled", + Platform: "linux", + } + winNotEnrolled := &fleet.Host{ + ID: 4, + UUID: "win-not-enrolled", + Platform: "windows", + } + macNotEnrolled := &fleet.Host{ + ID: 5, + UUID: "mac-not-enrolled", + Platform: "darwin", + } + macPending := &fleet.Host{ + ID: 6, + UUID: "mac-pending", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: false, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("Pending")}, + } + winPending := &fleet.Host{ + ID: 7, + UUID: "win-pending", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: false, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("Pending")}, + } + winEnrolledUP := &fleet.Host{ + ID: 8, + UUID: "win-enrolled-up", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledUP := &fleet.Host{ + ID: 9, + UUID: "mac-enrolled-up", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledLP := &fleet.Host{ + ID: 10, + UUID: "win-enrolled-lp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledLP := &fleet.Host{ + ID: 11, + UUID: "mac-enrolled-lp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledWP := &fleet.Host{ + ID: 12, + UUID: "win-enrolled-wp", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWP := &fleet.Host{ + ID: 13, + UUID: "mac-enrolled-wp", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledWiped := &fleet.Host{ + ID: 14, + UUID: "win-enrolled-wiped", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + macEnrolledWiped := &fleet.Host{ + ID: 15, + UUID: "mac-enrolled-wiped", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual)")}, + } + winEnrolledLocked := &fleet.Host{ + ID: 16, + UUID: "win-enrolled-locked", + Platform: "windows", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual")}, + } + macEnrolledLocked := &fleet.Host{ + ID: 17, + UUID: "mac-enrolled-locked", + Platform: "darwin", + MDMInfo: &fleet.HostMDM{Enrolled: true, Name: fleet.WellKnownMDMFleet}, + MDM: fleet.MDMHostData{Name: fleet.WellKnownMDMFleet, EnrollmentStatus: ptr.String("On (manual")}, + } + + hostByUUID := make(map[string]*fleet.Host) + hostsByID := make(map[uint]*fleet.Host) + for _, h := range []*fleet.Host{ + winEnrolled, + macEnrolled, + linuxEnrolled, + macNotEnrolled, + winNotEnrolled, + macPending, + winPending, + winEnrolledUP, + macEnrolledUP, + winEnrolledLP, + macEnrolledLP, + winEnrolledWP, + macEnrolledWP, + winEnrolledWiped, + macEnrolledWiped, + winEnrolledLocked, + macEnrolledLocked, + } { + hostByUUID[h.UUID] = h + hostsByID[h.ID] = h + } + + locked := map[uint]*fleet.Host{ + winEnrolledLocked.ID: winEnrolledLocked, + macEnrolledLocked.ID: macEnrolledLocked, + } + + unlockPending := map[uint]*fleet.Host{ + winEnrolledUP.ID: winEnrolledUP, + macEnrolledUP.ID: macEnrolledUP, + } + + lockPending := map[uint]*fleet.Host{ + winEnrolledLP.ID: winEnrolledLP, + macEnrolledLP.ID: macEnrolledLP, + } + + wipePending := map[uint]*fleet.Host{ + winEnrolledWP.ID: winEnrolledWP, + macEnrolledWP.ID: macEnrolledWP, + } + + wiped := map[uint]*fleet.Host{ + winEnrolledWiped.ID: winEnrolledWiped, + macEnrolledWiped.ID: macEnrolledWiped, + } + + ds := setupTestServer(t) + setupDSMocks(ds, hostByUUID, hostsByID) + + // TODO: custom ds mocks for these tests + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + fleetPlatform := host.FleetPlatform() + + var status fleet.HostLockWipeStatus + status.HostFleetPlatform = fleetPlatform + if _, ok := locked[host.ID]; ok { + if fleetPlatform == "darwin" { + status.LockMDMCommand = &fleet.MDMCommand{} + status.LockMDMCommandResult = &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged} + return &status, nil + } + + status.LockScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)} + } + + if _, ok := unlockPending[host.ID]; ok { + if fleetPlatform == "darwin" { + status.UnlockPIN = "1234" + status.UnlockRequestedAt = time.Now() + return &status, nil + } + + status.UnlockScript = &fleet.HostScriptResult{} + } + + if _, ok := lockPending[host.ID]; ok { + if fleetPlatform == "darwin" { + status.LockMDMCommand = &fleet.MDMCommand{} + return &status, nil + } + + status.LockScript = &fleet.HostScriptResult{} + } + + if _, ok := wipePending[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: nil} + return &status, nil + } + + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = nil + return &status, nil + } + + if _, ok := wiped[host.ID]; ok { + if fleetPlatform == "linux" { + status.WipeScript = &fleet.HostScriptResult{ExitCode: ptr.Int64(0)} + } + + if fleetPlatform == "darwin" { + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = &fleet.MDMCommandResult{ + Status: fleet.MDMAppleStatusAcknowledged, + } + } + + if fleetPlatform == "windows" { + status.WipeMDMCommand = &fleet.MDMCommand{} + status.WipeMDMCommandResult = &fleet.MDMCommandResult{ + Status: "200", + } + } + + return &status, nil + } + + return &status, nil + } + ds.UnlockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + return nil + } + ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error { + return nil + } + ds.WipeHostViaWindowsMDMFunc = func(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error { + return nil + } + + ds.WipeHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + return nil + } + + appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM := setupAppConigs() + appCfgScriptsDisabled := &fleet.AppConfig{ServerSettings: fleet.ServerSettings{ScriptsDisabled: true}} + + cases := []struct { + appCfg *fleet.AppConfig + desc string + flags []string + wantErr string + }{ + {appCfgAllMDM, "no flags", nil, `Required flag "host" not set`}, + {appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`}, + {appCfgAllMDM, "wipe non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`}, + {appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, + {appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.UUID}, ""}, + {appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.UUID}, ""}, + {appCfgNoMDM, "valid linux", []string{"--host", linuxEnrolled.UUID}, ""}, + {appCfgNoMDM, "valid windows but no mdm", []string{"--host", winEnrolled.UUID}, `Windows MDM isn't turned on.`}, + {appCfgMacMDM, "valid macos but not enrolled", []string{"--host", macNotEnrolled.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgWinMDM, "valid windows but not enrolled", []string{"--host", winNotEnrolled.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgWinMDM, "valid windows but pending mdm enroll", []string{"--host", winPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgMacMDM, "valid macos but pending mdm enroll", []string{"--host", macPending.UUID}, `Can't wipe the host because it doesn't have MDM turned on.`}, + {appCfgAllMDM, "valid windows but pending unlock", []string{"--host", winEnrolledUP.UUID}, "Host has pending unlock request."}, + {appCfgAllMDM, "valid macos but pending unlock", []string{"--host", macEnrolledUP.UUID}, "Host has pending unlock request."}, + {appCfgAllMDM, "valid windows but pending lock", []string{"--host", winEnrolledLP.UUID}, "Host has pending lock request."}, + {appCfgAllMDM, "valid macos but pending lock", []string{"--host", macEnrolledLP.UUID}, "Host has pending lock request."}, + {appCfgAllMDM, "valid windows but pending wipe", []string{"--host", winEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid macos but pending wipe", []string{"--host", macEnrolledWP.UUID}, "Host has pending wipe request."}, + {appCfgAllMDM, "valid windows but host wiped", []string{"--host", winEnrolledWiped.UUID}, "Host is already wiped."}, + {appCfgAllMDM, "valid macos but host wiped", []string{"--host", macEnrolledWiped.UUID}, "Host is already wiped."}, + {appCfgAllMDM, "valid windows but host is locked", []string{"--host", winEnrolledLocked.UUID}, "Host cannot be wiped until it is unlocked."}, + {appCfgAllMDM, "valid macos but host is locked", []string{"--host", macEnrolledLocked.UUID}, "Host cannot be wiped until it is unlocked."}, + {appCfgAllMDM, "valid macos but host is locked", []string{"--host", macEnrolledLocked.UUID}, "Host cannot be wiped until it is unlocked."}, + {appCfgScriptsDisabled, "valid linux but script are disabled", []string{"--host", linuxEnrolled.UUID}, "Can't wipe host because running scripts is disabled in organization settings."}, + } + + successfulOutput := func(ident string) string { + return fmt.Sprintf(` +The host will wipe when it comes online. + +Copy and run this command to see results: + +fleetctl get host %s`, ident) + } + + runTestCases(t, ds, "wipe", successfulOutput, cases) } func writeTmpAppleMDMCmd(t *testing.T, commandName string) string { @@ -882,3 +1095,116 @@ func writeTmpMobileconfig(t *testing.T, name string) string { require.NoError(t, err) return tmpFile.Name() } + +// sets up the test server with the mock datastore and returns the mock datastore +func setupTestServer(t *testing.T) *mock.Store { + enqueuer := new(mock.MDMAppleStore) + license := &fleet.LicenseInfo{Tier: fleet.TierPremium, Expiration: time.Now().Add(24 * time.Hour)} + + enqueuer.EnqueueDeviceLockCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error { + return nil + } + + enqueuer.EnqueueDeviceWipeCommandFunc = func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + return nil + } + + _, ds := runServerWithMockedDS(t, &service.TestServerOpts{ + MDMStorage: enqueuer, + MDMPusher: mockPusher{}, + License: license, + NoCacheDatastore: true, + }) + + return ds +} + +// sets up common data store mocks that are needed for the tests. +func setupDSMocks(ds *mock.Store, hostByUUID map[string]*fleet.Host, hostsByID map[uint]*fleet.Host) { + ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { + h, ok := hostByUUID[identifier] + if !ok { + return nil, ¬FoundError{} + } + return h, nil + } + ds.LoadHostSoftwareFunc = func(ctx context.Context, host *fleet.Host, includeCVEScores bool) error { + return nil + } + ds.ListPacksForHostFunc = func(ctx context.Context, hid uint) (packs []*fleet.Pack, err error) { + return nil, nil + } + ds.ListHostBatteriesFunc = func(ctx context.Context, id uint) ([]*fleet.HostBattery, error) { + return nil, nil + } + ds.ListPoliciesForHostFunc = func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { + return nil, nil + } + ds.ListLabelsForHostFunc = func(ctx context.Context, hid uint) ([]*fleet.Label, error) { + return nil, nil + } + ds.GetHostMDMAppleProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMAppleProfile, error) { + return nil, nil + } + ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, hostUUID string) ([]fleet.HostMDMWindowsProfile, error) { + return nil, nil + } + ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { + return nil, nil + } + ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { + h, ok := hostsByID[hostID] + if !ok { + return nil, ¬FoundError{} + } + + return h, nil + } + ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) { + return nil, nil + } + ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) { + h, ok := hostsByID[hostID] + if !ok { + return nil, ¬FoundError{} + } + + return h.MDMInfo, nil + } + ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { + return nil + } +} + +// sets up the various app configs for the tests. These app configs reflect the various +// states of the MDM configuration. +func setupAppConigs() (*fleet.AppConfig, *fleet.AppConfig, *fleet.AppConfig, *fleet.AppConfig) { + appCfgAllMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true, WindowsEnabledAndConfigured: true}} + appCfgWinMDM := &fleet.AppConfig{MDM: fleet.MDM{WindowsEnabledAndConfigured: true}} + appCfgMacMDM := &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}} + appCfgNoMDM := &fleet.AppConfig{MDM: fleet.MDM{}} + + return appCfgAllMDM, appCfgWinMDM, appCfgMacMDM, appCfgNoMDM +} + +func runTestCases(t *testing.T, ds *mock.Store, actionType string, successfulOutput func(ident string) string, cases []struct { + appCfg *fleet.AppConfig + desc string + flags []string + wantErr string +}, +) { + for _, c := range cases { + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return c.appCfg, nil + } + buf, err := runAppNoChecks(append([]string{"mdm", actionType}, c.flags...)) + if c.wantErr != "" { + require.Error(t, err, c.desc) + require.ErrorContains(t, err, c.wantErr, c.desc) + } else { + require.NoError(t, err, c.desc) + require.Contains(t, buf.String(), successfulOutput(c.flags[1]), c.desc) + } + } +} diff --git a/cmd/fleetctl/scripts_test.go b/cmd/fleetctl/scripts_test.go index fbc6d11547..e80d10c898 100644 --- a/cmd/fleetctl/scripts_test.go +++ b/cmd/fleetctl/scripts_test.go @@ -240,7 +240,7 @@ Fleet records the last 10,000 characters to prevent downtime. } return &fleet.HostScriptResult{}, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } ds.NewHostScriptExecutionRequestFunc = func(ctx context.Context, req *fleet.HostScriptRequestPayload) (*fleet.HostScriptResult, error) { diff --git a/docs/Using Fleet/Audit-logs.md b/docs/Using Fleet/Audit-logs.md index a5e0b9ff2f..2367b31d9f 100644 --- a/docs/Using Fleet/Audit-logs.md +++ b/docs/Using Fleet/Audit-logs.md @@ -1032,6 +1032,23 @@ This activity contains the following fields: } ``` +## wiped_host + +Generated when a user sends a request to wipe a host. + +This activity contains the following fields: +- "host_id": ID of the host. +- "host_display_name": Display name of the host. + +#### Example + +```json +{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro" +} +``` + diff --git a/ee/server/service/embedded_scripts/linux_wipe.sh b/ee/server/service/embedded_scripts/linux_wipe.sh new file mode 100644 index 0000000000..69a78b1235 --- /dev/null +++ b/ee/server/service/embedded_scripts/linux_wipe.sh @@ -0,0 +1,46 @@ +#!/bin/sh + +# Function to log out all users and lock their passwords except root +logout_users() { + for user in $(who | awk '{print $1}' | sort | uniq) + do + if [ "$user" != "root" ]; then + echo "Logging out $user" + pkill -KILL -u "$user" + passwd -l "$user" + fi + done +} + +# Function to wipe non-essential data +wipe_non_essential_data() { + # Define non-essential paths + non_essential_paths="/home/* /tmp /var/tmp /var/log /home/*/.cache /var/cache /home/*/.local/share/Trash" + + for path in $non_essential_paths + do + if [ -e "$path" ]; then + echo "Wiping $path" + rm -rf "$path" + fi + done +} + +# Function to wipe system files - Warning: This will render the system inoperable +wipe_system_files() { + # Define essential system paths + essential_system_paths="/bin /sbin /usr /lib" + + for path in $essential_system_paths + do + echo "Wiping $path" + rm -rf "$path" + done +} + +# Start the wiping process +logout_users +wipe_non_essential_data +wipe_system_files + +echo "Wiping process completed." diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index 57b5549ff7..f6f9168516 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -3,6 +3,7 @@ package service import ( "context" _ "embed" + "errors" "fmt" "net/http" "time" @@ -55,21 +56,22 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { return err } - // TODO(mna): error messages are subtly different in the figma for CLI and - // UI, they should be the same as they come from the same place (the API). - // I used the CLI messages for the implementation. - // locking validations are based on the platform of the host switch host.FleetPlatform() { case "darwin": if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { - err := fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") } // on macOS, the lock command requires the host to be MDM-enrolled in Fleet hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) if err != nil { + if fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't lock the host because it doesn't have MDM turned on.")) + } return ctxerr.Wrap(ctx, err, "get host MDM information") } if !hostMDM.IsFleetEnrolled() { @@ -79,7 +81,9 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { case "windows", "linux": if host.FleetPlatform() == "windows" { if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { - err := fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } return ctxerr.Wrap(ctx, err, "check windows MDM enabled") } } @@ -94,13 +98,12 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { } default: - // TODO(mna): should we allow/treat ChromeOS as Linux for this purpose? return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) } // if there's a lock, unlock or wipe action pending, do not accept the lock // request. - lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { return ctxerr.Wrap(ctx, err, "get host lock/wipe status") } @@ -111,6 +114,8 @@ func (svc *Service) LockHost(ctx context.Context, hostID uint) error { return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be locked again until unlock is complete.")) case lockWipe.IsPendingWipe(): return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process lock requests once host is wiped.")) + case lockWipe.IsWiped(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process lock requests once host is wiped.")) case lockWipe.IsLocked(): return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already locked.").WithStatus(http.StatusConflict)) } @@ -148,7 +153,9 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) // be enabled if host.FleetPlatform() == "windows" { if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { - err := fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } return "", ctxerr.Wrap(ctx, err, "check windows MDM enabled") } } @@ -161,11 +168,10 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) } default: - // TODO(mna): should we allow/treat ChromeOS as Linux for this purpose? return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) } - lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { return "", ctxerr.Wrap(ctx, err, "get host lock/wipe status") } @@ -182,6 +188,8 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. The host will unlock when it comes online.")) case lockWipe.IsPendingWipe(): return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. Cannot process unlock requests once host is wiped.")) + case lockWipe.IsWiped(): + return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is wiped. Cannot process unlock requests once host is wiped.")) case lockWipe.IsUnlocked(): return "", ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already unlocked.").WithStatus(http.StatusConflict)) } @@ -190,6 +198,97 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return svc.enqueueUnlockHostRequest(ctx, host, lockWipe) } +func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { + // First ensure the user has access to list hosts, then check the specific + // host once team_id is loaded. + if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil { + return err + } + host, err := svc.ds.HostLite(ctx, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host lite") + } + + // Authorize again with team loaded now that we have the host's team_id. + // Authorize as "execute mdm_command", which is the correct access + // requirement and is what happens for macOS platforms. + if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return err + } + + // wipe validations are based on the platform of the host, Windows and macOS + // require MDM to be enabled and the host to be MDM-enrolled in Fleet. Linux + // uses scripts, not MDM. + var requireMDM bool + switch host.FleetPlatform() { + case "darwin": + if err := svc.VerifyMDMAppleConfigured(ctx); err != nil { + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } + return ctxerr.Wrap(ctx, err, "check macOS MDM enabled") + } + requireMDM = true + + case "windows": + if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil { + if errors.Is(err, fleet.ErrMDMNotConfigured) { + err = fleet.NewInvalidArgumentError("host_id", fleet.WindowsMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest) + } + return ctxerr.Wrap(ctx, err, "check windows MDM enabled") + } + requireMDM = true + + case "linux": + // on linux, a script is used to wipe the host so scripts must be enabled + appCfg, err := svc.ds.AppConfig(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "get app config") + } + if appCfg.ServerSettings.ScriptsDisabled { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe host because running scripts is disabled in organization settings.")) + } + + default: + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", fmt.Sprintf("Unsupported host platform: %s", host.Platform))) + } + + if requireMDM { + // the wipe command requires the host to be MDM-enrolled in Fleet + hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID) + if err != nil { + if fleet.IsNotFound(err) { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe the host because it doesn't have MDM turned on.")) + } + return ctxerr.Wrap(ctx, err, "get host MDM information") + } + if !hostMDM.IsFleetEnrolled() { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't wipe the host because it doesn't have MDM turned on.")) + } + } + + // validations based on host's actions status (pending lock, unlock, wipe) + lockWipe, err := svc.ds.GetHostLockWipeStatus(ctx, host) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host lock/wipe status") + } + switch { + case lockWipe.IsPendingLock(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending lock request. Host cannot be wiped until lock is complete.")) + case lockWipe.IsPendingUnlock(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending unlock request. Host cannot be wiped until unlock is complete.")) + case lockWipe.IsPendingWipe(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host has pending wipe request. The host will be wiped when it comes online.")) + case lockWipe.IsLocked(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is locked. Host cannot be wiped until it is unlocked.")) + case lockWipe.IsWiped(): + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Host is already wiped.").WithStatus(http.StatusConflict)) + } + + // all good, go ahead with queuing the wipe request. + return svc.enqueueWipeHostRequest(ctx, host, lockWipe) +} + func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host, lockStatus *fleet.HostLockWipeStatus) error { vc, ok := viewer.FromContext(ctx) if !ok { @@ -232,7 +331,7 @@ func (svc *Service) enqueueLockHostRequest(ctx context.Context, host *fleet.Host ScriptContents: string(script), UserID: &vc.User.ID, SyncRequest: false, - }); err != nil { + }, host.FleetPlatform()); err != nil { return err } @@ -260,7 +359,7 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho if lockStatus.HostFleetPlatform == "darwin" { // record the unlock request if it was not already recorded if lockStatus.UnlockRequestedAt.IsZero() { - if err := svc.ds.UnlockHostManually(ctx, host.ID, time.Now().UTC()); err != nil { + if err := svc.ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()); err != nil { return "", err } } @@ -281,7 +380,7 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho ScriptContents: string(script), UserID: &vc.User.ID, SyncRequest: false, - }); err != nil { + }, host.FleetPlatform()); err != nil { return "", err } } @@ -301,6 +400,59 @@ func (svc *Service) enqueueUnlockHostRequest(ctx context.Context, host *fleet.Ho return unlockPIN, nil } +func (svc *Service) enqueueWipeHostRequest(ctx context.Context, host *fleet.Host, wipeStatus *fleet.HostLockWipeStatus) error { + vc, ok := viewer.FromContext(ctx) + if !ok { + return fleet.ErrNoContext + } + + switch wipeStatus.HostFleetPlatform { + case "darwin": + wipeCommandUUID := uuid.NewString() + if err := svc.mdmAppleCommander.EraseDevice(ctx, host, wipeCommandUUID); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing wipe request for darwin") + } + + case "windows": + wipeCmdUUID := uuid.NewString() + wipeCmd := &fleet.MDMWindowsCommand{ + CommandUUID: wipeCmdUUID, + RawCommand: []byte(fmt.Sprintf(windowsWipeCommand, wipeCmdUUID)), + TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", + } + if err := svc.ds.WipeHostViaWindowsMDM(ctx, host, wipeCmd); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing wipe request for windows") + } + + case "linux": + // TODO(mna): svc.RunHostScript should be refactored so that we can reuse the + // part starting with the validation of the script (just in case), the checks + // that we don't enqueue over the limit, etc. for any other important + // validation we may add over there and that we bypass here by enqueueing the + // script directly in the datastore layer. + if err := svc.ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: host.ID, + ScriptContents: string(linuxWipeScript), + UserID: &vc.User.ID, + SyncRequest: false, + }, host.FleetPlatform()); err != nil { + return err + } + } + + if err := svc.ds.NewActivity( + ctx, + vc.User, + fleet.ActivityTypeWipedHost{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for wipe host request") + } + return nil +} + // TODO(mna): ideally we'd embed the scripts from the scripts/mdm/windows/.. // and scripts/mdm/linux/.. directories where they currently exist, but this is // not possible (not a Go package) and I don't know if those script locations @@ -316,4 +468,21 @@ var ( linuxLockScript []byte //go:embed embedded_scripts/linux_unlock.sh linuxUnlockScript []byte + //go:embed embedded_scripts/linux_wipe.sh + linuxWipeScript []byte + + windowsWipeCommand = ` + + %s + + + ./Device/Vendor/MSFT/RemoteWipe/doWipeProtected + + + chr + text/plain + + + + ` ) diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 5d145bbf21..dd54773140 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -139,14 +139,7 @@ func (svc *Service) MDMAppleEraseDevice(ctx context.Context, hostID uint) error return err } - // TODO: save the pin (first return value) in the database - // TODO(mna): same here for when we implement the Wipe story, assuming this - // implementation (which is for the deprecated /mdm/hosts/:id/wipe endpoint) - // should work as the new endpoint, then this should call - // svc.enqueueWipeHostRequest so that it behaves like the new endpoint. And - // yes, we do need to save the generated PIN so the EraseDevice method - // signature must change to return it. - err = svc.mdmAppleCommander.EraseDevice(ctx, []string{host.UUID}, uuid.New().String()) + err = svc.mdmAppleCommander.EraseDevice(ctx, host, uuid.New().String()) if err != nil { return err } diff --git a/frontend/context/app.tsx b/frontend/context/app.tsx index 5784534c4e..4dc0d2137f 100644 --- a/frontend/context/app.tsx +++ b/frontend/context/app.tsx @@ -106,7 +106,8 @@ type InitialStateType = { isSandboxMode?: boolean; isFreeTier?: boolean; isPremiumTier?: boolean; - isMdmEnabledAndConfigured?: boolean; + isMacMdmEnabledAndConfigured?: boolean; + isWindowsMdmEnabledAndConfigured?: boolean; isGlobalAdmin?: boolean; isGlobalMaintainer?: boolean; isGlobalObserver?: boolean; @@ -156,7 +157,8 @@ export const initialState = { isSandboxMode: false, isFreeTier: undefined, isPremiumTier: undefined, - isMdmEnabledAndConfigured: undefined, + isMacMdmEnabledAndConfigured: undefined, + isWindowsMdmEnabledAndConfigured: undefined, isGlobalAdmin: undefined, isGlobalMaintainer: undefined, isGlobalObserver: undefined, @@ -212,7 +214,12 @@ const setPermissions = ( isSandboxMode: permissions.isSandboxMode(config), isFreeTier: permissions.isFreeTier(config), isPremiumTier: permissions.isPremiumTier(config), - isMdmEnabledAndConfigured: permissions.isMdmEnabledAndConfigured(config), + isMacMdmEnabledAndConfigured: permissions.isMacMdmEnabledAndConfigured( + config + ), + isWindowsMdmEnabledAndConfigured: permissions.isWindowsMdmEnabledAndConfigured( + config + ), isGlobalAdmin: permissions.isGlobalAdmin(user), isGlobalMaintainer: permissions.isGlobalMaintainer(user), isGlobalObserver: permissions.isGlobalObserver(user), @@ -365,7 +372,8 @@ const AppProvider = ({ children }: Props): JSX.Element => { isSandboxMode: state.isSandboxMode, isFreeTier: state.isFreeTier, isPremiumTier: state.isPremiumTier, - isMdmEnabledAndConfigured: state.isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured: state.isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured: state.isWindowsMdmEnabledAndConfigured, isGlobalAdmin: state.isGlobalAdmin, isGlobalMaintainer: state.isGlobalMaintainer, isGlobalObserver: state.isGlobalObserver, diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index ed047aa777..143efeaf0a 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -66,7 +66,15 @@ export enum ActivityType { EditedWindowsUpdates = "edited_windows_updates", LockedHost = "locked_host", UnlockedHost = "unlocked_host", + WipedHost = "wiped_host", } + +// This is a subset of ActivityType that are shown only for the host past activities +export type IHostPastActivityType = + | ActivityType.RanScript + | ActivityType.LockedHost + | ActivityType.UnlockedHost; + export interface IActivity { created_at: string; id: number; @@ -77,6 +85,11 @@ export interface IActivity { type: ActivityType; details?: IActivityDetails; } + +export type IPastActivity = Omit & { + type: IHostPastActivityType; +}; + export interface IActivityDetails { pack_id?: number; pack_name?: string; diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index 420591fa39..4465dd3ad1 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -157,8 +157,8 @@ interface IMdmMacOsSetup { bootstrap_package_name: string; } -export type HostMdmDeviceStatus = "unlocked" | "locked"; -export type HostMdmPendingAction = "unlock" | "lock" | ""; +export type HostMdmDeviceStatus = "unlocked" | "locked" | "wiped"; +export type HostMdmPendingAction = "unlock" | "lock" | "wipe" | ""; export interface IHostMdmData { encryption_key_available: boolean; diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx index a18dd1aae7..e8bff33d98 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tests.tsx @@ -1165,4 +1165,17 @@ describe("Activity Feed", () => { screen.getByText("deleted multiple queries", { exact: false }) ).toBeInTheDocument(); }); + // test for wipe activity + it("renders a 'wiped_host' type activity for a team", () => { + const activity = createMockActivity({ + type: ActivityType.WipedHost, + details: { + host_display_name: "Foo Host", + }, + }); + render(); + + expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument(); + expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument(); + }); }); diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx index dadda97c84..f576ad0532 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx @@ -755,6 +755,14 @@ const TAGGED_TEMPLATES = { ); }, + wipedHost: (activity: IActivity) => { + return ( + <> + {" "} + wiped {activity.details?.host_display_name}. + + ); + }, }; const getDetail = ( @@ -907,6 +915,9 @@ const getDetail = ( case ActivityType.UnlockedHost: { return TAGGED_TEMPLATES.unlockedHost(activity); } + case ActivityType.WipedHost: { + return TAGGED_TEMPLATES.wipedHost(activity); + } default: { return TAGGED_TEMPLATES.defaultActivityTemplate(activity); } diff --git a/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx b/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx index 94a09ee034..a567506653 100644 --- a/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/IntegrationPage.tests.tsx @@ -22,7 +22,7 @@ describe("Integrations Page", () => { const render = createCustomRenderer({ withBackendMock: true, context: { - app: { isMdmEnabledAndConfigured: true }, + app: { isMacMdmEnabledAndConfigured: true }, }, }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index 2094050495..57426f660c 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -94,7 +94,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -122,7 +122,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalMaintainer: true, currentUser: createMockUser(), }, @@ -150,7 +150,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser({ teams: [createMockTeam({ id: 1, role: "admin" })], }), @@ -179,7 +179,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser({ teams: [createMockTeam({ id: 1, role: "maintainer" })], }), @@ -208,7 +208,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, currentUser: createMockUser(), }, }, @@ -235,7 +235,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -267,7 +267,7 @@ describe("Host Actions Dropdown", () => { const render = createCustomRenderer({ context: { app: { - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -402,7 +402,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -431,7 +431,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -460,7 +460,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -491,7 +491,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -520,7 +520,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -549,7 +549,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -578,7 +578,7 @@ describe("Host Actions Dropdown", () => { context: { app: { isPremiumTier: true, - isMdmEnabledAndConfigured: true, + isMacMdmEnabledAndConfigured: true, isGlobalAdmin: true, currentUser: createMockUser(), }, @@ -601,5 +601,126 @@ describe("Host Actions Dropdown", () => { expect(screen.queryByText("Unlock")).not.toBeInTheDocument(); }); + + it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: false, + isWindowsMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Unlock")).not.toBeInTheDocument(); + }); + }); + + describe("Wipe action", () => { + it("renders only when the host is unlocked", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.getByText("Wipe")).toBeInTheDocument(); + }); + + it("does not renders when a windows host but does not have Fleet windows mdm enabled and configured", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: true, + isWindowsMdmEnabledAndConfigured: false, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); + }); + + it("does not renders when a mac host but does not have Fleet mac mdm enabled and configured", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isMacMdmEnabledAndConfigured: false, + isWindowsMdmEnabledAndConfigured: true, + isGlobalAdmin: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Wipe")).not.toBeInTheDocument(); + }); }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx index 378b72b9bc..988986012b 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx @@ -38,7 +38,8 @@ const HostActionsDropdown = ({ isPremiumTier = false, isGlobalAdmin = false, isGlobalMaintainer = false, - isMdmEnabledAndConfigured = false, + isMacMdmEnabledAndConfigured = false, + isWindowsMdmEnabledAndConfigured = false, isSandboxMode = false, currentUser, } = useContext(AppContext); @@ -67,7 +68,8 @@ const HostActionsDropdown = ({ hostMdmEnrollmentStatus ?? "" ), isFleetMdm: mdmName === "Fleet", - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured, doesStoreEncryptionKey: doesStoreEncryptionKey ?? false, isSandboxMode, hostMdmDeviceStatus, diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 7cee2c5e96..5310ea7ba9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -44,11 +44,11 @@ const DEFAULT_OPTIONS = [ value: "lock", disabled: false, }, - // { - // label: "Wipe", - // value: "wipe", - // disabled: false, - // }, + { + label: "Wipe", + value: "wipe", + disabled: false, + }, { label: "Unlock", value: "unlock", @@ -74,7 +74,8 @@ interface IHostActionConfigOptions { isHostOnline: boolean; isEnrolledInMdm: boolean; isFleetMdm: boolean; - isMdmEnabledAndConfigured: boolean; + isMacMdmEnabledAndConfigured: boolean; + isWindowsMdmEnabledAndConfigured: boolean; doesStoreEncryptionKey: boolean; isSandboxMode: boolean; hostMdmDeviceStatus: HostMdmDeviceStatusUIState; @@ -93,11 +94,11 @@ const canEditMdm = (config: IHostActionConfigOptions) => { isTeamMaintainer, isEnrolledInMdm, isFleetMdm, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, } = config; return ( config.hostPlatform === "darwin" && - isMdmEnabledAndConfigured && + isMacMdmEnabledAndConfigured && isEnrolledInMdm && isFleetMdm && (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) @@ -107,7 +108,7 @@ const canEditMdm = (config: IHostActionConfigOptions) => { const canLockHost = ({ isPremiumTier, hostPlatform, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, isEnrolledInMdm, isFleetMdm, isGlobalAdmin, @@ -120,7 +121,7 @@ const canLockHost = ({ const canLockDarwin = hostPlatform === "darwin" && isFleetMdm && - isMdmEnabledAndConfigured && + isMacMdmEnabledAndConfigured && isEnrolledInMdm; return ( @@ -143,23 +144,23 @@ const canWipeHost = ({ isTeamObserver, isFleetMdm, isEnrolledInMdm, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured, hostPlatform, + hostMdmDeviceStatus, }: IHostActionConfigOptions) => { - // TODO: remove when we work on wipe issue. - return false; + const hostMdmEnabled = + (hostPlatform === "darwin" && isMacMdmEnabledAndConfigured) || + (hostPlatform === "windows" && isWindowsMdmEnabledAndConfigured); // macOS and Windows hosts have the same conditions and can be wiped if they // are enrolled in MDM and the MDM is enabled. - const canWipeMacOrWindows = - (hostPlatform === "darwin" || hostPlatform === "windows") && - isFleetMdm && - isMdmEnabledAndConfigured && - isEnrolledInMdm; + const canWipeMacOrWindows = hostMdmEnabled && isFleetMdm && isEnrolledInMdm; return ( isPremiumTier && - (hostPlatform === "linux" || canWipeMacOrWindows) && + hostMdmDeviceStatus === "unlocked" && + (isLinuxLike(hostPlatform) || canWipeMacOrWindows) && (isGlobalAdmin || isGlobalMaintainer || isGlobalObserver || @@ -177,14 +178,14 @@ const canUnlock = ({ isTeamMaintainer, isFleetMdm, isEnrolledInMdm, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, hostPlatform, hostMdmDeviceStatus, }: IHostActionConfigOptions) => { - const canLockDarwin = + const canUnlockDarwin = hostPlatform === "darwin" && isFleetMdm && - isMdmEnabledAndConfigured && + isMacMdmEnabledAndConfigured && isEnrolledInMdm; // "unlocking" for a macOS host means that somebody saw the unlock pin, but @@ -198,7 +199,7 @@ const canUnlock = ({ isPremiumTier && isValidState && (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) && - (canLockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform)) + (canUnlockDarwin || hostPlatform === "windows" || isLinuxLike(hostPlatform)) ); }; @@ -265,9 +266,9 @@ const filterOutOptions = ( options = options.filter((option) => option.value !== "lock"); } - // if (!canWipeHost(config)) { - // options = options.filter((option) => option.value !== "wipe"); - // } + if (!canWipeHost(config)) { + options = options.filter((option) => option.value !== "wipe"); + } if (!canUnlock(config)) { options = options.filter((option) => option.value !== "unlock"); @@ -292,7 +293,12 @@ const setOptionsAsDisabled = ( }; let optionsToDisable: IDropdownOption[] = []; - if (!isHostOnline) { + if ( + !isHostOnline || + isDeviceStatusUpdating(hostMdmDeviceStatus) || + hostMdmDeviceStatus === "locked" || + hostMdmDeviceStatus === "wiped" + ) { optionsToDisable = optionsToDisable.concat( options.filter( (option) => option.value === "query" || option.value === "mdmOff" @@ -304,16 +310,6 @@ const setOptionsAsDisabled = ( options.filter((option) => option.value === "transfer") ); } - if ( - isDeviceStatusUpdating(hostMdmDeviceStatus) || - hostMdmDeviceStatus === "locked" - ) { - optionsToDisable = optionsToDisable.concat( - options.filter( - (option) => option.value === "query" || option.value === "mdmOff" - ) - ); - } disableOptions(optionsToDisable); return options; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index 2da5e28db2..99faef93dd 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -14,6 +14,7 @@ import { NotificationContext } from "context/notification"; import activitiesAPI, { IActivitiesResponse, + IPastActivitiesResponse, IUpcomingActivitiesResponse, } from "services/entities/activities"; import hostAPI from "services/entities/hosts"; @@ -90,6 +91,7 @@ import { HostMdmDeviceStatusUIState, getHostDeviceStatusUIState, } from "../helpers"; +import WipeModal from "./modals/WipeModal"; const baseClass = "host-details"; @@ -164,6 +166,7 @@ const HostDetailsPage = ({ ); const [showLockHostModal, setShowLockHostModal] = useState(false); const [showUnlockHostModal, setShowUnlockHostModal] = useState(false); + const [showWipeModal, setShowWipeModal] = useState(false); const [scriptDetailsId, setScriptDetailsId] = useState(""); const [selectedPolicy, setSelectedPolicy] = useState( null @@ -366,9 +369,9 @@ const HostDetailsPage = ({ isError: pastActivitiesIsError, refetch: refetchPastActivities, } = useQuery< - IActivitiesResponse, + IPastActivitiesResponse, Error, - IActivitiesResponse, + IPastActivitiesResponse, Array<{ scope: string; pageIndex: number; @@ -644,6 +647,9 @@ const HostDetailsPage = ({ case "unlock": setShowUnlockHostModal(true); break; + case "wipe": + setShowWipeModal(true); + break; default: // do nothing } }; @@ -976,6 +982,14 @@ const HostDetailsPage = ({ onClose={() => setShowUnlockHostModal(false)} /> )} + {showWipeModal && ( + setHostMdmDeviceState("wiping")} + onClose={() => setShowWipeModal(false)} + /> + )} ); diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx new file mode 100644 index 0000000000..7df40b2adb --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/WipeModal.tsx @@ -0,0 +1,77 @@ +import React, { useContext } from "react"; + +import hostAPI from "services/entities/hosts"; +import { getErrorReason } from "interfaces/errors"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; +import { NotificationContext } from "context/notification"; + +const baseClass = "wipe-modal"; + +interface IWipeModalProps { + id: number; + hostName: string; + onSuccess: () => void; + onClose: () => void; +} + +const WipeModal = ({ id, hostName, onSuccess, onClose }: IWipeModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [lockChecked, setLockChecked] = React.useState(false); + const [isWiping, setIsWiping] = React.useState(false); + + const onWipe = async () => { + setIsWiping(true); + try { + await hostAPI.wipeHost(id); + onSuccess(); + renderFlash("success", "Success! Host is wiping."); + } catch (e) { + renderFlash("error", getErrorReason(e)); + } + onClose(); + setIsWiping(false); + }; + + return ( + + <> +
+

All content will be erased on this host.

+
+ + Please check to confirm: + + setLockChecked(value)} + > + I wish to wipe {hostName} + +
+
+ +
+ + +
+ +
+ ); +}; + +export default WipeModal; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss new file mode 100644 index 0000000000..e5616edd18 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/_styles.scss @@ -0,0 +1,14 @@ +.wipe-modal { + p { + margin: 0; + } + + &__modal-content { + display: grid; + gap: $pad-large; + } + + &__wipe-checkbox { + margin-top: $pad-small; + } +} diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts new file mode 100644 index 0000000000..299b889006 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/WipeModal/index.ts @@ -0,0 +1 @@ +export { default } from "./WipeModal"; diff --git a/frontend/pages/hosts/details/cards/Activity/Activity.tsx b/frontend/pages/hosts/details/cards/Activity/Activity.tsx index 2421182e22..628bd71073 100644 --- a/frontend/pages/hosts/details/cards/Activity/Activity.tsx +++ b/frontend/pages/hosts/details/cards/Activity/Activity.tsx @@ -2,7 +2,10 @@ import React from "react"; import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import { IActivityDetails } from "interfaces/activity"; -import { IActivitiesResponse } from "services/entities/activities"; +import { + IPastActivitiesResponse, + IUpcomingActivitiesResponse, +} from "services/entities/activities"; import Card from "components/Card"; import TabsWrapper from "components/TabsWrapper"; @@ -45,7 +48,7 @@ const UpcomingTooltip = () => { interface IActivityProps { activeTab: "past" | "upcoming"; - activities?: IActivitiesResponse; + activities?: IPastActivitiesResponse | IUpcomingActivitiesResponse; isLoading?: boolean; isError?: boolean; upcomingCount: number; @@ -93,7 +96,7 @@ const Activity = ({ + | React.FC +> = { + [ActivityType.RanScript]: RanScriptActivityItem, + [ActivityType.LockedHost]: LockedHostActivityItem, + [ActivityType.UnlockedHost]: UnlockedHostActivityItem, +}; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx new file mode 100644 index 0000000000..09bc6a7c89 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/LockedHostActivityItem.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; +import HostActivityItem from "../../HostActivityItem"; + +const baseClass = "locked-host-activity-item"; + +const LockedHostActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + {activity.actor_full_name} locked this host. + + ); +}; + +export default LockedHostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts new file mode 100644 index 0000000000..ad51e3fabb --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/LockedHostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./LockedHostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx new file mode 100644 index 0000000000..94b1a8dc7c --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/RanScriptActivityItem.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import { formatScriptNameForActivityItem } from "utilities/helpers"; + +import HostActivityItem from "../../HostActivityItem"; +import { IHostActivityItemComponentPropsWithShowDetails } from "../../ActivityConfig"; +import ShowDetailsButton from "../../ShowDetailsButton"; + +const baseClass = "ran-script-activity-item"; + +const RanScriptActivityItem = ({ + activity, + onShowDetails, +}: IHostActivityItemComponentPropsWithShowDetails) => { + return ( + + {activity.actor_full_name} + <> + {" "} + ran {formatScriptNameForActivityItem(activity.details?.script_name)} on + this host.{" "} + + + + ); +}; + +export default RanScriptActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts new file mode 100644 index 0000000000..ea03a04cad --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/RanScriptActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./RanScriptActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx new file mode 100644 index 0000000000..079f4463b7 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/UnlockedHostActivityItem.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import { IHostActivityItemComponentProps } from "../../ActivityConfig"; +import HostActivityItem from "../../HostActivityItem"; + +const baseClass = "unlocked-host-activity-item"; + +const UnlockedHostActivityItem = ({ + activity, +}: IHostActivityItemComponentProps) => { + return ( + + {activity.actor_full_name} unlocked this host. + + ); +}; + +export default UnlockedHostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts new file mode 100644 index 0000000000..7e5a20b070 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ActivityItems/UnlockedHostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./UnlockedHostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx new file mode 100644 index 0000000000..44cc1267c0 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/HostActivityItem.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import ReactTooltip from "react-tooltip"; +import { formatDistanceToNowStrict } from "date-fns"; +import classnames from "classnames"; + +import { IActivity } from "interfaces/activity"; +import { + addGravatarUrlToResource, + internationalTimeFormat, +} from "utilities/helpers"; +import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; + +import Avatar from "components/Avatar"; + +import { COLORS } from "styles/var/colors"; + +const baseClass = "host-activity-item"; + +interface IHostActivityItemProps { + activity: IActivity; + children: React.ReactNode; + className?: string; +} + +/** + * A wrapper that will render all the common elements of a host activity item. + * This includes the avatar, the created at timestamp, and a dash to separate + * the activity items. The `children` will be the specific details of the activity + * implemented in the component that uses this wrapper. + */ +const HostActivityItem = ({ + activity, + children, + className, +}: IHostActivityItemProps) => { + const { actor_email } = activity; + const { gravatar_url } = actor_email + ? addGravatarUrlToResource({ email: actor_email }) + : { gravatar_url: DEFAULT_GRAVATAR_LINK }; + + // wrapped just in case the date string does not parse correctly + let activityCreatedAt: Date | null = null; + try { + activityCreatedAt = new Date(activity.created_at); + } catch (e) { + activityCreatedAt = null; + } + + const classNames = classnames(baseClass, className); + + return ( +
+ +
+
+ + {children} + +
+ + {activityCreatedAt && + formatDistanceToNowStrict(activityCreatedAt, { + addSuffix: true, + })} + + {activityCreatedAt && ( + + {internationalTimeFormat(activityCreatedAt)} + + )} +
+
+
+
+ ); +}; + +export default HostActivityItem; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss similarity index 98% rename from frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss rename to frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss index 04173fdab9..cfb2fcd282 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/_styles.scss +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/_styles.scss @@ -1,4 +1,4 @@ -.past-activity { +.host-activity-item { display: grid; // Grid system is used to create variable dashed line lengths grid-template-columns: 16px 16px 1fr; grid-template-rows: 32px max-content; @@ -62,4 +62,5 @@ padding-bottom: $pad-xxlarge; } } + } diff --git a/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts new file mode 100644 index 0000000000..b711dde773 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/HostActivityItem/index.ts @@ -0,0 +1 @@ +export { default } from "./HostActivityItem"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx deleted file mode 100644 index 8dadc1328c..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/PastActivity.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React from "react"; -import ReactTooltip from "react-tooltip"; -import { formatDistanceToNowStrict } from "date-fns"; - -import Avatar from "components/Avatar"; -import Icon from "components/Icon"; -import Button from "components/buttons/Button"; - -import { COLORS } from "styles/var/colors"; -import { DEFAULT_GRAVATAR_LINK } from "utilities/constants"; -import { - addGravatarUrlToResource, - formatScriptNameForActivityItem, - internationalTimeFormat, -} from "utilities/helpers"; -import { IActivity } from "interfaces/activity"; -import { ShowActivityDetailsHandler } from "../Activity"; - -const baseClass = "past-activity"; - -interface IPastActivityProps { - activity: IActivity; - // TODO: To handle clicks for different activity types, this could be refactored as a reducer that - // takes the activity and dispatches the relevant show details action based on the activity type - onDetailsClick: ShowActivityDetailsHandler; -} - -const RanScriptActivityDetails = ({ - activity, - onDetailsClick, -}: Pick) => ( - - {activity.actor_full_name} - <> - {" "} - ran {formatScriptNameForActivityItem(activity.details?.script_name)} on - this host.{" "} - - - -); - -const LockedHostActivityDetails = ({ - activity, -}: Pick) => ( - - {activity.actor_full_name} locked this host. - -); - -const UnlockedHostActivityDetails = ({ - activity, -}: Pick) => ( - - {activity.actor_full_name}{" "} - {activity.details?.host_platform === "darwin" - ? "viewed the six-digit unlock PIN for" - : "unlocked"}{" "} - this host. - -); - -const PastActivityTopline = ({ - activity, - onDetailsClick, -}: IPastActivityProps) => { - switch (activity.type) { - case "ran_script": - return ( - - ); - case "locked_host": - return ; - case "unlocked_host": - return ; - default: - return null; - } -}; - -// TODO: Combine this with ./UpcomingActivity/UpcomingActivity.tsx and -// frontend/pages/DashboardPage/cards/ActivityFeed/ActivityItem/ActivityItem.tsx -const PastActivity = ({ activity, onDetailsClick }: IPastActivityProps) => { - const { actor_email } = activity; - const { gravatar_url } = actor_email - ? addGravatarUrlToResource({ email: actor_email }) - : { gravatar_url: DEFAULT_GRAVATAR_LINK }; - const activityCreatedAt = new Date(activity.created_at); - - return ( -
- -
-
- -
- - {formatDistanceToNowStrict(activityCreatedAt, { - addSuffix: true, - })} - - - {internationalTimeFormat(activityCreatedAt)} - -
-
-
-
- ); -}; - -export default PastActivity; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts b/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts deleted file mode 100644 index 363a39834c..0000000000 --- a/frontend/pages/hosts/details/cards/Activity/PastActivity/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from "./PastActivity"; diff --git a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx index 086f481b6a..05c7a790a8 100644 --- a/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx +++ b/frontend/pages/hosts/details/cards/Activity/PastActivityFeed/PastActivityFeed.tsx @@ -1,7 +1,7 @@ import React from "react"; -import { IActivity } from "interfaces/activity"; -import { IActivitiesResponse } from "services/entities/activities"; +import { IPastActivity } from "interfaces/activity"; +import { IPastActivitiesResponse } from "services/entities/activities"; // @ts-ignore import FleetIcon from "components/icons/FleetIcon"; @@ -9,13 +9,14 @@ import Button from "components/buttons/Button"; import DataError from "components/DataError"; import EmptyFeed from "../EmptyFeed/EmptyFeed"; -import PastActivity from "../PastActivity/PastActivity"; import { ShowActivityDetailsHandler } from "../Activity"; +import { pastActivityComponentMap } from "../ActivityConfig"; + const baseClass = "past-activity-feed"; interface IPastActivityFeedProps { - activities?: IActivitiesResponse; + activities?: IPastActivitiesResponse; isError?: boolean; onDetailsClick: ShowActivityDetailsHandler; onNextPage: () => void; @@ -52,9 +53,16 @@ const PastActivityFeed = ({ return (
- {activitiesList.map((activity: IActivity) => ( - - ))} + {activitiesList.map((activity: IPastActivity) => { + const ActivityItemComponent = pastActivityComponentMap[activity.type]; + return ( + + ); + })}
+ ); +}; + +export default ShowDetailsButton; diff --git a/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss new file mode 100644 index 0000000000..c1f3a99a32 --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/_styles.scss @@ -0,0 +1,5 @@ +.show-details-button { + &__show-details-icon { + margin-left: $pad-xsmall; + } +} diff --git a/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts new file mode 100644 index 0000000000..533c76eddd --- /dev/null +++ b/frontend/pages/hosts/details/cards/Activity/ShowDetailsButton/index.ts @@ -0,0 +1 @@ +export { default } from "./ShowDetailsButton"; diff --git a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss index b95055e0b6..7b5cca654a 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss +++ b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss @@ -12,7 +12,8 @@ } &.error { - background-color: $ui-error; + color: $core-white; + background-color: $core-vibrant-red; } } diff --git a/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx b/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx index 0282416be7..668feffc6c 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/helpers.tsx @@ -39,6 +39,20 @@ export const DEVICE_STATUS_TAGS: DeviceStatusTagConfig = { generateTooltip: (platform) => "Host will lock when it comes online. If the host is online, it will lock the next time it checks in to Fleet.", }, + wiped: { + title: "WIPED", + tagType: "error", + generateTooltip: (platform) => + platform === "darwin" + ? "Host is wiped. To prevent the host from automatically reenrolling to Fleet, first release the host from Apple Business Manager and then delete the host in Fleet." + : "Host is wiped.", + }, + wiping: { + title: "WIPE PENDING", + tagType: "error", + generateTooltip: () => + "Host will wipe when it comes online. If the host is online, it will wipe the next time it checks in to Fleet.", + }, }; // We exclude "unlocked" as we dont display a tooltip for it. @@ -66,4 +80,14 @@ export const REFETCH_TOOLTIP_MESSAGES: Record< You can't fetch data from
a locked host. ), + wiping: ( + <> + You can't fetch data from
a wiping host. + + ), + wiped: ( + <> + You can't fetch data from
a wiped host. + + ), } as const; diff --git a/frontend/pages/hosts/details/helpers.ts b/frontend/pages/hosts/details/helpers.ts index c0a3a9a316..52a0efd6dd 100644 --- a/frontend/pages/hosts/details/helpers.ts +++ b/frontend/pages/hosts/details/helpers.ts @@ -1,5 +1,4 @@ /** Helpers used across the host details and my device pages and components. */ -import { is } from "date-fns/locale"; import { HostMdmDeviceStatus, HostMdmPendingAction } from "interfaces/host"; import { IHostMdmProfile, @@ -39,7 +38,9 @@ export type HostMdmDeviceStatusUIState = | "unlocked" | "locked" | "unlocking" - | "locking"; + | "locking" + | "wiped" + | "wiping"; // Exclude the empty string from HostPendingAction as that doesn't represent a // valid device status. @@ -51,9 +52,11 @@ const API_TO_UI_DEVICE_STATUS_MAP: Record< locked: "locked", unlock: "unlocking", lock: "locking", + wiped: "wiped", + wipe: "wiping", }; -const deviceUpdatingStates = ["unlocking", "locking"] as const; +const deviceUpdatingStates = ["unlocking", "locking", "wiping"] as const; /** * Gets the current UI state for the host device status. This helps us know what @@ -74,7 +77,7 @@ export const getHostDeviceStatusUIState = ( }; /** - * Helps check if our device status UI state is in an updating state. + * Checks if our device status UI state is in an updating state. */ export const isDeviceStatusUpdating = ( deviceStatus: HostMdmDeviceStatusUIState diff --git a/frontend/services/entities/activities.ts b/frontend/services/entities/activities.ts index d63e90fae1..7a45e52b83 100644 --- a/frontend/services/entities/activities.ts +++ b/frontend/services/entities/activities.ts @@ -1,5 +1,5 @@ import endpoints from "utilities/endpoints"; -import { IActivity } from "interfaces/activity"; +import { IActivity, IPastActivity } from "interfaces/activity"; import sendRequest from "services"; import { buildQueryStringFromParams } from "utilities/url"; @@ -16,6 +16,14 @@ export interface IActivitiesResponse { }; } +export interface IPastActivitiesResponse { + activities: IPastActivity[] | null; + meta: { + has_next_results: boolean; + has_previous_results: boolean; + }; +} + export interface IUpcomingActivitiesResponse extends IActivitiesResponse { count: number; } @@ -45,7 +53,7 @@ export default { id: number, page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE - ): Promise => { + ): Promise => { const { HOST_PAST_ACTIVITIES } = endpoints; const queryParams = { diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index 2104fa2469..16bddc671a 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -397,8 +397,14 @@ export default { const { HOST_LOCK } = endpoints; return sendRequest("POST", HOST_LOCK(id)); }, + unlockHost: (id: number): Promise => { const { HOST_UNLOCK } = endpoints; return sendRequest("POST", HOST_UNLOCK(id)); }, + + wipeHost: (id: number) => { + const { HOST_WIPE } = endpoints; + return sendRequest("POST", HOST_WIPE(id)); + }, }; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 491c14690b..b54ec4fd97 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -43,6 +43,7 @@ export default { HOSTS_TRANSFER_BY_FILTER: `/${API_VERSION}/fleet/hosts/transfer/filter`, HOST_LOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/lock`, HOST_UNLOCK: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/unlock`, + HOST_WIPE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/wipe`, INVITES: `/${API_VERSION}/fleet/invites`, LABELS: `/${API_VERSION}/fleet/labels`, diff --git a/frontend/utilities/permissions/permissions.ts b/frontend/utilities/permissions/permissions.ts index 8a394c4732..5ec2c2d35e 100644 --- a/frontend/utilities/permissions/permissions.ts +++ b/frontend/utilities/permissions/permissions.ts @@ -13,10 +13,14 @@ export const isPremiumTier = (config: IConfig): boolean => { return config.license.tier === "premium"; }; -export const isMdmEnabledAndConfigured = (config: IConfig): boolean => { +export const isMacMdmEnabledAndConfigured = (config: IConfig): boolean => { return Boolean(config.mdm.enabled_and_configured); }; +export const isWindowsMdmEnabledAndConfigured = (config: IConfig): boolean => { + return Boolean(config.mdm.windows_enabled_and_configured); +}; + export const isGlobalAdmin = (user: IUser): boolean => { return user.global_role === "admin"; }; @@ -142,7 +146,8 @@ export default { isSandboxMode, isFreeTier, isPremiumTier, - isMdmEnabledAndConfigured, + isMacMdmEnabledAndConfigured, + isWindowsMdmEnabledAndConfigured, isGlobalAdmin, isGlobalMaintainer, isGlobalObserver, diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index e93eab08f7..9c3cd5b0bd 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -653,6 +653,11 @@ func updateMDMAppleHostDB( return ctxerr.Wrap(ctx, err, "update mdm apple host") } + // clear any host_mdm_actions following re-enrollment here + if _, err := tx.ExecContext(ctx, `DELETE FROM host_mdm_actions WHERE host_id = ?`, hostID); err != nil { + return ctxerr.Wrap(ctx, err, "error clearing mdm apple host_mdm_actions") + } + if err := upsertMDMAppleHostMDMInfoDB(ctx, tx, appCfg.ServerSettings, false, hostID); err != nil { return ctxerr.Wrap(ctx, err, "ingest mdm apple host upsert MDM info") } diff --git a/server/datastore/mysql/apple_mdm_test.go b/server/datastore/mysql/apple_mdm_test.go index ed3da41248..77da42fc92 100644 --- a/server/datastore/mysql/apple_mdm_test.go +++ b/server/datastore/mysql/apple_mdm_test.go @@ -67,7 +67,7 @@ func TestMDMApple(t *testing.T) { {"TestMDMAppleConfigProfileHash", testMDMAppleConfigProfileHash}, {"TestResetMDMAppleEnrollment", testResetMDMAppleEnrollment}, {"TestMDMAppleDeleteHostDEPAssignments", testMDMAppleDeleteHostDEPAssignments}, - {"CleanMacOSMDMLock", testCleanMacOSMDMLock}, + {"LockUnlockWipeMacOS", testLockUnlockWipeMacOS}, } for _, c := range cases { @@ -4416,18 +4416,9 @@ func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) { } } -func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) { +func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) { ctx := context.Background() - checkState := func(t *testing.T, status *fleet.HostLockWipeStatus, unlocked, locked, wiped, pendingUnlock, pendingLock, pendingWipe bool) { - require.Equal(t, unlocked, status.IsUnlocked()) - require.Equal(t, locked, status.IsLocked()) - require.Equal(t, wiped, status.IsWiped()) - require.Equal(t, pendingLock, status.IsPendingLock()) - require.Equal(t, pendingUnlock, status.IsPendingUnlock()) - require.Equal(t, pendingWipe, status.IsPendingWipe()) - } - host, err := ds.NewHost(ctx, &fleet.Host{ Hostname: "test-host1-name", OsqueryHostID: ptr.String("1337"), @@ -4439,11 +4430,11 @@ func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) { require.NoError(t, err) nanoEnroll(t, ds, host, false) - status, err := ds.GetHostLockWipeStatus(ctx, host.ID, "macos") + status, err := ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) // default state - checkState(t, status, true, false, false, false, false, false) + checkLockWipeState(t, status, true, false, false, false, false, false) appleStore, err := ds.NewMDMAppleMDMStorage(nil, nil) require.NoError(t, err) @@ -4457,18 +4448,117 @@ func testCleanMacOSMDMLock(t *testing.T, ds *Datastore) { err = appleStore.EnqueueDeviceLockCommand(ctx, host, cmd, "123456") require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + // it is now pending lock + status, err = ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) - checkState(t, status, true, false, false, false, true, false) + checkLockWipeState(t, status, true, false, false, false, true, false) + + // record a command result to simulate locked state + err = appleStore.StoreCommandReport(&mdm.Request{ + EnrollID: &mdm.EnrollID{ID: host.UUID}, + Context: ctx, + }, &mdm.CommandResults{ + CommandUUID: cmd.CommandUUID, + Status: "Acknowledged", + RequestType: "DeviceLock", + Raw: cmd.Raw, + }) + require.NoError(t, err) + + err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, "DeviceLock", true) + require.NoError(t, err) + + // it is now locked + status, err = ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + checkLockWipeState(t, status, false, true, false, false, false, false) + + // request an unlock, to make it pending unlock + err = ds.UnlockHostManually(ctx, host.ID, host.FleetPlatform(), time.Now().UTC()) + require.NoError(t, err) + + // it is now locked pending unlock + status, err = ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + checkLockWipeState(t, status, false, true, false, true, false, false) // execute CleanMacOSMDMLock to simulate successful unlock err = ds.CleanMacOSMDMLock(ctx, host.UUID) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, host.ID, "macos") + // it is back to unlocked state + status, err = ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) - checkState(t, status, true, false, false, false, false, false) + checkLockWipeState(t, status, true, false, false, false, false, false) require.Empty(t, status.UnlockPIN) + + // record a request to wipe the host + cmd = &mdm.Command{ + CommandUUID: uuid.NewString(), + Raw: []byte(" or // entries to track them as responses. - var args []any - var sb strings.Builder - var potentialProfilePayloads []*fleet.MDMWindowsProfilePayload + var ( + args []any + sb strings.Builder + potentialProfilePayloads []*fleet.MDMWindowsProfilePayload + + wipeCmdUUID string + wipeCmdStatus string + ) + for _, cmd := range matchingCmds { statusCode := "" if status, ok := uuidsToStatus[cmd.CommandUUID]; ok && status.Data != nil { @@ -327,6 +337,13 @@ ON DUPLICATE KEY UPDATE } args = append(args, enrollment.ID, cmd.CommandUUID, rawResult, responseID, statusCode) sb.WriteString("(?, ?, ?, ?, ?),") + + // if the command is a Wipe, keep track of it so we can update + // host_mdm_actions accordingly. + if strings.Contains(cmd.TargetLocURI, "/Device/Vendor/MSFT/RemoteWipe/") { + wipeCmdUUID = cmd.CommandUUID + wipeCmdStatus = statusCode + } } if err := updateMDMWindowsHostProfileStatusFromResponseDB(ctx, tx, potentialProfilePayloads); err != nil { @@ -339,6 +356,14 @@ ON DUPLICATE KEY UPDATE return ctxerr.Wrap(ctx, err, "inserting command results") } + // if we received a Wipe command result, update the host's status + if wipeCmdUUID != "" { + if err := updateHostLockWipeStatusFromResultAndHostUUID(ctx, tx, enrollment.HostUUID, + "wipe_ref", wipeCmdUUID, strings.HasPrefix(wipeCmdStatus, "2")); err != nil { + return ctxerr.Wrap(ctx, err, "updating wipe command result in host_mdm_actions") + } + } + // dequeue the commands var matchingUUIDs []string for _, cmd := range matchingCmds { @@ -1874,3 +1899,27 @@ host_uuid = ? AND profile_name NOT IN(?) AND NOT (operation_type = '%s' AND COAL } return profiles, nil } + +func (ds *Datastore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error { + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + if err := ds.mdmWindowsInsertCommandForHostsDB(ctx, tx, []string{host.UUID}, cmd); err != nil { + return err + } + + stmt := ` + INSERT INTO host_mdm_actions ( + host_id, + wipe_ref, + fleet_platform + ) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + wipe_ref = VALUES(wipe_ref)` + + if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, host.FleetPlatform()); err != nil { + return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for wipe_ref") + } + + return nil + }) +} diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go index 96cc36d5ef..dff2c124ea 100644 --- a/server/datastore/mysql/microsoft_mdm_test.go +++ b/server/datastore/mysql/microsoft_mdm_test.go @@ -1266,7 +1266,8 @@ func testMDMWindowsCommandResults(t *testing.T, ds *Datastore) { require.Empty(t, results) } -func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) { +// enrolls the host in Windows MDM and returns the device's enrollment ID. +func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) string { ctx := context.Background() d1 := &fleet.MDMWindowsEnrolledDevice{ MDMDeviceID: uuid.New().String(), @@ -1285,6 +1286,7 @@ func windowsEnroll(t *testing.T, ds fleet.Datastore, h *fleet.Host) { require.NoError(t, err) err = ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, d1.HostUUID, d1.MDMDeviceID) require.NoError(t, err) + return d1.MDMDeviceID } func testMDMWindowsProfileManagement(t *testing.T, ds *Datastore) { diff --git a/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions.go b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions.go new file mode 100644 index 0000000000..8a74ea9a33 --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions.go @@ -0,0 +1,25 @@ +package tables + +import ( + "database/sql" + "fmt" +) + +func init() { + MigrationClient.AddMigration(Up_20240301173035, Down_20240301173035) +} + +func Up_20240301173035(tx *sql.Tx) error { + _, err := tx.Exec(` +ALTER TABLE host_mdm_actions + ADD COLUMN fleet_platform VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' +`) + if err != nil { + return fmt.Errorf("failed to alter host_mdm_actions table: %w", err) + } + return nil +} + +func Down_20240301173035(tx *sql.Tx) error { + return nil +} diff --git a/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions_test.go b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions_test.go new file mode 100644 index 0000000000..b177c1ab9c --- /dev/null +++ b/server/datastore/mysql/migrations/tables/20240301173035_AddFleetPlatformToHostMDMActions_test.go @@ -0,0 +1,33 @@ +package tables + +import ( + "testing" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" +) + +func TestUp_20240301173035(t *testing.T) { + db := applyUpToPrev(t) + + // create an existing host_mdm_actions row + _, err := db.Exec("INSERT INTO host_mdm_actions (host_id, lock_ref) VALUES (1, 'a')") + require.NoError(t, err) + + applyNext(t, db) + + var hostActions []struct { + HostID uint `db:"host_id"` + LockRef *string `db:"lock_ref"` + FleetPlatform string `db:"fleet_platform"` + } + + // fleet platform is left empty for pre-existing rows + err = sqlx.Select(db, &hostActions, `SELECT host_id, lock_ref, fleet_platform FROM host_mdm_actions`) + require.NoError(t, err) + require.Len(t, hostActions, 1) + require.Equal(t, uint(1), hostActions[0].HostID) + require.NotNil(t, hostActions[0].LockRef) + require.Equal(t, "a", *hostActions[0].LockRef) + require.Empty(t, hostActions[0].FleetPlatform) +} diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index ba03d27271..39faf5627e 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -1188,8 +1188,6 @@ func insertOnDuplicateDidUpdate(res sql.Result) bool { // time of the Exec call, and the result simply returns the integers it // already holds: // https://github.com/go-sql-driver/mysql/blob/bcc459a906419e2890a50fc2c99ea6dd927a88f2/result.go - // - // TODO(mna): would that work on mariadb too? lastID, _ := res.LastInsertId() aff, _ := res.RowsAffected() diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index 48c5d1d56c..c1929a4ba9 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -86,28 +86,26 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand( cmd *mdm.Command, pin string, ) error { - return withRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error { if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil { return err } - // TODO(roberto): call @mna's transactionable method to update - // these tables when it's ready. stmt := ` - INSERT INTO host_mdm_actions ( - host_id, - lock_ref, - unlock_pin - ) - VALUES (?, ?, ?) - ON DUPLICATE KEY UPDATE - wipe_ref = NULL, - unlock_ref = NULL, + INSERT INTO host_mdm_actions ( + host_id, + lock_ref, + unlock_pin, + fleet_platform + ) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + wipe_ref = NULL, + unlock_ref = NULL, unlock_pin = VALUES(unlock_pin), - lock_ref = VALUES(lock_ref)` + lock_ref = VALUES(lock_ref)` - if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, pin); err != nil { + if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, pin, host.FleetPlatform()); err != nil { return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceLock") } @@ -115,6 +113,31 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand( }, s.logger) } +// EnqueueDeviceWipeCommand enqueues a EraseDevice command for the given host. +func (s *NanoMDMStorage) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + return withRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error { + if err := enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd); err != nil { + return err + } + + stmt := ` + INSERT INTO host_mdm_actions ( + host_id, + wipe_ref, + fleet_platform + ) + VALUES (?, ?, ?) + ON DUPLICATE KEY UPDATE + wipe_ref = VALUES(wipe_ref)` + + if _, err := tx.ExecContext(ctx, stmt, host.ID, cmd.CommandUUID, host.FleetPlatform()); err != nil { + return ctxerr.Wrap(ctx, err, "modifying host_mdm_actions for DeviceWipe") + } + + return nil + }, s.logger) +} + // NewMDMAppleDEPStorage returns a MySQL nanodep storage that uses the Datastore // underlying MySQL writer *sql.DB. func (ds *Datastore) NewMDMAppleDEPStorage(tok nanodep_client.OAuth1Tokens) (*NanoDEPStorage, error) { diff --git a/server/datastore/mysql/nanomdm_storage_test.go b/server/datastore/mysql/nanomdm_storage_test.go index c08ba8a00c..034fab16d7 100644 --- a/server/datastore/mysql/nanomdm_storage_test.go +++ b/server/datastore/mysql/nanomdm_storage_test.go @@ -78,7 +78,7 @@ func testEnqueueDeviceLockCommand(t *testing.T, ds *Datastore) { }, }, res) - status, err := ds.GetHostLockWipeStatus(ctx, host.ID, "darwin") + status, err := ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) require.Equal(t, "cmd-uuid", status.LockMDMCommand.CommandUUID) require.Equal(t, "123456", status.UnlockPIN) diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index 94ab9bb93d..823c6cd954 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -289,6 +289,7 @@ CREATE TABLE `host_mdm_actions` ( `wipe_ref` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `unlock_pin` varchar(6) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `unlock_ref` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fleet_platform` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', PRIMARY KEY (`host_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; @@ -775,9 +776,9 @@ CREATE TABLE `migration_status_tables` ( `tstamp` timestamp NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `id` (`id`) -) ENGINE=InnoDB AUTO_INCREMENT=253 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +) ENGINE=InnoDB AUTO_INCREMENT=254 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'); +INSERT INTO `migration_status_tables` VALUES (1,0,1,'2020-01-01 01:01:01'),(2,20161118193812,1,'2020-01-01 01:01:01'),(3,20161118211713,1,'2020-01-01 01:01:01'),(4,20161118212436,1,'2020-01-01 01:01:01'),(5,20161118212515,1,'2020-01-01 01:01:01'),(6,20161118212528,1,'2020-01-01 01:01:01'),(7,20161118212538,1,'2020-01-01 01:01:01'),(8,20161118212549,1,'2020-01-01 01:01:01'),(9,20161118212557,1,'2020-01-01 01:01:01'),(10,20161118212604,1,'2020-01-01 01:01:01'),(11,20161118212613,1,'2020-01-01 01:01:01'),(12,20161118212621,1,'2020-01-01 01:01:01'),(13,20161118212630,1,'2020-01-01 01:01:01'),(14,20161118212641,1,'2020-01-01 01:01:01'),(15,20161118212649,1,'2020-01-01 01:01:01'),(16,20161118212656,1,'2020-01-01 01:01:01'),(17,20161118212758,1,'2020-01-01 01:01:01'),(18,20161128234849,1,'2020-01-01 01:01:01'),(19,20161230162221,1,'2020-01-01 01:01:01'),(20,20170104113816,1,'2020-01-01 01:01:01'),(21,20170105151732,1,'2020-01-01 01:01:01'),(22,20170108191242,1,'2020-01-01 01:01:01'),(23,20170109094020,1,'2020-01-01 01:01:01'),(24,20170109130438,1,'2020-01-01 01:01:01'),(25,20170110202752,1,'2020-01-01 01:01:01'),(26,20170111133013,1,'2020-01-01 01:01:01'),(27,20170117025759,1,'2020-01-01 01:01:01'),(28,20170118191001,1,'2020-01-01 01:01:01'),(29,20170119234632,1,'2020-01-01 01:01:01'),(30,20170124230432,1,'2020-01-01 01:01:01'),(31,20170127014618,1,'2020-01-01 01:01:01'),(32,20170131232841,1,'2020-01-01 01:01:01'),(33,20170223094154,1,'2020-01-01 01:01:01'),(34,20170306075207,1,'2020-01-01 01:01:01'),(35,20170309100733,1,'2020-01-01 01:01:01'),(36,20170331111922,1,'2020-01-01 01:01:01'),(37,20170502143928,1,'2020-01-01 01:01:01'),(38,20170504130602,1,'2020-01-01 01:01:01'),(39,20170509132100,1,'2020-01-01 01:01:01'),(40,20170519105647,1,'2020-01-01 01:01:01'),(41,20170519105648,1,'2020-01-01 01:01:01'),(42,20170831234300,1,'2020-01-01 01:01:01'),(43,20170831234301,1,'2020-01-01 01:01:01'),(44,20170831234303,1,'2020-01-01 01:01:01'),(45,20171116163618,1,'2020-01-01 01:01:01'),(46,20171219164727,1,'2020-01-01 01:01:01'),(47,20180620164811,1,'2020-01-01 01:01:01'),(48,20180620175054,1,'2020-01-01 01:01:01'),(49,20180620175055,1,'2020-01-01 01:01:01'),(50,20191010101639,1,'2020-01-01 01:01:01'),(51,20191010155147,1,'2020-01-01 01:01:01'),(52,20191220130734,1,'2020-01-01 01:01:01'),(53,20200311140000,1,'2020-01-01 01:01:01'),(54,20200405120000,1,'2020-01-01 01:01:01'),(55,20200407120000,1,'2020-01-01 01:01:01'),(56,20200420120000,1,'2020-01-01 01:01:01'),(57,20200504120000,1,'2020-01-01 01:01:01'),(58,20200512120000,1,'2020-01-01 01:01:01'),(59,20200707120000,1,'2020-01-01 01:01:01'),(60,20201011162341,1,'2020-01-01 01:01:01'),(61,20201021104586,1,'2020-01-01 01:01:01'),(62,20201102112520,1,'2020-01-01 01:01:01'),(63,20201208121729,1,'2020-01-01 01:01:01'),(64,20201215091637,1,'2020-01-01 01:01:01'),(65,20210119174155,1,'2020-01-01 01:01:01'),(66,20210326182902,1,'2020-01-01 01:01:01'),(67,20210421112652,1,'2020-01-01 01:01:01'),(68,20210506095025,1,'2020-01-01 01:01:01'),(69,20210513115729,1,'2020-01-01 01:01:01'),(70,20210526113559,1,'2020-01-01 01:01:01'),(71,20210601000001,1,'2020-01-01 01:01:01'),(72,20210601000002,1,'2020-01-01 01:01:01'),(73,20210601000003,1,'2020-01-01 01:01:01'),(74,20210601000004,1,'2020-01-01 01:01:01'),(75,20210601000005,1,'2020-01-01 01:01:01'),(76,20210601000006,1,'2020-01-01 01:01:01'),(77,20210601000007,1,'2020-01-01 01:01:01'),(78,20210601000008,1,'2020-01-01 01:01:01'),(79,20210606151329,1,'2020-01-01 01:01:01'),(80,20210616163757,1,'2020-01-01 01:01:01'),(81,20210617174723,1,'2020-01-01 01:01:01'),(82,20210622160235,1,'2020-01-01 01:01:01'),(83,20210623100031,1,'2020-01-01 01:01:01'),(84,20210623133615,1,'2020-01-01 01:01:01'),(85,20210708143152,1,'2020-01-01 01:01:01'),(86,20210709124443,1,'2020-01-01 01:01:01'),(87,20210712155608,1,'2020-01-01 01:01:01'),(88,20210714102108,1,'2020-01-01 01:01:01'),(89,20210719153709,1,'2020-01-01 01:01:01'),(90,20210721171531,1,'2020-01-01 01:01:01'),(91,20210723135713,1,'2020-01-01 01:01:01'),(92,20210802135933,1,'2020-01-01 01:01:01'),(93,20210806112844,1,'2020-01-01 01:01:01'),(94,20210810095603,1,'2020-01-01 01:01:01'),(95,20210811150223,1,'2020-01-01 01:01:01'),(96,20210818151827,1,'2020-01-01 01:01:01'),(97,20210818151828,1,'2020-01-01 01:01:01'),(98,20210818182258,1,'2020-01-01 01:01:01'),(99,20210819131107,1,'2020-01-01 01:01:01'),(100,20210819143446,1,'2020-01-01 01:01:01'),(101,20210903132338,1,'2020-01-01 01:01:01'),(102,20210915144307,1,'2020-01-01 01:01:01'),(103,20210920155130,1,'2020-01-01 01:01:01'),(104,20210927143115,1,'2020-01-01 01:01:01'),(105,20210927143116,1,'2020-01-01 01:01:01'),(106,20211013133706,1,'2020-01-01 01:01:01'),(107,20211013133707,1,'2020-01-01 01:01:01'),(108,20211102135149,1,'2020-01-01 01:01:01'),(109,20211109121546,1,'2020-01-01 01:01:01'),(110,20211110163320,1,'2020-01-01 01:01:01'),(111,20211116184029,1,'2020-01-01 01:01:01'),(112,20211116184030,1,'2020-01-01 01:01:01'),(113,20211202092042,1,'2020-01-01 01:01:01'),(114,20211202181033,1,'2020-01-01 01:01:01'),(115,20211207161856,1,'2020-01-01 01:01:01'),(116,20211216131203,1,'2020-01-01 01:01:01'),(117,20211221110132,1,'2020-01-01 01:01:01'),(118,20220107155700,1,'2020-01-01 01:01:01'),(119,20220125105650,1,'2020-01-01 01:01:01'),(120,20220201084510,1,'2020-01-01 01:01:01'),(121,20220208144830,1,'2020-01-01 01:01:01'),(122,20220208144831,1,'2020-01-01 01:01:01'),(123,20220215152203,1,'2020-01-01 01:01:01'),(124,20220223113157,1,'2020-01-01 01:01:01'),(125,20220307104655,1,'2020-01-01 01:01:01'),(126,20220309133956,1,'2020-01-01 01:01:01'),(127,20220316155700,1,'2020-01-01 01:01:01'),(128,20220323152301,1,'2020-01-01 01:01:01'),(129,20220330100659,1,'2020-01-01 01:01:01'),(130,20220404091216,1,'2020-01-01 01:01:01'),(131,20220419140750,1,'2020-01-01 01:01:01'),(132,20220428140039,1,'2020-01-01 01:01:01'),(133,20220503134048,1,'2020-01-01 01:01:01'),(134,20220524102918,1,'2020-01-01 01:01:01'),(135,20220526123327,1,'2020-01-01 01:01:01'),(136,20220526123328,1,'2020-01-01 01:01:01'),(137,20220526123329,1,'2020-01-01 01:01:01'),(138,20220608113128,1,'2020-01-01 01:01:01'),(139,20220627104817,1,'2020-01-01 01:01:01'),(140,20220704101843,1,'2020-01-01 01:01:01'),(141,20220708095046,1,'2020-01-01 01:01:01'),(142,20220713091130,1,'2020-01-01 01:01:01'),(143,20220802135510,1,'2020-01-01 01:01:01'),(144,20220818101352,1,'2020-01-01 01:01:01'),(145,20220822161445,1,'2020-01-01 01:01:01'),(146,20220831100036,1,'2020-01-01 01:01:01'),(147,20220831100151,1,'2020-01-01 01:01:01'),(148,20220908181826,1,'2020-01-01 01:01:01'),(149,20220914154915,1,'2020-01-01 01:01:01'),(150,20220915165115,1,'2020-01-01 01:01:01'),(151,20220915165116,1,'2020-01-01 01:01:01'),(152,20220928100158,1,'2020-01-01 01:01:01'),(153,20221014084130,1,'2020-01-01 01:01:01'),(154,20221027085019,1,'2020-01-01 01:01:01'),(155,20221101103952,1,'2020-01-01 01:01:01'),(156,20221104144401,1,'2020-01-01 01:01:01'),(157,20221109100749,1,'2020-01-01 01:01:01'),(158,20221115104546,1,'2020-01-01 01:01:01'),(159,20221130114928,1,'2020-01-01 01:01:01'),(160,20221205112142,1,'2020-01-01 01:01:01'),(161,20221216115820,1,'2020-01-01 01:01:01'),(162,20221220195934,1,'2020-01-01 01:01:01'),(163,20221220195935,1,'2020-01-01 01:01:01'),(164,20221223174807,1,'2020-01-01 01:01:01'),(165,20221227163855,1,'2020-01-01 01:01:01'),(166,20221227163856,1,'2020-01-01 01:01:01'),(167,20230202224725,1,'2020-01-01 01:01:01'),(168,20230206163608,1,'2020-01-01 01:01:01'),(169,20230214131519,1,'2020-01-01 01:01:01'),(170,20230303135738,1,'2020-01-01 01:01:01'),(171,20230313135301,1,'2020-01-01 01:01:01'),(172,20230313141819,1,'2020-01-01 01:01:01'),(173,20230315104937,1,'2020-01-01 01:01:01'),(174,20230317173844,1,'2020-01-01 01:01:01'),(175,20230320133602,1,'2020-01-01 01:01:01'),(176,20230330100011,1,'2020-01-01 01:01:01'),(177,20230330134823,1,'2020-01-01 01:01:01'),(178,20230405232025,1,'2020-01-01 01:01:01'),(179,20230408084104,1,'2020-01-01 01:01:01'),(180,20230411102858,1,'2020-01-01 01:01:01'),(181,20230421155932,1,'2020-01-01 01:01:01'),(182,20230425082126,1,'2020-01-01 01:01:01'),(183,20230425105727,1,'2020-01-01 01:01:01'),(184,20230501154913,1,'2020-01-01 01:01:01'),(185,20230503101418,1,'2020-01-01 01:01:01'),(186,20230515144206,1,'2020-01-01 01:01:01'),(187,20230517140952,1,'2020-01-01 01:01:01'),(188,20230517152807,1,'2020-01-01 01:01:01'),(189,20230518114155,1,'2020-01-01 01:01:01'),(190,20230520153236,1,'2020-01-01 01:01:01'),(191,20230525151159,1,'2020-01-01 01:01:01'),(192,20230530122103,1,'2020-01-01 01:01:01'),(193,20230602111827,1,'2020-01-01 01:01:01'),(194,20230608103123,1,'2020-01-01 01:01:01'),(195,20230629140529,1,'2020-01-01 01:01:01'),(196,20230629140530,1,'2020-01-01 01:01:01'),(197,20230711144622,1,'2020-01-01 01:01:01'),(198,20230721135421,1,'2020-01-01 01:01:01'),(199,20230721161508,1,'2020-01-01 01:01:01'),(200,20230726115701,1,'2020-01-01 01:01:01'),(201,20230807100822,1,'2020-01-01 01:01:01'),(202,20230814150442,1,'2020-01-01 01:01:01'),(203,20230823122728,1,'2020-01-01 01:01:01'),(204,20230906152143,1,'2020-01-01 01:01:01'),(205,20230911163618,1,'2020-01-01 01:01:01'),(206,20230912101759,1,'2020-01-01 01:01:01'),(207,20230915101341,1,'2020-01-01 01:01:01'),(208,20230918132351,1,'2020-01-01 01:01:01'),(209,20231004144339,1,'2020-01-01 01:01:01'),(210,20231009094541,1,'2020-01-01 01:01:01'),(211,20231009094542,1,'2020-01-01 01:01:01'),(212,20231009094543,1,'2020-01-01 01:01:01'),(213,20231009094544,1,'2020-01-01 01:01:01'),(214,20231016091915,1,'2020-01-01 01:01:01'),(215,20231024174135,1,'2020-01-01 01:01:01'),(216,20231025120016,1,'2020-01-01 01:01:01'),(217,20231025160156,1,'2020-01-01 01:01:01'),(218,20231031165350,1,'2020-01-01 01:01:01'),(219,20231106144110,1,'2020-01-01 01:01:01'),(220,20231107130934,1,'2020-01-01 01:01:01'),(221,20231109115838,1,'2020-01-01 01:01:01'),(222,20231121054530,1,'2020-01-01 01:01:01'),(223,20231122101320,1,'2020-01-01 01:01:01'),(224,20231130132828,1,'2020-01-01 01:01:01'),(225,20231130132931,1,'2020-01-01 01:01:01'),(226,20231204155427,1,'2020-01-01 01:01:01'),(227,20231206142340,1,'2020-01-01 01:01:01'),(228,20231207102320,1,'2020-01-01 01:01:01'),(229,20231207102321,1,'2020-01-01 01:01:01'),(230,20231207133731,1,'2020-01-01 01:01:01'),(231,20231212094238,1,'2020-01-01 01:01:01'),(232,20231212095734,1,'2020-01-01 01:01:01'),(233,20231212161121,1,'2020-01-01 01:01:01'),(234,20231215122713,1,'2020-01-01 01:01:01'),(235,20231219143041,1,'2020-01-01 01:01:01'),(236,20231224070653,1,'2020-01-01 01:01:01'),(237,20240110134315,1,'2020-01-01 01:01:01'),(238,20240119091637,1,'2020-01-01 01:01:01'),(239,20240126020642,1,'2020-01-01 01:01:01'),(240,20240126020643,1,'2020-01-01 01:01:01'),(241,20240129162819,1,'2020-01-01 01:01:01'),(242,20240130115133,1,'2020-01-01 01:01:01'),(243,20240131083822,1,'2020-01-01 01:01:01'),(244,20240205095928,1,'2020-01-01 01:01:01'),(245,20240205121956,1,'2020-01-01 01:01:01'),(246,20240209110212,1,'2020-01-01 01:01:01'),(247,20240212111533,1,'2020-01-01 01:01:01'),(248,20240221112844,1,'2020-01-01 01:01:01'),(249,20240222073518,1,'2020-01-01 01:01:01'),(250,20240222135115,1,'2020-01-01 01:01:01'),(251,20240226082255,1,'2020-01-01 01:01:01'),(252,20240228082706,1,'2020-01-01 01:01:01'),(253,20240301173035,1,'2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!40101 SET character_set_client = utf8 */; CREATE TABLE `mobile_device_management_solutions` ( diff --git a/server/datastore/mysql/scripts.go b/server/datastore/mysql/scripts.go index e5fb77e744..329a25dacb 100644 --- a/server/datastore/mysql/scripts.go +++ b/server/datastore/mysql/scripts.go @@ -138,7 +138,7 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f return ctxerr.Wrap(ctx, err, "lookup host script corresponding mdm action") } if refCol != "" { - err = ds.updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0) + err = updateHostLockWipeStatusFromResult(ctx, tx, result.HostID, refCol, result.ExitCode == 0) if err != nil { return ctxerr.Wrap(ctx, err, "update host mdm action based on script result") } @@ -146,7 +146,6 @@ func (ds *Datastore) SetHostScriptExecutionResult(ctx context.Context, result *f } return nil }) - if err != nil { return nil, err } @@ -557,13 +556,14 @@ ON DUPLICATE KEY UPDATE }) } -func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { +func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { const stmt = ` SELECT lock_ref, wipe_ref, unlock_ref, - unlock_pin + unlock_pin, + fleet_platform FROM host_mdm_actions WHERE @@ -571,16 +571,18 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle ` var mdmActions struct { - LockRef *string `db:"lock_ref"` - WipeRef *string `db:"wipe_ref"` - UnlockRef *string `db:"unlock_ref"` - UnlockPIN *string `db:"unlock_pin"` + LockRef *string `db:"lock_ref"` + WipeRef *string `db:"wipe_ref"` + UnlockRef *string `db:"unlock_ref"` + UnlockPIN *string `db:"unlock_pin"` + FleetPlatform string `db:"fleet_platform"` } + fleetPlatform := host.FleetPlatform() status := &fleet.HostLockWipeStatus{ HostFleetPlatform: fleetPlatform, } - if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmActions, stmt, hostID); err != nil { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmActions, stmt, host.ID); err != nil { if err == sql.ErrNoRows { // do not return a Not Found error, return the zero-value status, which // will report the correct states. @@ -589,6 +591,14 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle return nil, ctxerr.Wrap(ctx, err, "get host lock/wipe status") } + // if we have a fleet platform stored in host_mdm_actions, use it instead of + // the host.FleetPlatform() because the platform can be overwritten with an + // unknown OS name when a Wipe gets executed. + if mdmActions.FleetPlatform != "" { + fleetPlatform = mdmActions.FleetPlatform + status.HostFleetPlatform = fleetPlatform + } + switch fleetPlatform { case "darwin": if mdmActions.UnlockPIN != nil { @@ -608,34 +618,22 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle if mdmActions.LockRef != nil { // the lock reference is an MDM command - cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), *mdmActions.LockRef) + cmd, cmdRes, err := ds.getHostMDMAppleCommand(ctx, *mdmActions.LockRef, host.UUID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get lock reference MDM command") + return nil, ctxerr.Wrap(ctx, err, "get lock reference") } status.LockMDMCommand = cmd + status.LockMDMCommandResult = cmdRes + } - // get the MDM command result, which may be not found (indicating the - // command is pending) - cmdRes, err := ds.GetMDMAppleCommandResults(ctx, *mdmActions.LockRef) + if mdmActions.WipeRef != nil { + // the wipe reference is an MDM command + cmd, cmdRes, err := ds.getHostMDMAppleCommand(ctx, *mdmActions.WipeRef, host.UUID) if err != nil { - return nil, ctxerr.Wrap(ctx, err, "get lock reference MDM command result") - } - // TODO: each item in the slice returned by - // GetMDMAppleCommandResults is a result for a - // different host. This only works because we're - // enqueuing the command with the given UUID for a - // single host, but it's the equivalent of doing - // cmdRes[0]. - // - // Ideally, and to be super safe, we should try to find - // a command with a matching r.HostUUID, but we don't - // have the host UUID available. - for _, r := range cmdRes { - if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusCommandFormatError { - status.LockMDMCommandResult = r - break - } + return nil, ctxerr.Wrap(ctx, err, "get wipe reference") } + status.WipeMDMCommand = cmd + status.WipeMDMCommandResult = cmdRes } case "windows", "linux": @@ -655,13 +653,92 @@ func (ds *Datastore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fle } status.UnlockScript = hsr } + + // wipe is an MDM command on Windows, a script on Linux + if mdmActions.WipeRef != nil { + if fleetPlatform == "windows" { + cmd, cmdRes, err := ds.getHostMDMWindowsCommand(ctx, *mdmActions.WipeRef, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get wipe reference") + } + status.WipeMDMCommand = cmd + status.WipeMDMCommandResult = cmdRes + } else { + hsr, err := ds.getHostScriptExecutionResultDB(ctx, ds.reader(ctx), *mdmActions.WipeRef) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get wipe reference script result") + } + status.WipeScript = hsr + } + } } + return status, nil } +func (ds *Datastore) getHostMDMWindowsCommand(ctx context.Context, cmdUUID, hostUUID string) (*fleet.MDMCommand, *fleet.MDMCommandResult, error) { + cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Windows MDM command") + } + + // get the MDM command result, which may be not found (indicating the command + // is pending). Note that it doesn't return ErrNoRows if not found, it + // returns success and an empty cmdRes slice. + cmdResults, err := ds.GetMDMWindowsCommandResults(ctx, cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Windows MDM command result") + } + + // each item in the slice returned by GetMDMWindowsCommandResults is + // potentially a result for a different host, we need to find the one for + // that specific host. + var cmdRes *fleet.MDMCommandResult + for _, r := range cmdResults { + if r.HostUUID != hostUUID { + continue + } + // all statuses for Windows indicate end of processing of the command + // (there is no equivalent of "NotNow" or "Idle" as for Apple). + cmdRes = r + break + } + return cmd, cmdRes, nil +} + +func (ds *Datastore) getHostMDMAppleCommand(ctx context.Context, cmdUUID, hostUUID string) (*fleet.MDMCommand, *fleet.MDMCommandResult, error) { + cmd, err := ds.getMDMCommand(ctx, ds.reader(ctx), cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Apple MDM command") + } + + // get the MDM command result, which may be not found (indicating the command + // is pending). Note that it doesn't return ErrNoRows if not found, it + // returns success and an empty cmdRes slice. + cmdResults, err := ds.GetMDMAppleCommandResults(ctx, cmdUUID) + if err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get Apple MDM command result") + } + + // each item in the slice returned by GetMDMAppleCommandResults is + // potentially a result for a different host, we need to find the one for + // that specific host. + var cmdRes *fleet.MDMCommandResult + for _, r := range cmdResults { + if r.HostUUID != hostUUID { + continue + } + if r.Status == fleet.MDMAppleStatusAcknowledged || r.Status == fleet.MDMAppleStatusError || r.Status == fleet.MDMAppleStatusCommandFormatError { + cmdRes = r + break + } + } + return cmd, cmdRes, nil +} + // LockHostViaScript will create the script execution request and update // host_mdm_actions in a single transaction. -func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { var res *fleet.HostScriptResult return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error @@ -680,9 +757,9 @@ func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostS ( host_id, lock_ref, - unlock_ref + fleet_platform ) - VALUES (?,?,NULL) + VALUES (?,?,?) ON DUPLICATE KEY UPDATE lock_ref = VALUES(lock_ref) ` @@ -690,6 +767,7 @@ func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostS _, err = tx.ExecContext(ctx, stmt, request.HostID, res.ExecutionID, + hostFleetPlatform, ) if err != nil { return ctxerr.Wrap(ctx, err, "lock host via script update mdm actions") @@ -701,7 +779,7 @@ func (ds *Datastore) LockHostViaScript(ctx context.Context, request *fleet.HostS // UnlockHostViaScript will create the script execution request and update // host_mdm_actions in a single transaction. -func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { var res *fleet.HostScriptResult return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { var err error @@ -720,9 +798,9 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos ( host_id, unlock_ref, - lock_ref + fleet_platform ) - VALUES (?,?,NULL) + VALUES (?,?,?) ON DUPLICATE KEY UPDATE unlock_ref = VALUES(unlock_ref), unlock_pin = NULL @@ -731,6 +809,7 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos _, err = tx.ExecContext(ctx, stmt, request.HostID, res.ExecutionID, + hostFleetPlatform, ) if err != nil { return ctxerr.Wrap(ctx, err, "unlock host via script update mdm actions") @@ -740,14 +819,55 @@ func (ds *Datastore) UnlockHostViaScript(ctx context.Context, request *fleet.Hos }) } -func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error { +// WipeHostViaScript creates the script execution request and updates the +// host_mdm_actions table in a single transaction. +func (ds *Datastore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + var res *fleet.HostScriptResult + return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error { + var err error + res, err = newHostScriptExecutionRequest(ctx, request, tx) + if err != nil { + return ctxerr.Wrap(ctx, err, "wipe host via script create execution") + } + + // on duplicate we don't clear any other existing state because at this + // point in time, this is just a request to wipe the host that is recorded, + // it is pending execution, so if it was locked, it is still locked (so the + // lock_ref info must still be there). + const stmt = ` + INSERT INTO host_mdm_actions + ( + host_id, + wipe_ref, + fleet_platform + ) + VALUES (?,?,?) + ON DUPLICATE KEY UPDATE + wipe_ref = VALUES(wipe_ref) + ` + + _, err = tx.ExecContext(ctx, stmt, + request.HostID, + res.ExecutionID, + hostFleetPlatform, + ) + if err != nil { + return ctxerr.Wrap(ctx, err, "wipe host via script update mdm actions") + } + + return err + }) +} + +func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error { const stmt = ` INSERT INTO host_mdm_actions ( host_id, - unlock_ref + unlock_ref, + fleet_platform ) - VALUES (?, ?) + VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE -- do not overwrite if a value is already set unlock_ref = IF(unlock_ref IS NULL, VALUES(unlock_ref), unlock_ref) @@ -758,16 +878,22 @@ func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, ts tim // entering a PIN on the device). The /unlock endpoint can be called multiple // times, so we record the timestamp of the first time it was requested and // from then on, the host is marked as "pending unlock" until the device is - // actually unlocked with the PIN. - // TODO(mna): to be determined how we then get notified that it has been - // unlocked, so that it can transition to unlocked (not pending). + // actually unlocked with the PIN. The actual unlocking happens when the + // device sends an Idle MDM request. unlockRef := ts.Format(time.DateTime) - _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, unlockRef) + _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, unlockRef, hostFleetPlatform) return ctxerr.Wrap(ctx, err, "record manual unlock host request") } -func (ds *Datastore) updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error { - stmt := `UPDATE host_mdm_actions SET %s WHERE host_id = ?` +func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart string) string { + var alias string + + stmt := `UPDATE host_mdm_actions ` + if joinPart != "" { + stmt += `hma ` + joinPart + alias = "hma." + } + stmt += ` SET ` if succeeded { switch refCol { @@ -775,23 +901,49 @@ func (ds *Datastore) updateHostLockWipeStatusFromResult(ctx context.Context, tx // Note that this must not clear the unlock_pin, because recording the // lock request does generate the PIN and store it there to be used by an // eventual unlock. - stmt = fmt.Sprintf(stmt, "unlock_ref = NULL") + stmt += fmt.Sprintf("%sunlock_ref = NULL, %[1]swipe_ref = NULL", alias) case "unlock_ref": // a successful unlock clears itself as well as the lock ref, because // unlock is the default state so we don't need to keep its unlock_ref // around once it's confirmed. - stmt = fmt.Sprintf(stmt, "lock_ref = NULL, unlock_ref = NULL, unlock_pin = NULL") + stmt += fmt.Sprintf("%slock_ref = NULL, %[1]sunlock_ref = NULL, %[1]sunlock_pin = NULL, %[1]swipe_ref = NULL", alias) case "wipe_ref": - // TODO(mna): implement when implementing the wipe story - default: - return ctxerr.Errorf(ctx, "unknown reference column %q", refCol) + stmt += fmt.Sprintf("%slock_ref = NULL, %[1]sunlock_ref = NULL, %[1]sunlock_pin = NULL", alias) } } else { // if the action failed, then we clear the reference to that action itself so // the host stays in the previous state (it doesn't transition to the new // state). - stmt = fmt.Sprintf(stmt, refCol+" = NULL") + stmt += fmt.Sprintf("%s"+refCol+" = NULL", alias) } + return stmt +} + +func (ds *Datastore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID, cmdUUID, requestType string, succeeded bool) error { + // a bit of MDM protocol leaking in the mysql layer, but it's either that or + // the other way around (MDM protocol would translate to database column) + var refCol string + switch requestType { + case "EraseDevice": + refCol = "wipe_ref" + case "DeviceLock": + refCol = "lock_ref" + default: + return nil + } + return updateHostLockWipeStatusFromResultAndHostUUID(ctx, ds.writer(ctx), hostUUID, refCol, cmdUUID, succeeded) +} + +func updateHostLockWipeStatusFromResultAndHostUUID(ctx context.Context, tx sqlx.ExtContext, hostUUID, refCol, cmdUUID string, succeeded bool) error { + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, `JOIN hosts h ON hma.host_id = h.id`) + stmt += ` WHERE h.uuid = ? AND hma.` + refCol + ` = ?` + _, err := tx.ExecContext(ctx, stmt, hostUUID, cmdUUID) + return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result via host uuid") +} + +func updateHostLockWipeStatusFromResult(ctx context.Context, tx sqlx.ExtContext, hostID uint, refCol string, succeeded bool) error { + stmt := buildHostLockWipeStatusUpdateStmt(refCol, succeeded, "") + stmt += ` WHERE host_id = ?` _, err := tx.ExecContext(ctx, stmt, hostID) return ctxerr.Wrap(ctx, err, "update host lock/wipe status from result") } diff --git a/server/datastore/mysql/scripts_test.go b/server/datastore/mysql/scripts_test.go index 2466497e8e..444afe19c3 100644 --- a/server/datastore/mysql/scripts_test.go +++ b/server/datastore/mysql/scripts_test.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" + "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/require" ) @@ -29,7 +30,7 @@ func TestScripts(t *testing.T) { {"BatchSetScripts", testBatchSetScripts}, {"TestLockHostViaScript", testLockHostViaScript}, {"TestUnlockHostViaScript", testUnlockHostViaScript}, - {"TestLockUnlockViaScripts", testLockUnlockViaScripts}, + {"TestLockUnlockWipeViaScripts", testLockUnlockWipeViaScripts}, {"TestLockUnlockManually", testLockUnlockManually}, } for _, c := range cases { @@ -730,12 +731,12 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { ScriptContents: script, UserID: &user.ID, SyncRequest: false, - }) + }, "windows") require.NoError(t, err) // verify that we have created entries in host_mdm_actions and host_script_results - status, err := ds.GetHostLockWipeStatus(ctx, windowsHostID, "windows") + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.Equal(t, "windows", status.HostFleetPlatform) require.NotNil(t, status.LockScript) @@ -756,7 +757,7 @@ func testLockHostViaScript(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, windowsHostID, "windows") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: windowsHostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.True(t, status.IsLocked()) require.False(t, status.IsPendingLock()) @@ -781,12 +782,12 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) { ScriptContents: script, UserID: &user.ID, SyncRequest: false, - }) + }, "windows") require.NoError(t, err) // verify that we have created entries in host_mdm_actions and host_script_results - status, err := ds.GetHostLockWipeStatus(ctx, hostID, "windows") + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.Equal(t, "windows", status.HostFleetPlatform) require.NotNil(t, status.UnlockScript) @@ -807,14 +808,14 @@ func testUnlockHostViaScript(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, "windows") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: "windows", UUID: "uuid"}) require.NoError(t, err) require.True(t, status.IsUnlocked()) require.False(t, status.IsPendingUnlock()) require.False(t, status.IsLocked()) } -func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { +func testLockUnlockWipeViaScripts(t *testing.T, ds *Datastore) { ctx := context.Background() user := test.NewUser(t, ds, "Bob", "bob@example.com", true) @@ -822,7 +823,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { hostID := uint(i + 1) t.Run(platform, func(t *testing.T) { - status, err := ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) // default state @@ -834,10 +835,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "lock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, true, false) @@ -849,7 +850,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, false, false, false) @@ -859,10 +860,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "unlock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, true, false, false) @@ -875,7 +876,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { require.NoError(t, err) // still locked - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, false, false, false) @@ -885,10 +886,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "unlock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, false, true, false, true, false, false) @@ -901,7 +902,7 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { require.NoError(t, err) // host is now unlocked - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, false, false) @@ -911,10 +912,10 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { ScriptContents: "lock", UserID: &user.ID, SyncRequest: false, - }) + }, platform) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, true, false) @@ -926,9 +927,93 @@ func testLockUnlockViaScripts(t *testing.T, ds *Datastore) { }) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, hostID, platform) + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) require.NoError(t, err) checkLockWipeState(t, status, true, false, false, false, false, false) + + switch platform { + case "windows": + // need a real MDM-enrolled host for MDM commands + h, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "test-host-windows", + OsqueryHostID: ptr.String("osquery-windows"), + NodeKey: ptr.String("nodekey-windows"), + UUID: "test-uuid-windows", + Platform: "windows", + }) + require.NoError(t, err) + windowsEnroll(t, ds, h) + + // record a request to wipe the host + wipeCmdUUID := uuid.NewString() + wipeCmd := &fleet.MDMWindowsCommand{ + CommandUUID: wipeCmdUUID, + RawCommand: []byte(``), + TargetLocURI: "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", + } + err = ds.WipeHostViaWindowsMDM(ctx, h, wipeCmd) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, h) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, true) + + // TODO: we don't seem to have an easy way to simulate a Windows MDM + // protocol response, and there are lots of validations happening so we + // can't just send a simple XML. Will test the rest via integration + // tests. + + case "linux": + // record a request to wipe the host + err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: hostID, + ScriptContents: "wipe", + UserID: &user.ID, + SyncRequest: false, + }, platform) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, true) + + // simulate a failed result for the wipe script execution + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: hostID, + ExecutionID: status.WipeScript.ExecutionID, + ExitCode: 1, + }) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, false) + + // record another request to wipe the host + err = ds.WipeHostViaScript(ctx, &fleet.HostScriptRequestPayload{ + HostID: hostID, + ScriptContents: "wipe2", + UserID: &user.ID, + SyncRequest: false, + }, platform) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, true, false, false, false, false, true) + + // simulate a successful result for the wipe script execution + _, err = ds.SetHostScriptExecutionResult(ctx, &fleet.HostScriptResultPayload{ + HostID: hostID, + ExecutionID: status.WipeScript.ExecutionID, + ExitCode: 0, + }) + require.NoError(t, err) + + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: hostID, Platform: platform, UUID: "uuid"}) + require.NoError(t, err) + checkLockWipeState(t, status, false, false, true, false, false, false) + } }) } } @@ -938,19 +1023,19 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) { twoDaysAgo := time.Now().AddDate(0, 0, -2).UTC() today := time.Now().UTC() - err := ds.UnlockHostManually(ctx, 1, twoDaysAgo) + err := ds.UnlockHostManually(ctx, 1, "darwin", twoDaysAgo) require.NoError(t, err) - status, err := ds.GetHostLockWipeStatus(ctx, 1, "darwin") + status, err := ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 1, Platform: "darwin", UUID: "uuid"}) require.NoError(t, err) require.False(t, status.UnlockRequestedAt.IsZero()) require.WithinDuration(t, twoDaysAgo, status.UnlockRequestedAt, 1*time.Second) // if the unlock request already exists, it is not overwritten by subsequent // requests - err = ds.UnlockHostManually(ctx, 1, today) + err = ds.UnlockHostManually(ctx, 1, "darwin", today) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, 1, "darwin") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 1, Platform: "darwin", UUID: "uuid"}) require.NoError(t, err) require.False(t, status.UnlockRequestedAt.IsZero()) require.WithinDuration(t, twoDaysAgo, status.UnlockRequestedAt, 1*time.Second) @@ -961,9 +1046,9 @@ func testLockUnlockManually(t *testing.T, ds *Datastore) { _, err := tx.ExecContext(ctx, "INSERT INTO host_mdm_actions (host_id) VALUES (2)") return err }) - err = ds.UnlockHostManually(ctx, 2, today) + err = ds.UnlockHostManually(ctx, 2, "darwin", today) require.NoError(t, err) - status, err = ds.GetHostLockWipeStatus(ctx, 2, "darwin") + status, err = ds.GetHostLockWipeStatus(ctx, &fleet.Host{ID: 2, Platform: "darwin", UUID: "uuid"}) require.NoError(t, err) require.False(t, status.UnlockRequestedAt.IsZero()) require.WithinDuration(t, today, status.UnlockRequestedAt, 1*time.Second) diff --git a/server/fleet/activities.go b/server/fleet/activities.go index ed4352110a..2fb7a4dbc0 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -83,6 +83,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeLockedHost{}, ActivityTypeUnlockedHost{}, + ActivityTypeWipedHost{}, } type ActivityDetails interface { @@ -1234,6 +1235,20 @@ type ActivityTypeEditedWindowsProfile struct { TeamName *string `json:"team_name"` } +func (a ActivityTypeEditedWindowsProfile) ActivityName() string { + return "edited_windows_profile" +} + +func (a ActivityTypeEditedWindowsProfile) Documentation() (activity, details, detailsExample string) { + return `Generated when a user edits the Windows profiles of a team (or no team) via the fleetctl CLI.`, + `This activity contains the following fields: +- "team_id": The ID of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team. +- "team_name": The name of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{ + "team_id": 123, + "team_name": "Workstations" +}` +} + type ActivityTypeLockedHost struct { HostID uint `json:"host_id"` HostDisplayName string `json:"host_display_name"` @@ -1283,17 +1298,22 @@ func (a ActivityTypeUnlockedHost) Documentation() (activity, details, detailsExa }` } -func (a ActivityTypeEditedWindowsProfile) ActivityName() string { - return "edited_windows_profile" +type ActivityTypeWipedHost struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` } -func (a ActivityTypeEditedWindowsProfile) Documentation() (activity, details, detailsExample string) { - return `Generated when a user edits the Windows profiles of a team (or no team) via the fleetctl CLI.`, +func (a ActivityTypeWipedHost) ActivityName() string { + return "wiped_host" +} + +func (a ActivityTypeWipedHost) Documentation() (activity, details, detailsExample string) { + return `Generated when a user sends a request to wipe a host.`, `This activity contains the following fields: -- "team_id": The ID of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team. -- "team_name": The name of the team that the profiles apply to, ` + "`null`" + ` if they apply to devices that are not in a team.`, `{ - "team_id": 123, - "team_name": "Workstations" +- "host_id": ID of the host. +- "host_display_name": Display name of the host.`, `{ + "host_id": 1, + "host_display_name": "Anna's MacBook Pro" }` } diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 7f590fbc56..02a0b65f47 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -19,7 +19,7 @@ type MDMAppleCommandIssuer interface { InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error RemoveProfile(ctx context.Context, hostUUIDs []string, identifier string, uuid string) error DeviceLock(ctx context.Context, host *Host, uuid string) error - EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error + EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index b6400cc5e3..30d9b0a42f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1312,24 +1312,38 @@ type Datastore interface { BatchSetScripts(ctx context.Context, tmID *uint, scripts []*Script) error // GetHostLockWipeStatus gets the lock/unlock and wipe status for the host. - GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*HostLockWipeStatus, error) + GetHostLockWipeStatus(ctx context.Context, host *Host) (*HostLockWipeStatus, error) // LockHostViaScript sends a script to lock a host and updates the // states in host_mdm_actions - LockHostViaScript(ctx context.Context, request *HostScriptRequestPayload) error + LockHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error // UnlockHostViaScript sends a script to unlock a host and updates the // states in host_mdm_actions - UnlockHostViaScript(ctx context.Context, request *HostScriptRequestPayload) error + UnlockHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error // UnlockHostmanually records a request to unlock a host that requires manual // intervention (such as for macOS). It indicates the an unlock request is // pending. - UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error + UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error // CleanMacOSMDMLock cleans the lock status and pin for a macOS device // after it has been unlocked. CleanMacOSMDMLock(ctx context.Context, hostUUID string) error + + // WipeHostViaScript sends a script to wipe a host and updates the + // states in host_mdm_actions. + WipeHostViaScript(ctx context.Context, request *HostScriptRequestPayload, hostFleetPlatform string) error + + // WipeHostViaWindowsMDM sends a Windows MDM command to wipe a host and + // updates the states in host_mdm_actions. + WipeHostViaWindowsMDM(ctx context.Context, host *Host, cmd *MDMWindowsCommand) error + + // UpdateHostLockWipeStatusFromAppleMDMResult updates the host_mdm_actions + // table to reflect the result of the corresponding lock/wipe MDM command for + // Apple hosts. It is optimized to update using only the information + // available in the Apple MDM protocol. + UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID, cmdUUID, requestType string, succeeded bool) error } // MDMAppleStore wraps nanomdm's storage and adds methods to deal with @@ -1337,6 +1351,7 @@ type Datastore interface { type MDMAppleStore interface { storage.AllStorage EnqueueDeviceLockCommand(ctx context.Context, host *Host, cmd *mdm.Command, pin string) error + EnqueueDeviceWipeCommand(ctx context.Context, host *Host, cmd *mdm.Command) error } // Cloner represents any type that can clone itself. Used for the cached_mysql diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index a4de548c3b..dbb3808aab 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -192,7 +192,8 @@ type MDMCommandResult struct { HostUUID string `json:"host_uuid" db:"host_uuid"` // CommandUUID is the unique identifier of the command. CommandUUID string `json:"command_uuid" db:"command_uuid"` - // Status is the command status. One of Acknowledged, Error, or NotNow. + // Status is the command status. One of Acknowledged, Error, or NotNow for + // Apple, or 200, 400, etc for Windows. Status string `json:"status" db:"status"` // UpdatedAt is the last update timestamp of the command result. UpdatedAt time.Time `json:"updated_at" db:"updated_at"` diff --git a/server/fleet/scripts.go b/server/fleet/scripts.go index b20b4e3fc0..62e5573031 100644 --- a/server/fleet/scripts.go +++ b/server/fleet/scripts.go @@ -312,7 +312,12 @@ type HostLockWipeStatus struct { // windows and linux hosts use a script to unlock UnlockScript *HostScriptResult - // TODO: add wipe status when implementing the Wipe story. + // macOS and Windows use MDM commands for Wipe + WipeMDMCommand *MDMCommand + WipeMDMCommandResult *MDMCommandResult + + // Linux uses a script for Wipe + WipeScript *HostScriptResult } func (s *HostLockWipeStatus) IsPendingLock() bool { @@ -334,8 +339,12 @@ func (s HostLockWipeStatus) IsPendingUnlock() bool { } func (s HostLockWipeStatus) IsPendingWipe() bool { - // TODO(mna): implement when addressing Wipe story, for now wipe is never pending - return false + if s.HostFleetPlatform == "linux" { + // pending wipe if script execution request is queued but no result yet + return s.WipeScript != nil && s.WipeScript.ExitCode == nil + } + // pending wipe if an MDM command is queued but no result received yet + return s.WipeMDMCommand != nil && s.WipeMDMCommandResult == nil } func (s HostLockWipeStatus) IsLocked() bool { @@ -359,6 +368,20 @@ func (s HostLockWipeStatus) IsUnlocked() bool { } func (s HostLockWipeStatus) IsWiped() bool { - // TODO(mna): implement when addressing Wipe story, for now never wiped - return false + switch s.HostFleetPlatform { + case "linux": + // wiped if script was sent and succeeded + return s.WipeScript != nil && s.WipeScript.ExitCode != nil && + *s.WipeScript.ExitCode == 0 + case "windows": + // wiped if an MDM command was sent and succeeded + return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil && + strings.HasPrefix(s.WipeMDMCommandResult.Status, "2") + case "darwin": + // wiped if an MDM command was sent and succeeded + return s.WipeMDMCommand != nil && s.WipeMDMCommandResult != nil && + s.WipeMDMCommandResult.Status == MDMAppleStatusAcknowledged + default: + return false + } } diff --git a/server/fleet/service.go b/server/fleet/service.go index 7984af7f5f..8d3f9720c5 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -956,4 +956,5 @@ type Service interface { // Script-based methods (at least for some platforms, MDM-based for others) LockHost(ctx context.Context, hostID uint) error UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) + WipeHost(ctx context.Context, hostID uint) error } diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index ea39be26cb..280596a0cb 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -118,7 +118,7 @@ func (svc *MDMAppleCommander) DeviceLock(ctx context.Context, host *fleet.Host, return nil } -func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []string, uuid string) error { +func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error { pin := GenerateRandomPin(6) raw := fmt.Sprintf(` @@ -132,10 +132,26 @@ func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []strin EraseDevice PIN %s + ObliterationBehavior + Default `, uuid, pin) - return svc.EnqueueCommand(ctx, hostUUIDs, raw) + + cmd, err := mdm.DecodeCommand([]byte(raw)) + if err != nil { + return ctxerr.Wrap(ctx, err, "decoding command") + } + + if err := svc.storage.EnqueueDeviceWipeCommand(ctx, host, cmd); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing for DeviceWipe") + } + + if err := svc.sendNotifications(ctx, []string{host.UUID}); err != nil { + return ctxerr.Wrap(ctx, err, "sending notifications for DeviceWipe") + } + + return nil } func (svc *MDMAppleCommander) InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error { diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 05e2de999c..af7cc7dd33 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -104,7 +104,7 @@ func TestMDMAppleCommander(t *testing.T) { require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) mdmStorage.RetrievePushInfoFuncInvoked = false - host := &fleet.Host{ID: 1, UUID: "A"} + host := &fleet.Host{ID: 1, UUID: "A", Platform: "darwin"} cmdUUID = uuid.New().String() mdmStorage.EnqueueDeviceLockCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command, pin string) error { require.NotNil(t, gotHost) @@ -112,6 +112,7 @@ func TestMDMAppleCommander(t *testing.T) { require.Equal(t, host.UUID, gotHost.UUID) require.Equal(t, "DeviceLock", cmd.Command.RequestType) require.Contains(t, string(cmd.Raw), cmdUUID) + require.Len(t, pin, 6) return nil } err = cmdr.DeviceLock(ctx, host, cmdUUID) @@ -120,6 +121,22 @@ func TestMDMAppleCommander(t *testing.T) { mdmStorage.EnqueueDeviceLockCommandFuncInvoked = false require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) mdmStorage.RetrievePushInfoFuncInvoked = false + + cmdUUID = uuid.New().String() + mdmStorage.EnqueueDeviceWipeCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command) error { + require.NotNil(t, gotHost) + require.Equal(t, host.ID, gotHost.ID) + require.Equal(t, host.UUID, gotHost.UUID) + require.Equal(t, "EraseDevice", cmd.Command.RequestType) + require.Contains(t, string(cmd.Raw), cmdUUID) + return nil + } + err = cmdr.EraseDevice(ctx, host, cmdUUID) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueDeviceWipeCommandFuncInvoked) + mdmStorage.EnqueueDeviceWipeCommandFuncInvoked = false + require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) + mdmStorage.RetrievePushInfoFuncInvoked = false } func newMockAPNSPushProviderFactory() (*svcmock.APNSPushProviderFactory, *svcmock.APNSPushProvider) { diff --git a/server/mock/datastore_mdm_mock.go b/server/mock/datastore_mdm_mock.go index 25d914921c..d6da34a7c2 100644 --- a/server/mock/datastore_mdm_mock.go +++ b/server/mock/datastore_mdm_mock.go @@ -56,6 +56,8 @@ type RetrieveTokenUpdateTallyFunc func(ctx context.Context, id string) (int, err type EnqueueDeviceLockCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command, pin string) error +type EnqueueDeviceWipeCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error + type MDMAppleStore struct { StoreAuthenticateFunc StoreAuthenticateFunc StoreAuthenticateFuncInvoked bool @@ -120,6 +122,9 @@ type MDMAppleStore struct { EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFunc EnqueueDeviceLockCommandFuncInvoked bool + EnqueueDeviceWipeCommandFunc EnqueueDeviceWipeCommandFunc + EnqueueDeviceWipeCommandFuncInvoked bool + mu sync.Mutex } @@ -269,3 +274,10 @@ func (fs *MDMAppleStore) EnqueueDeviceLockCommand(ctx context.Context, host *fle fs.mu.Unlock() return fs.EnqueueDeviceLockCommandFunc(ctx, host, cmd, pin) } + +func (fs *MDMAppleStore) EnqueueDeviceWipeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + fs.mu.Lock() + fs.EnqueueDeviceWipeCommandFuncInvoked = true + fs.mu.Unlock() + return fs.EnqueueDeviceWipeCommandFunc(ctx, host, cmd) +} diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index ecc7a4dff7..81ace3f297 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -836,16 +836,22 @@ type GetHostScriptDetailsFunc func(ctx context.Context, hostID uint, teamID *uin type BatchSetScriptsFunc func(ctx context.Context, tmID *uint, scripts []*fleet.Script) error -type GetHostLockWipeStatusFunc func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) +type GetHostLockWipeStatusFunc func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) -type LockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) error +type LockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error -type UnlockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload) error +type UnlockHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error -type UnlockHostManuallyFunc func(ctx context.Context, hostID uint, ts time.Time) error +type UnlockHostManuallyFunc func(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error type CleanMacOSMDMLockFunc func(ctx context.Context, hostUUID string) error +type WipeHostViaScriptFunc func(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error + +type WipeHostViaWindowsMDMFunc func(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error + +type UpdateHostLockWipeStatusFromAppleMDMResultFunc func(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error + type DataStore struct { HealthCheckFunc HealthCheckFunc HealthCheckFuncInvoked bool @@ -2089,6 +2095,15 @@ type DataStore struct { CleanMacOSMDMLockFunc CleanMacOSMDMLockFunc CleanMacOSMDMLockFuncInvoked bool + WipeHostViaScriptFunc WipeHostViaScriptFunc + WipeHostViaScriptFuncInvoked bool + + WipeHostViaWindowsMDMFunc WipeHostViaWindowsMDMFunc + WipeHostViaWindowsMDMFuncInvoked bool + + UpdateHostLockWipeStatusFromAppleMDMResultFunc UpdateHostLockWipeStatusFromAppleMDMResultFunc + UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked bool + mu sync.Mutex } @@ -4955,32 +4970,32 @@ func (s *DataStore) BatchSetScripts(ctx context.Context, tmID *uint, scripts []* return s.BatchSetScriptsFunc(ctx, tmID, scripts) } -func (s *DataStore) GetHostLockWipeStatus(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { +func (s *DataStore) GetHostLockWipeStatus(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { s.mu.Lock() s.GetHostLockWipeStatusFuncInvoked = true s.mu.Unlock() - return s.GetHostLockWipeStatusFunc(ctx, hostID, fleetPlatform) + return s.GetHostLockWipeStatusFunc(ctx, host) } -func (s *DataStore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (s *DataStore) LockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { s.mu.Lock() s.LockHostViaScriptFuncInvoked = true s.mu.Unlock() - return s.LockHostViaScriptFunc(ctx, request) + return s.LockHostViaScriptFunc(ctx, request, hostFleetPlatform) } -func (s *DataStore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload) error { +func (s *DataStore) UnlockHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { s.mu.Lock() s.UnlockHostViaScriptFuncInvoked = true s.mu.Unlock() - return s.UnlockHostViaScriptFunc(ctx, request) + return s.UnlockHostViaScriptFunc(ctx, request, hostFleetPlatform) } -func (s *DataStore) UnlockHostManually(ctx context.Context, hostID uint, ts time.Time) error { +func (s *DataStore) UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error { s.mu.Lock() s.UnlockHostManuallyFuncInvoked = true s.mu.Unlock() - return s.UnlockHostManuallyFunc(ctx, hostID, ts) + return s.UnlockHostManuallyFunc(ctx, hostID, hostFleetPlatform, ts) } func (s *DataStore) CleanMacOSMDMLock(ctx context.Context, hostUUID string) error { @@ -4989,3 +5004,24 @@ func (s *DataStore) CleanMacOSMDMLock(ctx context.Context, hostUUID string) erro s.mu.Unlock() return s.CleanMacOSMDMLockFunc(ctx, hostUUID) } + +func (s *DataStore) WipeHostViaScript(ctx context.Context, request *fleet.HostScriptRequestPayload, hostFleetPlatform string) error { + s.mu.Lock() + s.WipeHostViaScriptFuncInvoked = true + s.mu.Unlock() + return s.WipeHostViaScriptFunc(ctx, request, hostFleetPlatform) +} + +func (s *DataStore) WipeHostViaWindowsMDM(ctx context.Context, host *fleet.Host, cmd *fleet.MDMWindowsCommand) error { + s.mu.Lock() + s.WipeHostViaWindowsMDMFuncInvoked = true + s.mu.Unlock() + return s.WipeHostViaWindowsMDMFunc(ctx, host, cmd) +} + +func (s *DataStore) UpdateHostLockWipeStatusFromAppleMDMResult(ctx context.Context, hostUUID string, cmdUUID string, requestType string, succeeded bool) error { + s.mu.Lock() + s.UpdateHostLockWipeStatusFromAppleMDMResultFuncInvoked = true + s.mu.Unlock() + return s.UpdateHostLockWipeStatusFromAppleMDMResultFunc(ctx, hostUUID, cmdUUID, requestType, succeeded) +} diff --git a/server/service/apple_mdm.go b/server/service/apple_mdm.go index 773129f307..8f91bb8e86 100644 --- a/server/service/apple_mdm.go +++ b/server/service/apple_mdm.go @@ -2400,6 +2400,13 @@ func (svc *MDMAppleCheckinAndCommandService) CommandAndReportResults(r *mdm.Requ Detail: apple_mdm.FmtErrorChain(cmdResult.ErrorChain), OperationType: fleet.MDMOperationTypeRemove, }) + case "DeviceLock", "EraseDevice": + // call into our datastore to update host_mdm_actions if the status is terminal + if cmdResult.Status == fleet.MDMAppleStatusAcknowledged || + cmdResult.Status == fleet.MDMAppleStatusError || + cmdResult.Status == fleet.MDMAppleStatusCommandFormatError { + return nil, svc.ds.UpdateHostLockWipeStatusFromAppleMDMResult(r.Context, cmdResult.UDID, cmdResult.CommandUUID, requestType, cmdResult.Status == fleet.MDMAppleStatusAcknowledged) + } } return nil, nil } diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index f8d5da8a85..fab8124084 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -760,7 +760,7 @@ func TestHostDetailsMDMProfiles(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go index 9a6980becd..f4b52c24d0 100644 --- a/server/service/client_mdm.go +++ b/server/service/client_mdm.go @@ -388,3 +388,11 @@ func (c *Client) MDMUnlockHost(hostID uint) (string, error) { } return response.UnlockPIN, nil } + +func (c *Client) MDMWipeHost(hostID uint) error { + var response wipeHostResponse + if err := c.authenticatedRequest(nil, "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", hostID), &response); err != nil { + return fmt.Errorf("wipe host request: %w", err) + } + return nil +} diff --git a/server/service/handler.go b/server/service/handler.go index 5df68c0b8f..bf4d1176d6 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -479,6 +479,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/activities", listHostPastActivitiesEndpoint, listHostPastActivitiesRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/lock", lockHostEndpoint, lockHostRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/unlock", unlockHostEndpoint, unlockHostRequest{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/wipe", wipeHostEndpoint, wipeHostRequest{}) // Only Fleet MDM specific endpoints should be within the root /mdm/ path. // NOTE: remember to update diff --git a/server/service/hosts.go b/server/service/hosts.go index f4741835e9..9a93cfe834 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1103,7 +1103,7 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f } host.MDM.MacOSSetup = macOSSetup - mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get host mdm lock/wipe status") } @@ -1114,10 +1114,10 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f host.MDM.PendingAction = ptr.String("") // device status switch { - case mdmActions.IsLocked(): - host.MDM.DeviceStatus = ptr.String("locked") case mdmActions.IsWiped(): host.MDM.DeviceStatus = ptr.String("wiped") + case mdmActions.IsLocked(): + host.MDM.DeviceStatus = ptr.String("locked") } // pending action, if any diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 32c25e1877..1d3a5ac4c5 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -67,7 +67,7 @@ func TestHostDetails(t *testing.T) { ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) { return dsBats, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } // Health should be replaced at the service layer with custom values determined by the cycle count. See https://github.com/fleetdm/fleet/issues/6763. @@ -108,7 +108,7 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds.ListHostBatteriesFunc = func(ctx context.Context, hostID uint) ([]*fleet.HostBattery, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -385,7 +385,7 @@ func TestHostDetailsOSSettings(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -497,7 +497,7 @@ func TestHostDetailsOSSettingsWindowsOnly(t *testing.T) { ds.GetHostMDMWindowsProfilesFunc = func(ctx context.Context, uuid string) ([]fleet.HostMDMWindowsProfile, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -600,7 +600,7 @@ func TestHostAuth(t *testing.T) { ds.ListHostUpcomingActivitiesFunc = func(ctx context.Context, hostID uint, opt fleet.ListOptions) ([]*fleet.Activity, *fleet.PaginationMetadata, error) { return nil, nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } @@ -1383,7 +1383,7 @@ func TestHostMDMProfileDetail(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hid uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { @@ -1445,15 +1445,20 @@ func TestHostMDMProfileDetail(t *testing.T) { } } -func TestLockUnlockHostAuth(t *testing.T) { +func TestLockUnlockWipeHostAuth(t *testing.T) { ds := new(mock.Store) svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + const ( + teamHostID = 1 + globalHostID = 2 + ) + teamHost := &fleet.Host{TeamID: ptr.Uint(1), Platform: "darwin"} globalHost := &fleet.Host{Platform: "darwin"} ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) { - if identifier == "1" { + if identifier == fmt.Sprint(teamHostID) { return teamHost, nil } @@ -1483,14 +1488,14 @@ func TestLockUnlockHostAuth(t *testing.T) { ds.GetHostMDMMacOSSetupFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDMMacOSSetup, error) { return nil, nil } - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } - ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload) error { + ds.LockHostViaScriptFunc = func(ctx context.Context, request *fleet.HostScriptRequestPayload, platform string) error { return nil } ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) { - if hostID == 1 { + if hostID == teamHostID { return teamHost, nil } @@ -1505,7 +1510,7 @@ func TestLockUnlockHostAuth(t *testing.T) { ds.NewActivityFunc = func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error { return nil } - ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, ts time.Time) error { + ds.UnlockHostManuallyFunc = func(ctx context.Context, hostID uint, platform string, ts time.Time) error { return nil } @@ -1596,25 +1601,30 @@ func TestLockUnlockHostAuth(t *testing.T) { } ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) - err := svc.LockHost(ctx, 2) + err := svc.LockHost(ctx, globalHostID) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - err = svc.LockHost(ctx, 1) + err = svc.LockHost(ctx, teamHostID) checkAuthErr(t, tt.shouldFailTeamWrite, err) // Pretend we locked the host - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { - return &fleet.HostLockWipeStatus{HostFleetPlatform: fleetPlatform, LockMDMCommand: &fleet.MDMCommand{}, LockMDMCommandResult: &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}}, nil + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { + return &fleet.HostLockWipeStatus{HostFleetPlatform: host.FleetPlatform(), LockMDMCommand: &fleet.MDMCommand{}, LockMDMCommandResult: &fleet.MDMCommandResult{Status: fleet.MDMAppleStatusAcknowledged}}, nil } - _, err = svc.UnlockHost(ctx, 2) + _, err = svc.UnlockHost(ctx, globalHostID) checkAuthErr(t, tt.shouldFailGlobalWrite, err) - _, err = svc.UnlockHost(ctx, 1) + _, err = svc.UnlockHost(ctx, teamHostID) checkAuthErr(t, tt.shouldFailTeamWrite, err) // Reset so we're now pretending host is unlocked - ds.GetHostLockWipeStatusFunc = func(ctx context.Context, hostID uint, fleetPlatform string) (*fleet.HostLockWipeStatus, error) { + ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + + err = svc.WipeHost(ctx, globalHostID) + checkAuthErr(t, tt.shouldFailGlobalWrite, err) + err = svc.WipeHost(ctx, teamHostID) + checkAuthErr(t, tt.shouldFailTeamWrite, err) }) } } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 674f3d719e..abdacd6513 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5395,9 +5395,10 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { "team_id", "1", ) - // lock/unlock a host + // lock/unlock/wipe a host s.Do("POST", "/api/v1/fleet/hosts/123/lock", nil, http.StatusPaymentRequired) s.Do("POST", "/api/v1/fleet/hosts/123/unlock", nil, http.StatusPaymentRequired) + s.Do("POST", "/api/v1/fleet/hosts/123/wipe", nil, http.StatusPaymentRequired) } func (s *integrationTestSuite) TestScriptsEndpointsWithoutLicense() { diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 8ed971e4bd..8d6047cb41 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -6867,7 +6867,7 @@ func (s *integrationEnterpriseTestSuite) TestAllSoftwareTitles() { ) } -func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() { +func (s *integrationEnterpriseTestSuite) TestLockUnlockWipeWindowsLinux() { ctx := context.Background() t := s.T() @@ -6889,19 +6889,22 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() { require.NotNil(t, getHostResp.Host.MDM.PendingAction) require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) - // try to lock/unlock the Windows host, fails because Windows MDM must be enabled + // try to lock/unlock/wipe the Windows host, fails because Windows MDM must be enabled res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", winHost.ID), nil, http.StatusBadRequest) errMsg := extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows MDM isn't turned on.") res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", winHost.ID), nil, http.StatusBadRequest) errMsg = extractServerErrorText(res.Body) require.Contains(t, errMsg, "Windows MDM isn't turned on.") + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", winHost.ID), nil, http.StatusBadRequest) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Windows MDM isn't turned on.") - // try to lock/unlock the Linux host succeeds, no MDM constraints + // try to lock/unlock/wipe the Linux host succeeds, no MDM constraints s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", linuxHost.ID), nil, http.StatusNoContent) // simulate a successful script result for the lock command - status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost.ID, linuxHost.FleetPlatform()) + status, err := s.ds.GetHostLockWipeStatus(ctx, linuxHost) require.NoError(t, err) var orbitScriptResp orbitPostScriptResultResponse @@ -6923,6 +6926,12 @@ func (s *integrationEnterpriseTestSuite) TestLockUnlockWindowsLinux() { require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) require.NotNil(t, getHostResp.Host.MDM.PendingAction) require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction) + + // attempting to Wipe the linux host fails due to pending unlock, not because + // of MDM not enabled + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", linuxHost.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host cannot be wiped until unlock is complete.") } // checks that the specified team/no-team has the Windows OS Updates profile with diff --git a/server/service/integration_mdm_test.go b/server/service/integration_mdm_test.go index e7ac0782f0..80d875cb79 100644 --- a/server/service/integration_mdm_test.go +++ b/server/service/integration_mdm_test.go @@ -11449,12 +11449,15 @@ func (s *integrationMDMTestSuite) TestManualEnrollmentCommands() { checkInstallFleetdCommandSent(mdmDevice, false) } -func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { +func (s *integrationMDMTestSuite) TestLockUnlockWipeWindowsLinux() { t := s.T() ctx := context.Background() // create an MDM-enrolled Windows host - winHost, _ := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + winHost, winMDMClient := createWindowsHostThenEnrollMDM(s.ds, s.server.URL, t) + // set its MDM data so it shows as MDM-enrolled in the backend + err := s.ds.SetOrUpdateMDMData(ctx, winHost.ID, false, true, s.server.URL, false, fleet.WellKnownMDMFleet, "") + require.NoError(t, err) linuxHost := createOrbitEnrolledHost(t, "linux", "lock_unlock_linux", s.ds) for _, host := range []*fleet.Host{winHost, linuxHost} { @@ -11487,7 +11490,7 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { require.Contains(t, errMsg, "Host has pending lock request.") // simulate a successful script result for the lock command - status, err := s.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + status, err := s.ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) var orbitScriptResp orbitPostScriptResultResponse @@ -11504,6 +11507,10 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { // try to lock the host again s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusConflict) + // try to wipe a locked host + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host cannot be wiped until it is unlocked.") // unlock the host s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusNoContent) @@ -11521,7 +11528,7 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { require.Contains(t, errMsg, "Host has pending unlock request.") // simulate a failed script result for the unlock command - status, err = s.ds.GetHostLockWipeStatus(ctx, host.ID, host.FleetPlatform()) + status, err = s.ds.GetHostLockWipeStatus(ctx, host) require.NoError(t, err) s.DoJSON("POST", "/api/fleet/orbit/scripts/result", @@ -11534,10 +11541,267 @@ func (s *integrationMDMTestSuite) TestLockUnlockWindowsLinux() { require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) require.NotNil(t, getHostResp.Host.MDM.PendingAction) require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // unlock the host, simulate success + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusNoContent) + status, err = s.ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, status.UnlockScript.ExecutionID)), + http.StatusOK, &orbitScriptResp) + + // refresh the host's status, it is unlocked, no pending action + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // wipe the host + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusNoContent) + wipeActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // try to wipe the host again, already have it pending + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host has pending wipe request.") + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // refresh the host's status, it is unlocked, pending wipe + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "wipe", *getHostResp.Host.MDM.PendingAction) + + status, err = s.ds.GetHostLockWipeStatus(ctx, host) + require.NoError(t, err) + if host.FleetPlatform() == "linux" { + // simulate a successful wipe for the Linux host's script response + s.DoJSON("POST", "/api/fleet/orbit/scripts/result", + json.RawMessage(fmt.Sprintf(`{"orbit_node_key": %q, "execution_id": %q, "exit_code": 0, "output": "ok"}`, *host.OrbitNodeKey, status.WipeScript.ExecutionID)), + http.StatusOK, &orbitScriptResp) + } else { + // simulate a successful wipe from the Windows device's MDM response + cmds, err := winMDMClient.StartManagementSession() + require.NoError(t, err) + + // two status + the wipe command we enqueued + require.Len(t, cmds, 3) + wipeCmd := cmds[status.WipeMDMCommand.CommandUUID] + require.NotNil(t, wipeCmd) + require.Equal(t, wipeCmd.Verb, fleet.CmdExec) + require.Len(t, wipeCmd.Cmd.Items, 1) + require.EqualValues(t, "./Device/Vendor/MSFT/RemoteWipe/doWipeProtected", *wipeCmd.Cmd.Items[0].Target) + + msgID, err := winMDMClient.GetCurrentMsgID() + require.NoError(t, err) + + winMDMClient.AppendResponse(fleet.SyncMLCmd{ + XMLName: xml.Name{Local: mdm_types.CmdStatus}, + MsgRef: &msgID, + CmdRef: &status.WipeMDMCommand.CommandUUID, + Cmd: ptr.String("Exec"), + Data: ptr.String("200"), + Items: nil, + CmdID: fleet.CmdID{Value: uuid.NewString()}, + }) + cmds, err = winMDMClient.SendResponse() + require.NoError(t, err) + // the ack of the message should be the only returned command + require.Len(t, cmds, 1) + } + + // refresh the host's status, it is wiped + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to lock/unlock the host fails + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process lock requests once host is wiped.") + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process unlock requests once host is wiped.") + + // try to wipe the host again, conflict (already wiped) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusConflict) + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // re-enroll the host, simulating that another user received the wiped host + newOrbitKey := uuid.New().String() + newHost, err := s.ds.EnrollOrbit(ctx, true, fleet.OrbitHostInfo{ + HardwareUUID: *host.OsqueryHostID, + HardwareSerial: host.HardwareSerial, + }, newOrbitKey, nil) + require.NoError(t, err) + // it re-enrolled using the same host record + require.Equal(t, host.ID, newHost.ID) + + // refresh the host's status, it is back to unlocked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) }) } } +func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() { + t := s.T() + host, mdmClient := createHostThenEnrollMDM(s.ds, s.server.URL, t) + + // get the host's information + var getHostResp getHostResponse + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to unlock the host (which is already its status) + var unlockResp unlockHostResponse + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusConflict, &unlockResp) + + // lock the host + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusNoContent) + + // refresh the host's status, it is now pending lock + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "lock", *getHostResp.Host.MDM.PendingAction) + + // try locking the host while it is pending lock fails + res := s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg := extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host has pending lock request.") + + // simulate a successful MDM result for the lock command + cmd, err := mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "DeviceLock", cmd.Command.RequestType) + cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // refresh the host's status, it is now locked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to lock the host again + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusConflict) + // try to wipe a locked host + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host cannot be wiped until it is unlocked.") + + // unlock the host + unlockResp = unlockHostResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusOK, &unlockResp) + require.NotNil(t, unlockResp.HostID) + require.Equal(t, host.ID, *unlockResp.HostID) + require.Len(t, unlockResp.UnlockPIN, 6) + unlockPIN := unlockResp.UnlockPIN + unlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0) + + // refresh the host's status, it is locked pending unlock + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "locked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "unlock", *getHostResp.Host.MDM.PendingAction) + + // try unlocking the host again simply returns the PIN again + unlockResp = unlockHostResponse{} + s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusOK, &unlockResp) + require.Equal(t, unlockPIN, unlockResp.UnlockPIN) + // a new unlock host activity is created every time the unlock PIN is viewed + newUnlockActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeUnlockedHost{}.ActivityName(), + fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0) + require.NotEqual(t, unlockActID, newUnlockActID) + + // as soon as the host sends an Idle MDM request, it is maked as unlocked + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.Nil(t, cmd) + + // refresh the host's status, it is unlocked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // wipe the host + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusNoContent) + wipeActID := s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), 0) + + // try to wipe the host again, already have it pending + res = s.DoRaw("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Host has pending wipe request.") + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // refresh the host's status, it is unlocked, pending wipe + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "wipe", *getHostResp.Host.MDM.PendingAction) + + // simulate a successful MDM result for the wipe command + cmd, err = mdmClient.Idle() + require.NoError(t, err) + require.NotNil(t, cmd) + require.Equal(t, "EraseDevice", cmd.Command.RequestType) + cmd, err = mdmClient.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + + // refresh the host's status, it is wiped + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "wiped", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) + + // try to lock/unlock the host fails + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/lock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process lock requests once host is wiped.") + res = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/unlock", host.ID), nil, http.StatusUnprocessableEntity) + errMsg = extractServerErrorText(res.Body) + require.Contains(t, errMsg, "Cannot process unlock requests once host is wiped.") + + // try to wipe the host again, conflict (already wiped) + s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusConflict) + // no activity created + s.lastActivityOfTypeMatches(fleet.ActivityTypeWipedHost{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, host.ID, host.DisplayName()), wipeActID) + + // re-enroll the host, simulating that another user received the wiped host + err = mdmClient.Enroll() + require.NoError(t, err) + + // refresh the host's status, it is back to unlocked + s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d", host.ID), nil, http.StatusOK, &getHostResp) + require.NotNil(t, getHostResp.Host.MDM.DeviceStatus) + require.Equal(t, "unlocked", *getHostResp.Host.MDM.DeviceStatus) + require.NotNil(t, getHostResp.Host.MDM.PendingAction) + require.Equal(t, "", *getHostResp.Host.MDM.PendingAction) +} + func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() { t := s.T() diff --git a/server/service/mdm.go b/server/service/mdm.go index da369740c9..3f1bf6aab0 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -545,24 +545,6 @@ func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte return nil, ctxerr.Wrap(ctx, err, "decode plist command") } - // TODO(mna): as per the story's spec: - // Make macOS and Windows MDM, low-level lock command available for free - // users. Remove validation where we check for Premium for custom MDM - // commands that contain the lock command - // - // So we'd need to not only remove this validation to allow DeviceLock (and - // eventually EraseDevice for the Wipe story), but it needs to behave - // similarly to how the /lock endpoint would've: - // - // see https://fleetdm.slack.com/archives/C03C41L5YEL/p1707169116154199?thread_ts=1707162619.655219&cid=C03C41L5YEL - // Regarding Free use of “lock” command as custom command, remove the validation but does that behave the same as if /lock had been used? - // @Martin Angers - // that’s right. - // - // So it looks like we'd need to parse the command's XML to get the unlock - // PIN, and TBD how to behave if there is no PIN or if it's larger than - // supported. - if appleMDMPremiumCommands[strings.TrimSpace(cmd.Command.RequestType)] { lic, err := svc.License(ctx) if err != nil { @@ -621,15 +603,6 @@ func (svc *Service) enqueueMicrosoftMDMCommand(ctx context.Context, rawXMLCmd [] return nil, ctxerr.Wrap(ctx, err, "decode SyncML command") } - // TODO(mna): as per the story's spec: - // Make macOS and Windows MDM, low-level lock command available for Free - // users. Remove validation where we check for Premium for custom MDM - // commands that contain the lock command - // - // However for Windows, it looks like we only prevent the RemoteWipe command, - // nothing for lock, so looks like nothing to do here for now (will need a - // change for the wipe command). - if cmdMsg.IsPremium() { lic, err := svc.License(ctx) if err != nil { diff --git a/server/service/scripts.go b/server/service/scripts.go index 51d1c009f9..b9c87e40f4 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -924,3 +924,34 @@ func (svc *Service) UnlockHost(ctx context.Context, hostID uint) (string, error) return "", fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// Wipe host +//////////////////////////////////////////////////////////////////////////////// + +type wipeHostRequest struct { + HostID uint `url:"id"` +} + +type wipeHostResponse struct { + Err error `json:"error,omitempty"` +} + +func (r wipeHostResponse) Status() int { return http.StatusNoContent } +func (r wipeHostResponse) error() error { return r.Err } + +func wipeHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { + req := request.(*wipeHostRequest) + if err := svc.WipeHost(ctx, req.HostID); err != nil { + return wipeHostResponse{Err: err}, nil + } + return wipeHostResponse{}, nil +} + +func (svc *Service) WipeHost(ctx context.Context, hostID uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +}