Add mdm wipe host feature (#17272)

relates to #9951

This adds the mdm host wipe feature to fleet. This includes:

1. adding wipe functionality in the fleet web UI
2. adding wipe functionality in the fleetctl CLI
3. adding API endpoints to wipe a host
4. Implementing wipe functionality on the fleet server.
This commit is contained in:
Roberto Dip 2024-03-01 18:02:00 -03:00 committed by GitHub
commit 730f8850ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
84 changed files with 2650 additions and 725 deletions

View file

@ -0,0 +1 @@
* Added the `POST /api/v1/fleet/hosts/:id/wipe` Fleet Premium API endpoint to support remote wiping a host.

View file

@ -0,0 +1 @@
- add UI for wiping a host with fleet mdm.

View file

@ -0,0 +1 @@
- add wipe command to fleetctl

BIN
cmd/fleetctl/debug.test3290534544 Executable file

Binary file not shown.

BIN
cmd/fleetctl/debug.test698732484 Executable file

Binary file not shown.

View file

@ -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
}

View file

@ -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
}

View file

@ -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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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, &notFoundError{}
}
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)
}
}
}

View file

@ -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) {

View file

@ -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"
}
```
<meta name="title" value="Audit logs">
<meta name="pageOrderInSection" value="1400">

View file

@ -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."

View file

@ -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 = `
<Exec>
<CmdID>%s</CmdID>
<Item>
<Target>
<LocURI>./Device/Vendor/MSFT/RemoteWipe/doWipeProtected</LocURI>
</Target>
<Meta>
<Format xmlns="syncml:metinf">chr</Format>
<Type>text/plain</Type>
</Meta>
<Data></Data>
</Item>
</Exec>`
)

View file

@ -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
}

View file

@ -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,

View file

@ -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<IActivity, "type"> & {
type: IHostPastActivityType;
};
export interface IActivityDetails {
pack_id?: number;
pack_name?: string;

View file

@ -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;

View file

@ -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(<ActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText("wiped", { exact: false })).toBeInTheDocument();
expect(screen.getByText("Foo Host", { exact: false })).toBeInTheDocument();
});
});

View file

@ -755,6 +755,14 @@ const TAGGED_TEMPLATES = {
</>
);
},
wipedHost: (activity: IActivity) => {
return (
<>
{" "}
wiped <b>{activity.details?.host_display_name}</b>.
</>
);
},
};
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);
}

View file

@ -22,7 +22,7 @@ describe("Integrations Page", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: { isMdmEnabledAndConfigured: true },
app: { isMacMdmEnabledAndConfigured: true },
},
});

View file

@ -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(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="darwin"
hostMdmDeviceStatus="locked"
/>
);
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(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="darwin"
hostMdmDeviceStatus="unlocked"
/>
);
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(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="windows"
hostMdmDeviceStatus="unlocked"
/>
);
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(
<HostActionsDropdown
hostTeamId={null}
onSelect={noop}
hostStatus="online"
hostMdmEnrollmentStatus="On (automatic)"
mdmName="Fleet"
hostPlatform="darwin"
hostMdmDeviceStatus="unlocked"
/>
);
await user.click(screen.getByText("Actions"));
expect(screen.queryByText("Wipe")).not.toBeInTheDocument();
});
});
});

View file

@ -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,

View file

@ -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;

View file

@ -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<IHostPolicy | null>(
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 && (
<WipeModal
id={host.id}
hostName={host.display_name}
onSuccess={() => setHostMdmDeviceState("wiping")}
onClose={() => setShowWipeModal(false)}
/>
)}
</>
</MainContent>
);

View file

@ -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 (
<Modal className={baseClass} title="Wipe host" onExit={onClose}>
<>
<div className={`${baseClass}__modal-content`}>
<p>All content will be erased on this host.</p>
<div className={`${baseClass}__confirm-message`}>
<span>
<b>Please check to confirm:</b>
</span>
<Checkbox
wrapperClassName={`${baseClass}__wipe-checkbox`}
value={lockChecked}
onChange={(value: boolean) => setLockChecked(value)}
>
I wish to wipe <b>{hostName}</b>
</Checkbox>
</div>
</div>
<div className="modal-cta-wrap">
<Button
type="button"
onClick={onWipe}
variant="alert"
className="delete-loading"
disabled={!lockChecked}
isLoading={isWiping}
>
Wipe
</Button>
<Button onClick={onClose} variant="inverse-alert">
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default WipeModal;

View file

@ -0,0 +1,14 @@
.wipe-modal {
p {
margin: 0;
}
&__modal-content {
display: grid;
gap: $pad-large;
}
&__wipe-checkbox {
margin-top: $pad-small;
}
}

View file

@ -0,0 +1 @@
export { default } from "./WipeModal";

View file

@ -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 = ({
</TabList>
<TabPanel>
<PastActivityFeed
activities={activities}
activities={activities as IPastActivitiesResponse | undefined}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}
@ -103,7 +106,7 @@ const Activity = ({
<TabPanel>
<UpcomingTooltip />
<UpcomingActivityFeed
activities={activities}
activities={activities as IUpcomingActivitiesResponse | undefined}
onDetailsClick={onShowDetails}
isError={isError}
onNextPage={onNextPage}

View file

@ -0,0 +1,34 @@
import React from "react";
import {
ActivityType,
IHostPastActivityType,
IPastActivity,
} from "interfaces/activity";
import { ShowActivityDetailsHandler } from "./Activity";
import RanScriptActivityItem from "./ActivityItems/RanScriptActivityItem";
import LockedHostActivityItem from "./ActivityItems/LockedHostActivityItem";
import UnlockedHostActivityItem from "./ActivityItems/UnlockedHostActivityItem";
/** The component props that all host activity items must adhere to */
export interface IHostActivityItemComponentProps {
activity: IPastActivity;
}
/** Used for activity items component that need a show details handler */
export interface IHostActivityItemComponentPropsWithShowDetails
extends IHostActivityItemComponentProps {
onShowDetails: ShowActivityDetailsHandler;
}
export const pastActivityComponentMap: Record<
IHostPastActivityType,
| React.FC<IHostActivityItemComponentProps>
| React.FC<IHostActivityItemComponentPropsWithShowDetails>
> = {
[ActivityType.RanScript]: RanScriptActivityItem,
[ActivityType.LockedHost]: LockedHostActivityItem,
[ActivityType.UnlockedHost]: UnlockedHostActivityItem,
};

View file

@ -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 (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b> locked this host.
</HostActivityItem>
);
};
export default LockedHostActivityItem;

View file

@ -0,0 +1 @@
export { default } from "./LockedHostActivityItem";

View file

@ -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 (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b>
<>
{" "}
ran {formatScriptNameForActivityItem(activity.details?.script_name)} on
this host.{" "}
<ShowDetailsButton activity={activity} onShowDetails={onShowDetails} />
</>
</HostActivityItem>
);
};
export default RanScriptActivityItem;

View file

@ -0,0 +1 @@
export { default } from "./RanScriptActivityItem";

View file

@ -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 (
<HostActivityItem className={baseClass} activity={activity}>
<b>{activity.actor_full_name}</b> unlocked this host.
</HostActivityItem>
);
};
export default UnlockedHostActivityItem;

View file

@ -0,0 +1 @@
export { default } from "./UnlockedHostActivityItem";

View file

@ -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 (
<div className={classNames}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{ gravatar_url }}
size="small"
hasWhiteBackground
/>
<div className={`${baseClass}__details-wrapper`}>
<div className={"activity-details"}>
<span className={`${baseClass}__details-topline`}>
<span>{children}</span>
</span>
<br />
<span
className={`${baseClass}__details-bottomline`}
data-tip
data-for={`activity-${activity.id}`}
>
{activityCreatedAt &&
formatDistanceToNowStrict(activityCreatedAt, {
addSuffix: true,
})}
</span>
{activityCreatedAt && (
<ReactTooltip
className="date-tooltip"
place="top"
type="dark"
effect="solid"
id={`activity-${activity.id}`}
backgroundColor={COLORS["tooltip-bg"]}
>
{internationalTimeFormat(activityCreatedAt)}
</ReactTooltip>
)}
</div>
</div>
<div className={`${baseClass}__dash`} />
</div>
);
};
export default HostActivityItem;

View file

@ -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;
}
}
}

View file

@ -0,0 +1 @@
export { default } from "./HostActivityItem";

View file

@ -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<IPastActivityProps, "activity" | "onDetailsClick">) => (
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b>
<>
{" "}
ran {formatScriptNameForActivityItem(activity.details?.script_name)} on
this host.{" "}
<Button
className={`${baseClass}__show-query-link`}
variant="text-link"
onClick={() => onDetailsClick?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-query-icon`} name="eye" />
</Button>
</>
</span>
);
const LockedHostActivityDetails = ({
activity,
}: Pick<IPastActivityProps, "activity">) => (
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b> locked this host.
</span>
);
const UnlockedHostActivityDetails = ({
activity,
}: Pick<IPastActivityProps, "activity">) => (
<span className={`${baseClass}__details-topline`}>
<b>{activity.actor_full_name}</b>{" "}
{activity.details?.host_platform === "darwin"
? "viewed the six-digit unlock PIN for"
: "unlocked"}{" "}
this host.
</span>
);
const PastActivityTopline = ({
activity,
onDetailsClick,
}: IPastActivityProps) => {
switch (activity.type) {
case "ran_script":
return (
<RanScriptActivityDetails
activity={activity}
onDetailsClick={onDetailsClick}
/>
);
case "locked_host":
return <LockedHostActivityDetails activity={activity} />;
case "unlocked_host":
return <UnlockedHostActivityDetails activity={activity} />;
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 (
<div className={baseClass}>
<Avatar
className={`${baseClass}__avatar-image`}
user={{ gravatar_url }}
size="small"
hasWhiteBackground
/>
<div className={`${baseClass}__details-wrapper`}>
<div className="activity-details">
<PastActivityTopline
activity={activity}
onDetailsClick={onDetailsClick}
/>
<br />
<span
className={`${baseClass}__details-bottomline`}
data-tip
data-for={`activity-${activity.id}`}
>
{formatDistanceToNowStrict(activityCreatedAt, {
addSuffix: true,
})}
</span>
<ReactTooltip
className="date-tooltip"
place="top"
type="dark"
effect="solid"
id={`activity-${activity.id}`}
backgroundColor={COLORS["tooltip-bg"]}
>
{internationalTimeFormat(activityCreatedAt)}
</ReactTooltip>
</div>
</div>
<div className={`${baseClass}__dash`} />
</div>
);
};
export default PastActivity;

View file

@ -1 +0,0 @@
export { default } from "./PastActivity";

View file

@ -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 (
<div className={baseClass}>
<div>
{activitiesList.map((activity: IActivity) => (
<PastActivity activity={activity} onDetailsClick={onDetailsClick} />
))}
{activitiesList.map((activity: IPastActivity) => {
const ActivityItemComponent = pastActivityComponentMap[activity.type];
return (
<ActivityItemComponent
key={activity.id}
activity={activity}
onShowDetails={onDetailsClick}
/>
);
})}
</div>
<div className={`${baseClass}__pagination`}>
<Button

View file

@ -0,0 +1,33 @@
import React from "react";
import { IActivity } from "interfaces/activity";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { ShowActivityDetailsHandler } from "../Activity";
const baseClass = "show-details-button";
interface IShowDetailsButtonProps {
activity: IActivity;
onShowDetails: ShowActivityDetailsHandler;
}
const ShowDetailsButton = ({
activity,
onShowDetails,
}: IShowDetailsButtonProps) => {
return (
<Button
className={baseClass}
variant="text-link"
onClick={() => onShowDetails?.(activity)}
>
Show details{" "}
<Icon className={`${baseClass}__show-details-icon`} name="eye" />
</Button>
);
};
export default ShowDetailsButton;

View file

@ -0,0 +1,5 @@
.show-details-button {
&__show-details-icon {
margin-left: $pad-xsmall;
}
}

View file

@ -0,0 +1 @@
export { default } from "./ShowDetailsButton";

View file

@ -12,7 +12,8 @@
}
&.error {
background-color: $ui-error;
color: $core-white;
background-color: $core-vibrant-red;
}
}

