diff --git a/changes/27646-ios-clear-passcode b/changes/27646-ios-clear-passcode new file mode 100644 index 00000000000..f216b72fcdb --- /dev/null +++ b/changes/27646-ios-clear-passcode @@ -0,0 +1 @@ +- Added ability for admins to remotely clear the passcode on ADE-enrolled iOS/iPadOS devices via the host details page. diff --git a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json index bd9f5719fd4..80fbdf0a073 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseJson.json @@ -49,7 +49,8 @@ "enrollment_status": null, "name": "", "pending_action": "", - "server_url": null + "server_url": null, + "unlock_token_available": false }, "memory": 0, "orbit_version": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml index 4971c3bdac9..3af2a825189 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedHostDetailResponseYaml.yml @@ -49,6 +49,7 @@ spec: name: "" pending_action: "" server_url: null + unlock_token_available: false memory: 0 orbit_version: null os_version: "" diff --git a/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json b/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json index 1761171cf96..359194cc34f 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedListHostsJson.json @@ -50,7 +50,8 @@ "encryption_key_available": false, "enrollment_status": null, "name": "", - "server_url": null + "server_url": null, + "unlock_token_available": false }, "memory": 0, "orbit_version": null, @@ -123,7 +124,8 @@ "encryption_key_available": false, "enrollment_status": null, "name": "", - "server_url": null + "server_url": null, + "unlock_token_available": false }, "memory": 0, "orbit_version": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json b/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json index b25dbea74b2..58e89402753 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedListHostsMDM.json @@ -51,7 +51,8 @@ "encryption_key_available": false, "enrollment_status": null, "name": "", - "server_url": null + "server_url": null, + "unlock_token_available": false }, "memory": 0, "orbit_version": null, @@ -124,7 +125,8 @@ "encryption_key_available": false, "enrollment_status": null, "name": "", - "server_url": null + "server_url": null, + "unlock_token_available": false }, "memory": 0, "orbit_version": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml index a5b2edf22a2..5a800e815f5 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedListHostsYaml.yml @@ -46,6 +46,7 @@ spec: enrollment_status: null name: "" server_url: null + unlock_token_available: false memory: 0 orbit_version: null os_version: "" @@ -115,6 +116,7 @@ spec: enrollment_status: null name: "" server_url: null + unlock_token_available: false memory: 0 orbit_version: null os_version: "" diff --git a/docs/REST API/rest-api.md b/docs/REST API/rest-api.md index 958d8b9e7cd..f9a957239bd 100644 --- a/docs/REST API/rest-api.md +++ b/docs/REST API/rest-api.md @@ -2882,6 +2882,7 @@ None. - [Lock host](#lock-host) - [Unlock host](#unlock-host) - [Wipe host](#wipe-host) +- [Clear passcode](#clear-passcode) - [Get host's past activity](#get-hosts-past-activity) - [Get host's upcoming activity](#get-hosts-upcoming-activity) - [Cancel host's upcoming activity](#cancel-hosts-upcoming-activity) @@ -5187,6 +5188,28 @@ To wipe a macOS, iOS, iPadOS, or Windows host, the host must have MDM turned on. > To verify the host was successfully wiped, you can use the [Get host](https://fleetdm.com/docs/rest-api/rest-api#get-host) endpoint to retrieve the host's `mdm.device_status`. +### Clear passcode + +Sends a command to clear the passcode on the specified iOS or iPadOS host. The device must be enrolled via Automated Device Enrollment (ADE) and have MDM turned on. + +The host must have previously sent its unlock token to Fleet (visible via `mdm.unlock_token_available` on the [Get host](https://fleetdm.com/docs/rest-api/rest-api#get-host) response). The unlock token is sent automatically when the device checks in. + +`POST /api/v1/fleet/hosts/:id/clear_passcode` + +#### Parameters + +| Name | Type | In | Description | +|------|---------|------|--------------------------------------------------------| +| id | integer | path | **Required**. ID of the iOS or iPadOS host. | + +#### Example + +`POST /api/v1/fleet/hosts/123/clear_passcode` + +##### Default response + +`Status: 200` + ### Get host's past activity `GET /api/v1/fleet/hosts/:id/activities` diff --git a/ee/server/service/hosts.go b/ee/server/service/hosts.go index cf459c04396..a68d34ba738 100644 --- a/ee/server/service/hosts.go +++ b/ee/server/service/hosts.go @@ -566,3 +566,82 @@ var ( ` ) + +func (svc *Service) ClearHostPasscode(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.Host(ctx, hostID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get host") + } + + // Authorize again with team loaded now that we have the host's team_id. + if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil { + return err + } + + // Only supported for iOS and iPadOS. + switch host.FleetPlatform() { + case "ios", "ipados": + // continue + default: + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Clear passcode is only supported for iOS and iPadOS hosts.")) + } + + // Personal (BYOD) enrollment is not supported. + if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "On (personal)" { + return &fleet.BadRequestError{ + Message: "Can't clear passcode on a personal device.", + } + } + + // Manual enrollment is not supported — requires ADE. + if host.MDM.EnrollmentStatus != nil && *host.MDM.EnrollmentStatus == "On (manual)" { + return &fleet.BadRequestError{ + Message: "Can't clear passcode on a manually enrolled device. The host must be enrolled via Automated Device Enrollment (ADE).", + } + } + + 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 Apple MDM enabled") + } + + connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host) + if err != nil { + return ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet MDM") + } + if !connected { + return ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "Can't clear passcode because the host doesn't have MDM turned on.")) + } + + unlockToken, err := svc.ds.GetMDMAppleDeviceUnlockToken(ctx, host.UUID) + if err != nil { + return ctxerr.Wrap(ctx, err, "get device unlock token") + } + if len(unlockToken) == 0 { + return fleet.NewInvalidArgumentError("host_id", "Passcode cannot be cleared. The device unlock token is not yet available. Try again after the device checks in.") + } + + if err := svc.mdmAppleCommander.ClearPasscode(ctx, host, uuid.NewString(), unlockToken); err != nil { + return ctxerr.Wrap(ctx, err, "enqueue clear passcode command") + } + + vc, ok := viewer.FromContext(ctx) + if !ok { + return fleet.ErrNoContext + } + if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeClearedPasscode{ + HostID: host.ID, + HostDisplayName: host.DisplayName(), + }); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for clear passcode") + } + + return nil +} diff --git a/ee/server/service/mdm_test.go b/ee/server/service/mdm_test.go index c7b9f8dac04..8a4f21e97c0 100644 --- a/ee/server/service/mdm_test.go +++ b/ee/server/service/mdm_test.go @@ -19,6 +19,63 @@ import ( "howett.net/plist" ) +// mockMDMAppleCommander implements fleet.MDMAppleCommandIssuer for testing. +type mockMDMAppleCommander struct { + clearPasscodeFunc func(ctx context.Context, host *fleet.Host, commandUUID string, unlockToken []byte) error +} + +func (m *mockMDMAppleCommander) InstallProfile(_ context.Context, _ []string, _ mobileconfig.Mobileconfig, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) RemoveProfile(_ context.Context, _ []string, _ string, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) DeviceLock(_ context.Context, _ *fleet.Host, _ string) (string, error) { + return "", nil +} +func (m *mockMDMAppleCommander) EnableLostMode(_ context.Context, _ *fleet.Host, _ string, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) DisableLostMode(_ context.Context, _ *fleet.Host, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) EraseDevice(_ context.Context, _ *fleet.Host, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) InstallEnterpriseApplication(_ context.Context, _ []string, _ string, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) DeviceConfigured(_ context.Context, _, _ string) error { return nil } +func (m *mockMDMAppleCommander) SetRecoveryLock(_ context.Context, _ []string, _ string) error { + return nil +} +func (m *mockMDMAppleCommander) ClearPasscode(ctx context.Context, host *fleet.Host, commandUUID string, unlockToken []byte) error { + if m.clearPasscodeFunc != nil { + return m.clearPasscodeFunc(ctx, host, commandUUID, unlockToken) + } + return nil +} + +// minimalMockFleetService provides the fleet.Service methods needed by ClearHostPasscode tests. +type minimalMockFleetService struct { + fleet.Service + newActivityCalled bool + newActivityErr error + verifyMDMConfiguredFunc func() error +} + +func (s *minimalMockFleetService) VerifyMDMAppleConfigured(_ context.Context) error { + if s.verifyMDMConfiguredFunc != nil { + return s.verifyMDMConfiguredFunc() + } + return nil +} + +func (s *minimalMockFleetService) NewActivity(_ context.Context, _ *fleet.User, _ fleet.ActivityDetails) error { + s.newActivityCalled = true + return s.newActivityErr +} + func setup(t *testing.T) (*mock.Store, *Service) { ds := new(mock.Store) @@ -176,6 +233,243 @@ b1xn1jGQd/o0xFf9ojpDNy6vNojidQGHh6E3h0GYvxbnQmVNq5U= // private key in code. func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") } +func TestClearHostPasscode(t *testing.T) { + t.Parallel() + + ds := new(mock.Store) + authorizer, err := authz.NewAuthorizer() + require.NoError(t, err) + commander := &mockMDMAppleCommander{} + activitySvc := &minimalMockFleetService{} + svc := Service{ds: ds, authz: authorizer, mdmAppleCommander: commander, Service: activitySvc} + + iosHost := &fleet.Host{ + ID: 1, + UUID: "ios-host-uuid", + Platform: "ios", + } + enrolledAuto := "On (automatic)" + iosHost.MDM.EnrollmentStatus = &enrolledAuto + + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return iosHost, nil + } + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return []byte("unlock-token"), nil + } + + t.Run("auth", func(t *testing.T) { + cases := []struct { + desc string + user *fleet.User + shouldFailAuth bool + }{ + {"no role", test.UserNoRoles, true}, + {"global admin", test.UserAdmin, false}, + {"global maintainer", test.UserMaintainer, false}, + {"global observer", test.UserObserver, true}, + {"global observer+", test.UserObserverPlus, true}, + {"global technician", test.UserTechnician, true}, + // GitOps cannot list hosts (first auth check), so this fails. + {"global gitops", test.UserGitOps, true}, + // Team-scoped users can list hosts but cannot write global MDM commands. + {"team admin team1", test.UserTeamAdminTeam1, true}, + {"team maintainer team1", test.UserTeamMaintainerTeam1, true}, + {"team observer team1", test.UserTeamObserverTeam1, true}, + {"team observer+ team1", test.UserTeamObserverPlusTeam1, true}, + } + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + ctx := test.UserContext(t.Context(), c.user) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + checkAuthErr(t, c.shouldFailAuth, err) + }) + } + }) + + t.Run("non-iOS platform fails", func(t *testing.T) { + macHost := &fleet.Host{ID: 2, UUID: "mac-uuid", Platform: "darwin"} + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return macHost, nil + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, macHost.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "only supported for iOS and iPadOS") + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return iosHost, nil + } + }) + + t.Run("personal enrollment fails", func(t *testing.T) { + personal := "On (personal)" + host := &fleet.Host{ID: 3, UUID: "ios-personal", Platform: "ios"} + host.MDM.EnrollmentStatus = &personal + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return host, nil + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, host.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "personal device") + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return iosHost, nil + } + }) + + t.Run("manual enrollment fails", func(t *testing.T) { + manual := "On (manual)" + host := &fleet.Host{ID: 4, UUID: "ios-manual", Platform: "ios"} + host.MDM.EnrollmentStatus = &manual + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return host, nil + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, host.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "manually enrolled") + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return iosHost, nil + } + }) + + t.Run("not connected to Fleet MDM fails", func(t *testing.T) { + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return false, nil + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "doesn't have MDM turned on") + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + }) + + t.Run("missing unlock token returns error", func(t *testing.T) { + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, nil + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.Contains(t, err.Error(), "unlock token is not yet available") + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return []byte("unlock-token"), nil + } + }) + + t.Run("happy path", func(t *testing.T) { + var commanderCalled bool + commander.clearPasscodeFunc = func(ctx context.Context, host *fleet.Host, commandUUID string, unlockToken []byte) error { + commanderCalled = true + require.Equal(t, iosHost.UUID, host.UUID) + require.Equal(t, []byte("unlock-token"), unlockToken) + return nil + } + activitySvc.newActivityCalled = false + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.NoError(t, err) + require.True(t, commanderCalled) + require.True(t, activitySvc.newActivityCalled) + }) + + t.Run("ds.Host error", func(t *testing.T) { + testErr := errors.New("db error") + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return nil, testErr + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + ds.HostFunc = func(ctx context.Context, id uint) (*fleet.Host, error) { + return iosHost, nil + } + }) + + t.Run("MDM not configured returns bad request", func(t *testing.T) { + activitySvc.verifyMDMConfiguredFunc = func() error { + return fleet.ErrMDMNotConfigured + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.Contains(t, err.Error(), fleet.AppleMDMNotConfiguredMessage) + activitySvc.verifyMDMConfiguredFunc = nil + }) + + t.Run("MDM configured check returns generic error", func(t *testing.T) { + testErr := errors.New("mdm config error") + activitySvc.verifyMDMConfiguredFunc = func() error { + return testErr + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + activitySvc.verifyMDMConfiguredFunc = nil + }) + + t.Run("IsHostConnectedToFleetMDM error", func(t *testing.T) { + testErr := errors.New("connected check error") + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return false, testErr + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) { + return true, nil + } + }) + + t.Run("GetMDMAppleDeviceUnlockToken error", func(t *testing.T) { + testErr := errors.New("unlock token error") + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, testErr + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return []byte("unlock-token"), nil + } + }) + + t.Run("commander ClearPasscode error", func(t *testing.T) { + testErr := errors.New("commander error") + commander.clearPasscodeFunc = func(ctx context.Context, host *fleet.Host, commandUUID string, unlockToken []byte) error { + return testErr + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + commander.clearPasscodeFunc = nil + }) + + t.Run("NewActivity error", func(t *testing.T) { + testErr := errors.New("activity error") + activitySvc.newActivityErr = testErr + commander.clearPasscodeFunc = func(ctx context.Context, host *fleet.Host, commandUUID string, unlockToken []byte) error { + return nil + } + ctx := test.UserContext(t.Context(), test.UserAdmin) + err := svc.ClearHostPasscode(ctx, iosHost.ID) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + activitySvc.newActivityErr = nil + commander.clearPasscodeFunc = nil + }) +} + func TestCountABMTokensAuth(t *testing.T) { t.Parallel() ds := new(mock.Store) diff --git a/frontend/interfaces/host.ts b/frontend/interfaces/host.ts index bf49fdb7d78..1f670ad656e 100644 --- a/frontend/interfaces/host.ts +++ b/frontend/interfaces/host.ts @@ -166,6 +166,7 @@ export interface IHostMdmData { device_status: HostMdmDeviceStatus; pending_action: HostMdmPendingAction; connected_to_fleet?: boolean; + unlock_token_available?: boolean; } export interface IHostMaintenanceWindow { diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx index 3af1b5b1f3a..927a5e95ac9 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tests.tsx @@ -1775,4 +1775,139 @@ describe("Host Actions Dropdown", () => { }); }); }); + + describe("Clear passcode action", () => { + it("renders for an ADE-enrolled iOS host connected to Fleet MDM with unlock token available", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + isMacMdmEnabledAndConfigured: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Clear passcode")).toBeInTheDocument(); + }); + + it("renders for an ADE-enrolled iPadOS host connected to Fleet MDM with unlock token available", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + isMacMdmEnabledAndConfigured: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Clear passcode")).toBeInTheDocument(); + }); + + it("does not render for a macOS host", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + isMacMdmEnabledAndConfigured: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + expect(screen.queryByText("Clear passcode")).not.toBeInTheDocument(); + }); + + it("disables the action when unlock token is not yet available and shows tooltip on hover", async () => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier: true, + isGlobalAdmin: true, + isMacMdmEnabledAndConfigured: true, + currentUser: createMockUser(), + }, + }, + }); + + const { user } = render( + + ); + + await user.click(screen.getByText("Actions")); + + const option = screen.getByText("Clear passcode"); + expect(option).toBeInTheDocument(); + expect(option).toHaveAttribute("aria-disabled", "true"); + + await user.hover(option); + await waitFor(() => { + expect( + screen.getByText(/Clear passcode is unavailable until/i) + ).toBeInTheDocument(); + }); + }); + }); }); diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx index 4fceb8f0699..5283a6f01b8 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/HostActionsDropdown.tsx @@ -26,6 +26,7 @@ interface IHostActionsDropdownProps { isRecoveryLockPasswordEnabled?: boolean; diskEncryptionProfileStatus?: string; recoveryLockPasswordProfileStatus?: string; + unlockTokenAvailable?: boolean; } const HostActionsDropdown = ({ @@ -42,6 +43,7 @@ const HostActionsDropdown = ({ isRecoveryLockPasswordEnabled = false, diskEncryptionProfileStatus, recoveryLockPasswordProfileStatus, + unlockTokenAvailable = false, }: IHostActionsDropdownProps) => { const { isPremiumTier = false, @@ -96,6 +98,7 @@ const HostActionsDropdown = ({ isRecoveryLockPasswordEnabled, diskEncryptionProfileStatus, recoveryLockPasswordProfileStatus, + unlockTokenAvailable, }); // No options to render. Exit early diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx index 12bcda10afb..171d714c015 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostActionsDropdown/helpers.tsx @@ -69,6 +69,12 @@ const DEFAULT_OPTIONS = [ value: "unlock", disabled: false, }, + { + label: "Clear passcode", + value: "clear_passcode", + disabled: false, + premiumOnly: true, + }, { label: "Delete", disabled: false, @@ -103,6 +109,7 @@ interface IHostActionConfigOptions { isRecoveryLockPasswordEnabled: boolean; diskEncryptionProfileStatus: string | undefined; recoveryLockPasswordProfileStatus: string | undefined; + unlockTokenAvailable: boolean; } const canTransferTeam = (config: IHostActionConfigOptions) => { @@ -258,6 +265,31 @@ const canUnlock = ({ ); }; +// Clear passcode is only available for iOS and iPadOS hosts enrolled via ADE (not personal/manual). +// The option is shown (but may be disabled) for eligible hosts; it is removed entirely for ineligible ones. +const canShowClearPasscode = ({ + isPremiumTier, + hostPlatform, + isConnectedToFleetMdm, + isMacMdmEnabledAndConfigured, + isEnrolledInMdm, + hostMdmEnrollmentStatus, + isGlobalAdmin, + isGlobalMaintainer, + isTeamAdmin, + isTeamMaintainer, +}: IHostActionConfigOptions) => { + return ( + isPremiumTier && + isIPadOrIPhone(hostPlatform) && + isAutomaticDeviceEnrollment(hostMdmEnrollmentStatus) && + isConnectedToFleetMdm && + isMacMdmEnabledAndConfigured && + isEnrolledInMdm && + (isGlobalAdmin || isGlobalMaintainer || isTeamAdmin || isTeamMaintainer) + ); +}; + const canDeleteHost = (config: IHostActionConfigOptions) => { const { isGlobalAdmin, @@ -380,6 +412,10 @@ const removeUnavailableOptions = ( options = options.filter((option) => option.value !== "unlock"); } + if (!canShowClearPasscode(config)) { + options = options.filter((option) => option.value !== "clear_passcode"); + } + // TODO: refactor to filter in one pass using predefined filters specified for each of the // DEFAULT_OPTIONS. Note that as currently, structured the default is to include all options. // This is a bit confusing since we remove options instead of add options @@ -443,6 +479,7 @@ const modifyOptions = ( scriptsGloballyDisabled, diskEncryptionProfileStatus, recoveryLockPasswordProfileStatus, + unlockTokenAvailable, }: IHostActionConfigOptions ) => { const disableOptions = (optionsToDisable: IDropdownOption[]) => { @@ -552,6 +589,23 @@ const modifyOptions = ( } } + // Disable clear passcode when the unlock token has not yet been received from the device. + if (!unlockTokenAvailable) { + const clearPasscodeOption = options.find( + (option) => option.value === "clear_passcode" + ); + if (clearPasscodeOption) { + clearPasscodeOption.disabled = true; + clearPasscodeOption.tooltipContent = ( + <> + Clear passcode is unavailable until +
+ the host sends its unlock token. + + ); + } + } + disableOptions(optionsToDisable); formatTurnOffOptionLabel(options, hostPlatform); return options; diff --git a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx index da327430be3..6e2f109b916 100644 --- a/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx +++ b/frontend/pages/hosts/details/HostDetailsPage/HostDetailsPage.tsx @@ -130,6 +130,7 @@ import SelectQueryModal from "./modals/SelectQueryModal"; import HostDetailsBanners from "./components/HostDetailsBanners"; import LockModal from "./modals/LockModal"; import UnlockModal from "./modals/UnlockModal"; +import ClearPasscodeModal from "./modals/ClearPasscodeModal"; import { HostMdmDeviceStatusUIState, getHostDeviceStatusUIState, @@ -236,6 +237,7 @@ const HostDetailsPage = ({ const [showLockHostModal, setShowLockHostModal] = useState(false); const [showUnlockHostModal, setShowUnlockHostModal] = useState(false); const [showWipeModal, setShowWipeModal] = useState(false); + const [showClearPasscodeModal, setShowClearPasscodeModal] = useState(false); const [showUpdateEndUserModal, setShowUpdateEndUserModal] = useState(false); // Undefined used to return to true after closing the lock modal const [showLocationModal, setShowLocationModal] = useState< @@ -969,6 +971,9 @@ const HostDetailsPage = ({ case "wipe": setShowWipeModal(true); break; + case "clear_passcode": + setShowClearPasscodeModal(true); + break; default: // do nothing } }; @@ -1017,6 +1022,7 @@ const HostDetailsPage = ({ recoveryLockPasswordProfileStatus={ host.mdm.os_settings?.recovery_lock_password?.status } + unlockTokenAvailable={host.mdm.unlock_token_available} /> ); }; @@ -1709,6 +1715,14 @@ const HostDetailsPage = ({ onClose={() => setShowWipeModal(false)} /> )} + {showClearPasscodeModal && ( + setShowClearPasscodeModal(false)} + onClose={() => setShowClearPasscodeModal(false)} + /> + )} {selectedHostSWForInventoryVersions && ( { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("renders description and confirmation text", () => { + const render = createCustomRenderer({ withBackendMock: true }); + render(); + + expect( + screen.getByText( + /Clearing the passcode allows the user to set a new passcode on the device./i + ) + ).toBeInTheDocument(); + expect( + screen.getByText(/I wish to clear the passcode on/i) + ).toBeInTheDocument(); + expect(screen.getByText(/iphone-host-1/i)).toBeInTheDocument(); + }); + + it("disables Clear passcode button until confirm checkbox is checked", async () => { + const render = createCustomRenderer({ withBackendMock: true }); + const { user } = render(); + + const clearButton = screen.getByRole("button", { name: /Clear passcode/i }); + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + + expect(clearButton).toBeDisabled(); + expect(cancelButton).toBeEnabled(); + + const checkbox = screen.getByRole("checkbox", { + name: /iphone-host-1/i, + }); + + await user.click(checkbox); + + expect(clearButton).toBeEnabled(); + }); + + it("calls onClose when Cancel is clicked", async () => { + const render = createCustomRenderer({ withBackendMock: true }); + const { user } = render(); + + const cancelButton = screen.getByRole("button", { name: "Cancel" }); + await user.click(cancelButton); + + expect(MOCK_PROPS.onClose).toHaveBeenCalled(); + }); + + it("calls onSuccess and shows success flash when API call succeeds", async () => { + mockServer.use(http.post(clearPasscodeUrl, () => HttpResponse.json({}))); + + const render = createCustomRenderer({ withBackendMock: true }); + const { user } = render(); + + const checkbox = screen.getByRole("checkbox", { name: /iphone-host-1/i }); + await user.click(checkbox); + + const clearButton = screen.getByRole("button", { name: /Clear passcode/i }); + await user.click(clearButton); + + await waitFor(() => { + expect(MOCK_PROPS.onSuccess).toHaveBeenCalled(); + }); + }); + + it("shows error flash when API call fails", async () => { + mockServer.use( + http.post(clearPasscodeUrl, () => + HttpResponse.json( + { message: "unlock token unavailable" }, + { status: 422 } + ) + ) + ); + + const render = createCustomRenderer({ withBackendMock: true }); + const { user } = render(); + + const checkbox = screen.getByRole("checkbox", { name: /iphone-host-1/i }); + await user.click(checkbox); + + const clearButton = screen.getByRole("button", { name: /Clear passcode/i }); + await user.click(clearButton); + + await waitFor(() => { + expect(MOCK_PROPS.onSuccess).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/ClearPasscodeModal.tsx b/frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/ClearPasscodeModal.tsx new file mode 100644 index 00000000000..a5e02f93f3f --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/ClearPasscodeModal.tsx @@ -0,0 +1,82 @@ +import React, { useContext } from "react"; + +import { NotificationContext } from "context/notification"; +import { getErrorReason } from "interfaces/errors"; +import hostAPI from "services/entities/hosts"; + +import Modal from "components/Modal"; +import Button from "components/buttons/Button"; +import Checkbox from "components/forms/fields/Checkbox"; + +const baseClass = "clear-passcode-modal"; + +interface IClearPasscodeModalProps { + id: number; + hostName: string; + onSuccess: () => void; + onClose: () => void; +} + +const ClearPasscodeModal = ({ + id, + hostName, + onSuccess, + onClose, +}: IClearPasscodeModalProps) => { + const { renderFlash } = useContext(NotificationContext); + const [clearChecked, setClearChecked] = React.useState(false); + const [isClearing, setIsClearing] = React.useState(false); + + const onClearPasscode = async () => { + setIsClearing(true); + try { + await hostAPI.clearPasscode(id); + onSuccess(); + renderFlash("success", "Passcode cleared."); + } catch (e) { + renderFlash("error", getErrorReason(e)); + } + setIsClearing(false); + }; + + return ( + +
+
+

+ Clearing the passcode allows the user to set a new passcode on the + device. +

+
+
+ + Confirm: + + setClearChecked(value)} + > + I wish to clear the passcode on {hostName} + +
+
+
+ + +
+
+ ); +}; + +export default ClearPasscodeModal; diff --git a/frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/index.ts b/frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/index.ts new file mode 100644 index 00000000000..f3697966af9 --- /dev/null +++ b/frontend/pages/hosts/details/HostDetailsPage/modals/ClearPasscodeModal/index.ts @@ -0,0 +1 @@ +export { default } from "./ClearPasscodeModal"; diff --git a/frontend/services/entities/hosts.ts b/frontend/services/entities/hosts.ts index b46175fbffe..1531b03a778 100644 --- a/frontend/services/entities/hosts.ts +++ b/frontend/services/entities/hosts.ts @@ -634,6 +634,11 @@ export default { return sendRequest("POST", HOST_WIPE(id)); }, + clearPasscode: (id: number): Promise => { + const { HOST_CLEAR_PASSCODE } = endpoints; + return sendRequest("POST", HOST_CLEAR_PASSCODE(id)); + }, + resendProfile: (hostId: number, profileUUID: string): Promise => { const { HOST_RESEND_PROFILE } = endpoints; diff --git a/frontend/utilities/endpoints.ts b/frontend/utilities/endpoints.ts index 8e7b5065804..f75f6287d85 100644 --- a/frontend/utilities/endpoints.ts +++ b/frontend/utilities/endpoints.ts @@ -85,6 +85,8 @@ export default { 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`, + HOST_CLEAR_PASSCODE: (id: number) => + `/${API_VERSION}/fleet/hosts/${id}/clear_passcode`, HOST_RESEND_PROFILE: (hostId: number, profileUUID: string) => `/${API_VERSION}/fleet/hosts/${hostId}/configuration_profiles/${profileUUID}/resend`, HOST_SOFTWARE: (id: number) => `/${API_VERSION}/fleet/hosts/${id}/software`, diff --git a/server/datastore/mysql/apple_mdm.go b/server/datastore/mysql/apple_mdm.go index 02e1454d12c..8c187824e3f 100644 --- a/server/datastore/mysql/apple_mdm.go +++ b/server/datastore/mysql/apple_mdm.go @@ -7240,6 +7240,20 @@ func (ds *Datastore) DeleteHostLocationData(ctx context.Context, hostID uint) er return ctxerr.Wrap(ctx, err, "delete host location data") } +// GetMDMAppleDeviceUnlockToken returns the unlock token stored in nano_devices for the given host UUID. +// Returns nil if no token has been received yet. +func (ds *Datastore) GetMDMAppleDeviceUnlockToken(ctx context.Context, hostUUID string) ([]byte, error) { + var token []byte + err := sqlx.GetContext(ctx, ds.reader(ctx), &token, `SELECT unlock_token FROM nano_devices WHERE id = ? AND unlock_token IS NOT NULL`, hostUUID) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, ctxerr.Wrap(ctx, err, "get MDM Apple device unlock token") + } + return token, nil +} + /////////////////////////////////////////////////////////////////////////////// // Apple MDM Recovery Lock Password diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index 6e8fb96c04e..821006c20b7 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -277,6 +277,13 @@ func (s *NanoMDMStorage) EnqueueDeviceWipeCommand(ctx context.Context, host *fle }, s.logger) } +// EnqueueDeviceClearPasscodeCommand enqueues a ClearPasscode command for the given host. +func (s *NanoMDMStorage) EnqueueDeviceClearPasscodeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + return common_mysql.WithRetryTxx(ctx, s.db, func(tx sqlx.ExtContext) error { + return enqueueCommandDB(ctx, tx, []string{host.UUID}, cmd) + }, s.logger) +} + func (s *NanoMDMStorage) GetAllMDMConfigAssetsByName(ctx context.Context, assetNames []fleet.MDMAssetName, queryerContext sqlx.QueryerContext, ) (map[fleet.MDMAssetName]fleet.MDMConfigAsset, error) { diff --git a/server/fleet/activities.go b/server/fleet/activities.go index 451132b72c6..ae061e64362 100644 --- a/server/fleet/activities.go +++ b/server/fleet/activities.go @@ -155,6 +155,7 @@ var ActivityDetailsList = []ActivityDetails{ ActivityTypeLockedHost{}, ActivityTypeUnlockedHost{}, ActivityTypeWipedHost{}, + ActivityTypeClearedPasscode{}, ActivityTypeCreatedDeclarationProfile{}, ActivityTypeDeletedDeclarationProfile{}, @@ -984,6 +985,19 @@ func (a ActivityTypeWipedHost) HostIDs() []uint { return []uint{a.HostID} } +type ActivityTypeClearedPasscode struct { + HostID uint `json:"host_id"` + HostDisplayName string `json:"host_display_name"` +} + +func (a ActivityTypeClearedPasscode) ActivityName() string { + return "cleared_passcode" +} + +func (a ActivityTypeClearedPasscode) HostIDs() []uint { + return []uint{a.HostID} +} + type ActivityTypeCreatedDeclarationProfile struct { ProfileName string `json:"profile_name"` Identifier string `json:"identifier"` diff --git a/server/fleet/apple_mdm.go b/server/fleet/apple_mdm.go index 1b04aaec3ac..60f3f92b01b 100644 --- a/server/fleet/apple_mdm.go +++ b/server/fleet/apple_mdm.go @@ -27,6 +27,7 @@ type MDMAppleCommandIssuer interface { EraseDevice(ctx context.Context, host *Host, uuid string) error InstallEnterpriseApplication(ctx context.Context, hostUUIDs []string, uuid string, manifestURL string) error DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error + ClearPasscode(ctx context.Context, host *Host, commandUUID string, unlockToken []byte) error SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index ac86ce472f9..1766e66e187 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1323,6 +1323,10 @@ type Datastore interface { // ListMDMAppleEnrollmentProfiles returns the list of all the enrollment profiles. ListMDMAppleEnrollmentProfiles(ctx context.Context) ([]*MDMAppleEnrollmentProfile, error) + // GetMDMAppleDeviceUnlockToken returns the unlock token for the given host UUID + // as stored in nano_devices. Returns nil if no token has been received yet. + GetMDMAppleDeviceUnlockToken(ctx context.Context, hostUUID string) ([]byte, error) + // GetMDMAppleCommandResults returns the execution results of a command identified by a // CommandUUID. If a hostUUID is provided, it filters the results for that host. GetMDMAppleCommandResults(ctx context.Context, commandUUID string, hostUUID string) ([]*MDMCommandResult, error) @@ -2838,6 +2842,7 @@ type MDMAppleStore interface { EnqueueDeviceLockCommand(ctx context.Context, host *Host, cmd *mdm.Command, pin string) error EnqueueDeviceUnlockCommand(ctx context.Context, host *Host, cmd *mdm.Command) error EnqueueDeviceWipeCommand(ctx context.Context, host *Host, cmd *mdm.Command) error + EnqueueDeviceClearPasscodeCommand(ctx context.Context, host *Host, cmd *mdm.Command) error } type MDMAssetRetriever interface { diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index cf075f99d23..32533efd9e9 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -590,6 +590,11 @@ type MDMHostData struct { // with this Fleet instance. This boolean is not filled by all // host-returning methods. ConnectedToFleet *bool `json:"connected_to_fleet" csv:"-" db:"connected_to_fleet"` + + // UnlockTokenAvailable indicates whether an MDM unlock token has been + // received from this host, which is required to send a ClearPasscode command. + // Only relevant for iOS and iPadOS hosts. Not filled by all host-returning methods. + UnlockTokenAvailable bool `json:"unlock_token_available" db:"-" csv:"-"` } type HostMDMOSSettings struct { diff --git a/server/fleet/service.go b/server/fleet/service.go index 3923821731e..88398e3b817 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1311,6 +1311,7 @@ type Service interface { LockHost(ctx context.Context, hostID uint, viewPIN bool) (unlockPIN string, err error) UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error) WipeHost(ctx context.Context, hostID uint, metadata *MDMWipeMetadata) error + ClearHostPasscode(ctx context.Context, hostID uint) error /////////////////////////////////////////////////////////////////////////////// // Software installers diff --git a/server/mdm/apple/commander.go b/server/mdm/apple/commander.go index 69a86854dd6..16e24158a2b 100644 --- a/server/mdm/apple/commander.go +++ b/server/mdm/apple/commander.go @@ -238,6 +238,39 @@ func (svc *MDMAppleCommander) DisableLostMode(ctx context.Context, host *fleet.H return nil } +func (svc *MDMAppleCommander) ClearPasscode(ctx context.Context, host *fleet.Host, commandUUID string, unlockToken []byte) error { + raw := fmt.Sprintf(` + + + + CommandUUID + %s + Command + + RequestType + ClearPasscode + UnlockToken + %s + + +`, commandUUID, base64.StdEncoding.EncodeToString(unlockToken)) + + cmd, err := mdm.DecodeCommand([]byte(raw)) + if err != nil { + return ctxerr.Wrap(ctx, err, "decoding command for ClearPasscode") + } + + if err := svc.storage.EnqueueDeviceClearPasscodeCommand(ctx, host, cmd); err != nil { + return ctxerr.Wrap(ctx, err, "enqueuing device clear passcode command") + } + + if err := svc.SendNotifications(ctx, []string{host.UUID}); err != nil { + return ctxerr.Wrap(ctx, err, "sending notifications for ClearPasscode") + } + + return nil +} + func (svc *MDMAppleCommander) EraseDevice(ctx context.Context, host *fleet.Host, uuid string) error { pin, err := GenerateRandomPin(6) if err != nil { diff --git a/server/mdm/apple/commander_test.go b/server/mdm/apple/commander_test.go index 28bb32f4a6c..b44a02676b4 100644 --- a/server/mdm/apple/commander_test.go +++ b/server/mdm/apple/commander_test.go @@ -214,6 +214,79 @@ func TestMDMAppleCommander(t *testing.T) { mdmStorage.EnqueueDeviceWipeCommandFuncInvoked = false require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) mdmStorage.RetrievePushInfoFuncInvoked = false + + cmdUUID = uuid.New().String() + unlockToken := []byte("test-unlock-token") + mdmStorage.EnqueueDeviceClearPasscodeCommandFunc = 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, "ClearPasscode", cmd.Command.RequestType) + require.Contains(t, string(cmd.Raw), cmdUUID) + return nil + } + err = cmdr.ClearPasscode(ctx, host, cmdUUID, unlockToken) + require.NoError(t, err) + require.True(t, mdmStorage.EnqueueDeviceClearPasscodeCommandFuncInvoked) + mdmStorage.EnqueueDeviceClearPasscodeCommandFuncInvoked = false + require.True(t, mdmStorage.RetrievePushInfoFuncInvoked) + mdmStorage.RetrievePushInfoFuncInvoked = false +} + +func TestMDMAppleCommanderClearPasscodeErrors(t *testing.T) { + ctx := context.Background() + mdmStorage := &mdmmock.MDMAppleStore{} + pushFactory, mockPushProvider := newMockAPNSPushProviderFactory() + pusher := nanomdm_pushsvc.New( + mdmStorage, + mdmStorage, + pushFactory, + stdlogfmt.New(), + ) + cmdr := NewMDMAppleCommander(mdmStorage, pusher) + + host := &fleet.Host{ID: 1, UUID: "TEST-HOST", Platform: "ios"} + cmdUUID := uuid.New().String() + unlockToken := []byte("test-unlock-token") + + mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, ids []string) (map[string]*mdm.Push, error) { + return map[string]*mdm.Push{ + host.UUID: { + PushMagic: "push-magic", + Token: []byte("token"), + Topic: "topic", + }, + }, nil + } + mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) { + cert, err := tls.LoadX509KeyPair("../../service/testdata/server.pem", "../../service/testdata/server.key") + return &cert, "", err + } + mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) { + return false, nil + } + + t.Run("EnqueueDeviceClearPasscodeCommand error", func(t *testing.T) { + testErr := errors.New("enqueue error") + mdmStorage.EnqueueDeviceClearPasscodeCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command) error { + return testErr + } + err := cmdr.ClearPasscode(ctx, host, cmdUUID, unlockToken) + require.Error(t, err) + require.ErrorIs(t, err, testErr) + }) + + t.Run("SendNotifications error", func(t *testing.T) { + mdmStorage.EnqueueDeviceClearPasscodeCommandFunc = func(ctx context.Context, gotHost *fleet.Host, cmd *mdm.Command) error { + return nil + } + mockPushProvider.PushFunc = func(_ context.Context, _ []*mdm.Push) (map[string]*push.Response, error) { + return nil, errors.New("push error") + } + err := cmdr.ClearPasscode(ctx, host, cmdUUID, unlockToken) + require.Error(t, err) + mockPushProvider.PushFunc = mockSuccessfulPush + }) } func TestMDMAppleCommanderConcurrentDeviceLock(t *testing.T) { diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index fb4558dee8d..58fe30de616 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -939,6 +939,8 @@ type GetMDMAppleEnrollmentProfileByTypeFunc func(ctx context.Context, typ fleet. type ListMDMAppleEnrollmentProfilesFunc func(ctx context.Context) ([]*fleet.MDMAppleEnrollmentProfile, error) +type GetMDMAppleDeviceUnlockTokenFunc func(ctx context.Context, hostUUID string) ([]byte, error) + type GetMDMAppleCommandResultsFunc func(ctx context.Context, commandUUID string, hostUUID string) ([]*fleet.MDMCommandResult, error) type GetVPPCommandResultsFunc func(ctx context.Context, commandUUID string, hostUUID string) ([]*fleet.MDMCommandResult, error) @@ -3180,6 +3182,9 @@ type DataStore struct { ListMDMAppleEnrollmentProfilesFunc ListMDMAppleEnrollmentProfilesFunc ListMDMAppleEnrollmentProfilesFuncInvoked bool + GetMDMAppleDeviceUnlockTokenFunc GetMDMAppleDeviceUnlockTokenFunc + GetMDMAppleDeviceUnlockTokenFuncInvoked bool + GetMDMAppleCommandResultsFunc GetMDMAppleCommandResultsFunc GetMDMAppleCommandResultsFuncInvoked bool @@ -7688,6 +7693,13 @@ func (s *DataStore) ListMDMAppleEnrollmentProfiles(ctx context.Context) ([]*flee return s.ListMDMAppleEnrollmentProfilesFunc(ctx) } +func (s *DataStore) GetMDMAppleDeviceUnlockToken(ctx context.Context, hostUUID string) ([]byte, error) { + s.mu.Lock() + s.GetMDMAppleDeviceUnlockTokenFuncInvoked = true + s.mu.Unlock() + return s.GetMDMAppleDeviceUnlockTokenFunc(ctx, hostUUID) +} + func (s *DataStore) GetMDMAppleCommandResults(ctx context.Context, commandUUID string, hostUUID string) ([]*fleet.MDMCommandResult, error) { s.mu.Lock() s.GetMDMAppleCommandResultsFuncInvoked = true diff --git a/server/mock/mdm/datastore_mdm_mock.go b/server/mock/mdm/datastore_mdm_mock.go index e196d60dd1f..d71feba0f54 100644 --- a/server/mock/mdm/datastore_mdm_mock.go +++ b/server/mock/mdm/datastore_mdm_mock.go @@ -77,6 +77,8 @@ type EnqueueDeviceUnlockCommandFunc func(ctx context.Context, host *fleet.Host, type EnqueueDeviceWipeCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error +type EnqueueDeviceClearPasscodeCommandFunc func(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error + type MDMAppleStore struct { StoreAuthenticateFunc StoreAuthenticateFunc StoreAuthenticateFuncInvoked bool @@ -171,6 +173,9 @@ type MDMAppleStore struct { EnqueueDeviceWipeCommandFunc EnqueueDeviceWipeCommandFunc EnqueueDeviceWipeCommandFuncInvoked bool + EnqueueDeviceClearPasscodeCommandFunc EnqueueDeviceClearPasscodeCommandFunc + EnqueueDeviceClearPasscodeCommandFuncInvoked bool + mu sync.Mutex } @@ -390,3 +395,10 @@ func (fs *MDMAppleStore) EnqueueDeviceWipeCommand(ctx context.Context, host *fle fs.mu.Unlock() return fs.EnqueueDeviceWipeCommandFunc(ctx, host, cmd) } + +func (fs *MDMAppleStore) EnqueueDeviceClearPasscodeCommand(ctx context.Context, host *fleet.Host, cmd *mdm.Command) error { + fs.mu.Lock() + fs.EnqueueDeviceClearPasscodeCommandFuncInvoked = true + fs.mu.Unlock() + return fs.EnqueueDeviceClearPasscodeCommandFunc(ctx, host, cmd) +} diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 46c61786a0a..f9e397eadaa 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -799,6 +799,8 @@ type UnlockHostFunc func(ctx context.Context, hostID uint) (unlockPIN string, er type WipeHostFunc func(ctx context.Context, hostID uint, metadata *fleet.MDMWipeMetadata) error +type ClearHostPasscodeFunc func(ctx context.Context, hostID uint) error + type UploadSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) type UpdateSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UpdateSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) @@ -2062,6 +2064,9 @@ type Service struct { WipeHostFunc WipeHostFunc WipeHostFuncInvoked bool + ClearHostPasscodeFunc ClearHostPasscodeFunc + ClearHostPasscodeFuncInvoked bool + UploadSoftwareInstallerFunc UploadSoftwareInstallerFunc UploadSoftwareInstallerFuncInvoked bool @@ -4933,6 +4938,13 @@ func (s *Service) WipeHost(ctx context.Context, hostID uint, metadata *fleet.MDM return s.WipeHostFunc(ctx, hostID, metadata) } +func (s *Service) ClearHostPasscode(ctx context.Context, hostID uint) error { + s.mu.Lock() + s.ClearHostPasscodeFuncInvoked = true + s.mu.Unlock() + return s.ClearHostPasscodeFunc(ctx, hostID) +} + func (s *Service) UploadSoftwareInstaller(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error) { s.mu.Lock() s.UploadSoftwareInstallerFuncInvoked = true diff --git a/server/service/client_mdm.go b/server/service/client_mdm.go index 2ec375b3586..14ae717bed4 100644 --- a/server/service/client_mdm.go +++ b/server/service/client_mdm.go @@ -419,6 +419,14 @@ func (c *Client) MDMWipeHost(hostID uint) error { return nil } +func (c *Client) MDMClearHostPasscode(hostID uint) error { + var response clearHostPasscodeResponse + if err := c.authenticatedRequest(nil, "POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/clear_passcode", hostID), &response); err != nil { + return fmt.Errorf("clear host passcode request: %w", err) + } + return nil +} + type eulaContent struct { Bytes []byte } diff --git a/server/service/devices_endpoint_test.go b/server/service/devices_endpoint_test.go index d15a85083d9..7b59de3d7d5 100644 --- a/server/service/devices_endpoint_test.go +++ b/server/service/devices_endpoint_test.go @@ -87,6 +87,9 @@ func TestGetDeviceHostEndpointScrubbing(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, nil + } ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } diff --git a/server/service/handler.go b/server/service/handler.go index dd3cb819438..98b0986c4ee 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -563,6 +563,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC 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{}) + ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/clear_passcode", clearHostPasscodeEndpoint, clearHostPasscodeRequest{}) // Generative AI ue.POST("/api/_version_/fleet/autofill/policy", autofillPoliciesEndpoint, autofillPoliciesRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 9868df7d0e7..a2eb45eca4f 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -1812,6 +1812,14 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f host.MDM.DeviceStatus = ptr.String(string(mdmActions.DeviceStatus())) host.MDM.PendingAction = ptr.String(string(mdmActions.PendingAction())) + if host.FleetPlatform() == "ios" || host.FleetPlatform() == "ipados" { + unlockToken, err := svc.ds.GetMDMAppleDeviceUnlockToken(ctx, host.UUID) + if err != nil { + return nil, ctxerr.Wrap(ctx, err, "get device unlock token for host details") + } + host.MDM.UnlockTokenAvailable = len(unlockToken) > 0 + } + host.Policies = policies endUsers, err := fleet.GetEndUsers(ctx, svc.ds, host.ID) diff --git a/server/service/hosts_test.go b/server/service/hosts_test.go index 4bafa64eaf3..17add546456 100644 --- a/server/service/hosts_test.go +++ b/server/service/hosts_test.go @@ -85,6 +85,9 @@ func TestHostDetails(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, nil + } ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } @@ -143,6 +146,9 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, nil + } ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } @@ -435,6 +441,9 @@ func TestHostDetailsMDMTimestamps(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, nil + } ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } @@ -3637,6 +3646,9 @@ func TestGetHostDetailsExcludeSoftwareFlag(t *testing.T) { ds.GetHostLockWipeStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostLockWipeStatus, error) { return &fleet.HostLockWipeStatus{}, nil } + ds.GetMDMAppleDeviceUnlockTokenFunc = func(ctx context.Context, hostUUID string) ([]byte, error) { + return nil, nil + } ds.ScimUserByHostIDFunc = func(ctx context.Context, hostID uint) (*fleet.ScimUser, error) { return nil, nil } diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 220fb2b2576..8864314293f 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -7555,10 +7555,11 @@ func (s *integrationTestSuite) TestPremiumEndpointsWithoutLicense() { listSoftwareRequest{fleet.SoftwareListOptions{VulnerableOnly: true, KnownExploit: true}}, http.StatusPaymentRequired, &countResp, ) - // lock/unlock/wipe a host + // lock/unlock/wipe/clear_passcode 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) + s.Do("POST", "/api/v1/fleet/hosts/123/clear_passcode", nil, http.StatusPaymentRequired) // try to update the enable_release_device_manually setting, requires premium. s.Do("PATCH", "/api/v1/fleet/setup_experience", fleet.MDMAppleSetupPayload{EnableReleaseDeviceManually: ptr.Bool(true)}, http.StatusPaymentRequired) diff --git a/server/service/scripts.go b/server/service/scripts.go index 5d4e78e49f7..1e9d5137ee6 100644 --- a/server/service/scripts.go +++ b/server/service/scripts.go @@ -1685,3 +1685,33 @@ func (svc *Service) WipeHost(ctx context.Context, _ uint, _ *fleet.MDMWipeMetada return fleet.ErrMissingLicense } + +//////////////////////////////////////////////////////////////////////////////// +// Clear host passcode +//////////////////////////////////////////////////////////////////////////////// + +type clearHostPasscodeRequest struct { + HostID uint `url:"id"` +} + +type clearHostPasscodeResponse struct { + Err error `json:"error,omitempty"` +} + +func (r clearHostPasscodeResponse) Error() error { return r.Err } + +func clearHostPasscodeEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { + req := request.(*clearHostPasscodeRequest) + if err := svc.ClearHostPasscode(ctx, req.HostID); err != nil { + return clearHostPasscodeResponse{Err: err}, nil + } + return clearHostPasscodeResponse{}, nil +} + +func (svc *Service) ClearHostPasscode(ctx context.Context, _ uint) error { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return fleet.ErrMissingLicense +}