View file

@ -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&apos;t fetch data from <br /> a locked host.
</>
),
wiping: (
<>
You can&apos;t fetch data from <br /> a wiping host.
</>
),
wiped: (
<>
You can&apos;t fetch data from <br /> a wiped host.
</>
),
} as const;

View file

@ -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

View file

@ -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<IActivitiesResponse> => {
): Promise<IPastActivitiesResponse> => {
const { HOST_PAST_ACTIVITIES } = endpoints;
const queryParams = {

View file

@ -397,8 +397,14 @@ export default {
const { HOST_LOCK } = endpoints;
return sendRequest("POST", HOST_LOCK(id));
},
unlockHost: (id: number): Promise<IUnlockHostResponse> => {
const { HOST_UNLOCK } = endpoints;
return sendRequest("POST", HOST_UNLOCK(id));
},
wipeHost: (id: number) => {
const { HOST_WIPE } = endpoints;
return sendRequest("POST", HOST_WIPE(id));
},
};

View file

@ -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`,

View file

@ -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,

View file

@ -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")
}

View file

@ -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("<?xml"),
}
cmd.Command.RequestType = "EraseDevice"
err = appleStore.EnqueueDeviceWipeCommand(ctx, host, cmd)
require.NoError(t, err)
// it is now pending wipe
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// record a command result failure to simulate failed wipe (back to unlocked)
err = appleStore.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: host.UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: cmd.CommandUUID,
Status: "Error",
RequestType: cmd.Command.RequestType,
Raw: cmd.Raw,
})
require.NoError(t, err)
err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, cmd.Command.RequestType, false)
require.NoError(t, err)
// it is back to unlocked
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, false)
// record a new request to wipe the host
cmd = &mdm.Command{
CommandUUID: uuid.NewString(),
Raw: []byte("<?xml"),
}
cmd.Command.RequestType = "EraseDevice"
err = appleStore.EnqueueDeviceWipeCommand(ctx, host, cmd)
require.NoError(t, err)
// it is back to pending wipe
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, true, false, false, false, false, true)
// record a command result success to simulate wipe
err = appleStore.StoreCommandReport(&mdm.Request{
EnrollID: &mdm.EnrollID{ID: host.UUID},
Context: ctx,
}, &mdm.CommandResults{
CommandUUID: cmd.CommandUUID,
Status: "Acknowledged",
RequestType: cmd.Command.RequestType,
Raw: cmd.Raw,
})
require.NoError(t, err)
err = ds.UpdateHostLockWipeStatusFromAppleMDMResult(ctx, host.UUID, cmd.CommandUUID, cmd.Command.RequestType, true)
require.NoError(t, err)
// it is wiped
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, false, false, true, false, false, false)
}
func TestMDMAppleProfileVerification(t *testing.T) {

View file

@ -784,8 +784,6 @@ const hostMDMSelect = `,
) mdm_host_data
`
// TODO(mna): add integration tests with Get host with locked/wiped/unlocked (+pending) to ensure proper marshaling.
// hostMDMJoin is the SQL fragment used to join MDM-related tables to the hosts table. It is a
// dependency of the hostMDMSelect fragment.
const hostMDMJoin = `
@ -1526,9 +1524,9 @@ func filterHostsByVulnerability(sqlstmt string, opt fleet.HostListOptions, param
SELECT hs.host_id FROM host_software hs
JOIN software_cve sc ON sc.software_id = hs.software_id
WHERE sc.cve = ?
UNION
SELECT hos.host_id FROM host_operating_system hos
JOIN operating_system_vulnerabilities osv ON osv.operating_system_id = hos.os_id
WHERE osv.cve = ?)`
@ -1798,6 +1796,11 @@ func (ds *Datastore) EnrollOrbit(ctx context.Context, isMDMEnabled bool, hostInf
}
host.ID = hostID
// 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, "orbit enroll error clearing host_mdm_actions")
}
case errors.Is(err, sql.ErrNoRows):
zeroTime := time.Unix(0, 0).Add(24 * time.Hour)
// Create new host record. We always create newly enrolled hosts with refetch_requested = true
@ -1920,6 +1923,11 @@ func (ds *Datastore) EnrollHost(ctx context.Context, isMDMEnabled bool, osqueryH
return ctxerr.Wrap(ctx, err, "cleanup policy membership on re-enroll")
}
// clear any host_mdm_actions following re-enrollment here
if _, err := tx.ExecContext(ctx, `DELETE FROM host_mdm_actions WHERE host_id = ?`, matchedID); err != nil {
return ctxerr.Wrap(ctx, err, "error clearing host_mdm_actions")
}
// Update existing host record
sqlUpdate := `
UPDATE hosts

View file

@ -142,28 +142,32 @@ func (ds *Datastore) MDMWindowsInsertCommandForHosts(ctx context.Context, hostUU
}
return ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// first, create the command entry
stmt := `
INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri)
VALUES (?, ?, ?)
`
if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil {
if isDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand")
}
// create the command execution queue entries, one per host
for _, hostUUIDOrDeviceID := range hostUUIDsOrDeviceIDs {
if err := ds.mdmWindowsInsertHostCommandDB(ctx, tx, hostUUIDOrDeviceID, cmd.CommandUUID); err != nil {
return err
}
}
return nil
return ds.mdmWindowsInsertCommandForHostsDB(ctx, tx, hostUUIDsOrDeviceIDs, cmd)
})
}
func (ds *Datastore) mdmWindowsInsertCommandForHostsDB(ctx context.Context, tx sqlx.ExecerContext, hostUUIDsOrDeviceIDs []string, cmd *fleet.MDMWindowsCommand) error {
// first, create the command entry
stmt := `
INSERT INTO windows_mdm_commands (command_uuid, raw_command, target_loc_uri)
VALUES (?, ?, ?)
`
if _, err := tx.ExecContext(ctx, stmt, cmd.CommandUUID, cmd.RawCommand, cmd.TargetLocURI); err != nil {
if isDuplicate(err) {
return ctxerr.Wrap(ctx, alreadyExists("MDMWindowsCommand", cmd.CommandUUID))
}
return ctxerr.Wrap(ctx, err, "inserting MDMWindowsCommand")
}
// create the command execution queue entries, one per host
for _, hostUUIDOrDeviceID := range hostUUIDsOrDeviceIDs {
if err := ds.mdmWindowsInsertHostCommandDB(ctx, tx, hostUUIDOrDeviceID, cmd.CommandUUID); err != nil {
return err
}
}
return nil
}
func (ds *Datastore) mdmWindowsInsertHostCommandDB(ctx context.Context, tx sqlx.ExecerContext, hostUUIDOrDeviceID, commandUUID string) error {
stmt := `
INSERT INTO windows_mdm_command_queue (enrollment_id, command_uuid)
@ -228,20 +232,20 @@ func (ds *Datastore) MDMWindowsSaveResponse(ctx context.Context, deviceID string
return ctxerr.New(ctx, "empty raw response")
}
const findCommandsStmt = `SELECT command_uuid, raw_command FROM windows_mdm_commands WHERE command_uuid IN (?)`
const (
findCommandsStmt = `SELECT command_uuid, raw_command, target_loc_uri FROM windows_mdm_commands WHERE command_uuid IN (?)`
saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`
dequeueCommandsStmt = `DELETE FROM windows_mdm_command_queue WHERE command_uuid IN (?)`
const saveFullRespStmt = `INSERT INTO windows_mdm_responses (enrollment_id, raw_response) VALUES (?, ?)`
const dequeueCommandsStmt = `DELETE FROM windows_mdm_command_queue WHERE command_uuid IN (?)`
const insertResultsStmt = `
insertResultsStmt = `
INSERT INTO windows_mdm_command_results
(enrollment_id, command_uuid, raw_result, response_id, status_code)
VALUES %s
ON DUPLICATE KEY UPDATE
raw_result = COALESCE(VALUES(raw_result), raw_result),
status_code = COALESCE(VALUES(status_code), status_code)
`
`
)
enrollment, err := ds.MDMWindowsGetEnrolledDeviceWithDeviceID(ctx, deviceID)
if err != nil {
@ -301,9 +305,15 @@ ON DUPLICATE KEY UPDATE
// for all the matching UUIDs, try to find any <Status> or
// <Result> 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
})
}

View file

@ -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) {

View file

@ -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
}

View file

@ -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)
}

View file

@ -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()

View file

@ -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) {

View file

@ -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)

File diff suppressed because one or more lines are too long

View file

@ -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")
}

View file

@ -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(`<Exec></Exec>`),
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)

View file

@ -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"
}`
}

View file

@ -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
}

View file

@ -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

View file

@ -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"`

View file

@ -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
}
}

View file

@ -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
}

View file

@ -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(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@ -132,10 +132,26 @@ func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, hostUUIDs []strin
<string>EraseDevice</string>
<key>PIN</key>
<string>%s</string>
<key>ObliterationBehavior</key>
<string>Default</string>
</dict>
</dict>
</plist>`, 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 {

View file

@ -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) {

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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

View file

@ -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

View file

@ -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)
})
}
}

View file

@ -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() {

View file

@ -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

View file

@ -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()

View file

@ -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
// thats 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 {

View file

@ -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
}