Implement clear passcode backend (#43072)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #42368 

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information. For the overall story

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
This commit is contained in:
Magnus Jensen 2026-04-07 15:23:59 -05:00 committed by GitHub
parent 3371b48373
commit 6a9d394e62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 702 additions and 70 deletions

View file

@ -0,0 +1 @@
* Implemented Clear Passcode feature for iOS and iPadOS.

View file

@ -263,8 +263,8 @@ func TestMDMRunCommand(t *testing.T) {
}
return res, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
@ -1401,8 +1401,8 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[
ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) {
return nil, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
h, ok := hostsByID[hostID]

View file

@ -1648,3 +1648,85 @@ func (svc *Service) decryptUploadedABMToken(ctx context.Context, token io.Reader
}
return encryptedToken, decryptedToken, nil
}
func (svc *Service) ClearPasscode(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionRead); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "host lite")
}
if err := svc.authz.Authorize(ctx, fleet.MDMCommandAuthz{TeamID: host.TeamID}, fleet.ActionWrite); err != nil {
return nil, err
}
appCfg, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get app config")
}
if fleet.IsApplePlatform(host.Platform) {
return svc.clearPasscodeApple(ctx, host, appCfg)
}
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: "Clearing passcode is only supported on Apple mobile platforms",
})
}
func (svc *Service) clearPasscodeApple(ctx context.Context, host *fleet.Host, appCfg *fleet.AppConfig) (*fleet.CommandEnqueueResult, error) {
if !appCfg.MDM.EnabledAndConfigured {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: "Apple MDM is not enabled and configured",
})
}
if !fleet.IsAppleMobilePlatform(host.Platform) {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: "Clearing passcode is only supported on Apple mobile platforms",
})
}
mdmData, err := svc.ds.GetHostMDM(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host MDM data")
}
if mdmData.IsPersonalEnrollment {
return nil, &fleet.BadRequestError{
Message: fleet.CantClearPasscodePersonalHostsMessage,
}
}
nanoDetails, err := svc.ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get nanomdm enrollment details")
}
if nanoDetails == nil || nanoDetails.UnlockToken == nil {
return nil, &fleet.BadRequestError{
Message: fleet.CantClearPasscodePersonalHostsMessage,
}
}
commandUUID := uuid.NewString()
if err := svc.mdmAppleCommander.ClearPasscode(ctx, []string{host.UUID}, commandUUID); err != nil {
return nil, ctxerr.Wrap(ctx, err, "clearing passcode")
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), fleet.ActivityTypeClearedPasscode{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
}); err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating activity for cleared passcode")
}
return &fleet.CommandEnqueueResult{
CommandUUID: commandUUID,
RequestType: fleet.AppleMDMCommandTypeClearPasscode,
Platform: host.Platform,
}, nil
}

View file

@ -2,6 +2,7 @@ package service
import (
"context"
"crypto/tls"
"errors"
"strings"
"testing"
@ -9,11 +10,20 @@ import (
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
nanomdm_mdm "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
"github.com/fleetdm/fleet/v4/server/mock"
mdmmock "github.com/fleetdm/fleet/v4/server/mock/mdm"
mocksvc "github.com/fleetdm/fleet/v4/server/mock/service"
"github.com/fleetdm/fleet/v4/server/ptr"
svcmock "github.com/fleetdm/fleet/v4/server/service/mock"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanolib/log/stdlogfmt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"howett.net/plist"
@ -218,3 +228,217 @@ func TestCountABMTokensAuth(t *testing.T) {
}
})
}
func TestClearPasscode(t *testing.T) {
t.Parallel()
ds := new(mock.Store)
authorizer, err := authz.NewAuthorizer()
require.NoError(t, err)
// Set up the real commander with mocked storage and pusher.
mdmStorage := &mdmmock.MDMAppleStore{}
pushProvider := &svcmock.APNSPushProvider{}
pushProvider.PushFunc = func(_ context.Context, pushes []*nanomdm_mdm.Push) (map[string]*nanomdm_push.Response, error) {
res := make(map[string]*nanomdm_push.Response, len(pushes))
for _, p := range pushes {
res[p.Token.String()] = &nanomdm_push.Response{Id: "ok"}
}
return res, nil
}
pushFactory := &svcmock.APNSPushProviderFactory{}
pushFactory.NewPushProviderFunc = func(*tls.Certificate) (nanomdm_push.PushProvider, error) {
return pushProvider, nil
}
pusher := nanomdm_pushsvc.New(mdmStorage, mdmStorage, pushFactory, stdlogfmt.New())
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
svc := Service{ds: ds, authz: authorizer, mdmAppleCommander: commander, Service: &mocksvc.Service{
NewActivityFunc: func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
return nil
},
}}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
// Common mdmStorage mocks for enqueue + push.
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) {
return nil, nil
}
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targets []string) (map[string]*nanomdm_mdm.Push, error) {
pushes := make(map[string]*nanomdm_mdm.Push, len(targets))
for _, uuid := range targets {
pushes[uuid] = &nanomdm_mdm.Push{
PushMagic: "magic" + uuid,
Token: []byte("token" + uuid),
Topic: "topic" + uuid,
}
}
return pushes, nil
}
mdmStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
cert, err := tls.LoadX509KeyPair("../../../server/service/testdata/server.pem", "../../../server/service/testdata/server.key")
return &cert, "", err
}
mdmStorage.IsPushCertStaleFunc = func(ctx context.Context, topic string, staleToken string) (bool, error) {
return false, nil
}
t.Run("authorization", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{UnlockToken: new("fake-token")}, nil
}
cases := []struct {
desc string
user *fleet.User
shoudFailWithAuth bool
}{
{"no role", test.UserNoRoles, true},
{"observer", test.UserObserver, true},
{"observer+", test.UserObserverPlus, true},
{"technician", test.UserTechnician, true},
{"gitops", test.UserGitOps, true},
{"maintainer", test.UserMaintainer, false},
{"admin", test.UserAdmin, false},
}
for _, c := range cases {
t.Run(c.desc, func(t *testing.T) {
ctx := test.UserContext(t.Context(), c.user)
_, err := svc.ClearPasscode(ctx, 1)
checkAuthErr(t, c.shoudFailWithAuth, err)
})
}
})
t.Run("happy path ipados", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, UUID: "host-uuid-1", Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
mdmStorage.EnqueueCommandFuncInvoked = false
})
t.Run("happy path ios", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, UUID: "host-uuid-2", Platform: "ios"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
mdmStorage.EnqueueCommandFuncInvoked = false
})
t.Run("non-apple platform", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "windows"}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "only supported on Apple mobile platforms")
})
t.Run("macOS not supported", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "darwin"}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "only supported on Apple mobile platforms")
})
t.Run("MDM not enabled", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "ipados"}, nil
}
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: false}}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "Apple MDM is not enabled")
// Restore for subsequent tests.
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil
}
})
t.Run("personal enrollment", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{IsPersonalEnrollment: true}, nil
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
var badReq *fleet.BadRequestError
require.ErrorAs(t, err, &badReq)
assert.Contains(t, badReq.Message, "Unlock token is not available")
})
t.Run("enqueue command error", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return &fleet.Host{ID: hostID, UUID: "host-uuid-3", Platform: "ipados"}, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
return &fleet.HostMDM{}, nil
}
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) {
return nil, errors.New("enqueue failed")
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 1)
require.Error(t, err)
assert.Contains(t, err.Error(), "enqueue failed")
// Restore for subsequent tests.
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *nanomdm_mdm.CommandWithSubtype) (map[string]error, error) {
return nil, nil
}
})
t.Run("host not found", func(t *testing.T) {
ds.HostLiteFunc = func(ctx context.Context, hostID uint) (*fleet.Host, error) {
return nil, &notFoundError{}
}
ctx := test.UserContext(t.Context(), test.UserAdmin)
_, err := svc.ClearPasscode(ctx, 999)
require.Error(t, err)
})
}

View file

@ -1017,9 +1017,11 @@ func (c *TestAppleMDMClient) Authenticate() error {
func (c *TestAppleMDMClient) TokenUpdate(awaitingConfiguration bool) error {
pushMagic := "pushmagic" + c.SerialNumber
token := []byte("token" + c.SerialNumber)
unlockToken := []byte("unlocktoken" + c.SerialNumber)
if c.SerialNumber == "" {
pushMagic = "pushmagic" + c.Identifier()
token = []byte("token" + c.Identifier())
unlockToken = []byte("unlocktoken" + c.Identifier())
}
payload := map[string]any{
"MessageType": "TokenUpdate",
@ -1028,6 +1030,7 @@ func (c *TestAppleMDMClient) TokenUpdate(awaitingConfiguration bool) error {
"NotOnConsole": "false",
"PushMagic": pushMagic,
"Token": token,
"UnlockToken": unlockToken,
}
if c.UUID != "" {
payload["UDID"] = c.UUID

View file

@ -7207,31 +7207,27 @@ LIMIT ?
return res, nil
}
func (ds *Datastore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
res := []struct {
LastMDMEnrollmentTime *time.Time `db:"authenticate_at"`
LastMDMSeenTime *time.Time `db:"last_seen_at"`
HardwareAttested bool `db:"hardware_attested"`
}{}
func (ds *Datastore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
res := []*fleet.NanoMDMEnrollmentDetails{}
// We are specifically only looking at the singular device enrollment row and not the
// potentially many user enrollment rows that will exist for a given device. The device
// enrollment row is the only one that gets regularly updated with the last seen time. Along
// those same lines authenticate_at gets updated only at the authenticate step during the
// enroll process and as such is a good indicator of the last enrollment or reenrollment.
query := `
SELECT nd.authenticate_at, ne.last_seen_at, ne.hardware_attested
SELECT nd.authenticate_at, ne.last_seen_at, ne.hardware_attested, nd.unlock_token
FROM nano_devices nd
INNER JOIN nano_enrollments ne ON ne.id = nd.id
WHERE ne.type IN ('Device', 'User Enrollment (Device)') AND nd.id = ?`
err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, query, hostUUID)
if err == sql.ErrNoRows || len(res) == 0 {
return nil, nil, false, nil
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
if err != nil {
return nil, nil, false, ctxerr.Wrap(ctx, err, "get mdm enrollment times")
return nil, ctxerr.Wrap(ctx, err, "get mdm enrollment times")
}
return res[0].LastMDMEnrollmentTime, res[0].LastMDMSeenTime, res[0].HardwareAttested, nil
return res[0], nil
}
func (ds *Datastore) AssociateHostMDMIdPAccount(ctx context.Context, hostUUID, idpAcctUUID string) error {

View file

@ -5417,9 +5417,9 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) {
`, host.UUID)
require.NoError(t, err)
_, _, hardwareAttested, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.True(t, hardwareAttested)
require.True(t, details.HardwareAttested)
// host has no boostrap package command yet
_, err = ds.GetHostBootstrapPackageCommand(ctx, host.UUID)
@ -5472,9 +5472,9 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) {
require.Zero(t, sum.Installed)
require.Zero(t, sum.Pending)
_, _, hardwareAttested, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.False(t, hardwareAttested)
require.False(t, details.HardwareAttested)
}
func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) {
@ -9117,19 +9117,19 @@ func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.Nil(t, lastMDMEnrolledAt)
require.Nil(t, lastMDMSeenAt)
require.Nil(t, details.LastMDMEnrollmentTime)
require.Nil(t, details.LastMDMSeenTime)
// add user and device enrollment for this device. Timestamps should not be updated so nothing
// returned yet
nanoEnroll(t, ds, host, true)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation
require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value
require.NotNil(t, details.LastMDMEnrollmentTime) // defaults to current time on creation
require.NotNil(t, details.LastMDMSeenTime) // defaults to time 0 value
// Add a BYOD host
byodHost, err := ds.NewHost(ctx, &fleet.Host{
@ -9143,10 +9143,10 @@ func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) {
require.NoError(t, err)
nanoEnrollUserDevice(t, ds, byodHost)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
details, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation
require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value
require.NotNil(t, details.LastMDMEnrollmentTime) // defaults to current time on creation
require.NotNil(t, details.LastMDMSeenTime) // defaults to time 0 value
authenticateTime := time.Now().Add(-1 * time.Hour).UTC().Round(time.Second)
deviceEnrollTime := time.Now().Add(-2 * time.Hour).UTC().Round(time.Second)
@ -9179,19 +9179,19 @@ func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) {
return nil
})
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt)
assert.Equal(t, authenticateTime, *lastMDMEnrolledAt)
require.NotNil(t, lastMDMSeenAt)
assert.Equal(t, deviceEnrollTime, *lastMDMSeenAt)
require.NotNil(t, details)
assert.Equal(t, authenticateTime, *details.LastMDMEnrollmentTime)
require.NotNil(t, details.LastMDMSeenTime)
assert.Equal(t, deviceEnrollTime, *details.LastMDMSeenTime)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
details, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt)
assert.Equal(t, byodDeviceAuthenticateTime, *lastMDMEnrolledAt)
require.NotNil(t, lastMDMSeenAt)
assert.Equal(t, byodDeviceEnrollTime, *lastMDMSeenAt)
require.NotNil(t, details)
assert.Equal(t, byodDeviceAuthenticateTime, *details.LastMDMEnrollmentTime)
require.NotNil(t, details.LastMDMSeenTime)
assert.Equal(t, byodDeviceEnrollTime, *details.LastMDMSeenTime)
}
func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) {
@ -9217,10 +9217,10 @@ func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.Nil(t, lastMDMEnrolledAt)
require.Nil(t, lastMDMSeenAt)
require.Nil(t, details.LastMDMEnrollmentTime)
require.Nil(t, details.LastMDMSeenTime)
userEnrollment, err = ds.GetNanoMDMUserEnrollment(ctx, host.UUID)
require.NoError(t, err)

View file

@ -3,6 +3,7 @@ package mysql
import (
"context"
"database/sql"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
@ -464,6 +465,21 @@ func (ds *Datastore) ExpandHostSecrets(ctx context.Context, document string, enr
return "", ctxerr.Wrapf(ctx, err, "getting pending recovery lock password for host %s", enrollmentID)
}
secretValues[secretType] = password
case fleet.HostSecretMDMUnlockToken:
details, err := ds.GetNanoMDMEnrollmentDetails(ctx, enrollmentID)
if err != nil {
return "", ctxerr.Wrapf(ctx, err, "getting MDM enrollment details for host %s", enrollmentID)
}
if details == nil {
return "", ctxerr.Errorf(ctx, "no MDM enrollment details found for host %s", enrollmentID)
}
if details.UnlockToken == nil {
return "", ctxerr.Errorf(ctx, "%s", fleet.CantClearPasscodePersonalHostsMessage)
}
// We need to send base64 encoded data in the <data> field.
encoded := base64.StdEncoding.EncodeToString([]byte(*details.UnlockToken))
secretValues[secretType] = encoded
default:
return "", ctxerr.Errorf(ctx, "unknown host secret type: %s", secretType)
}

View file

@ -1,6 +1,7 @@
package mysql
import (
"encoding/base64"
"encoding/json"
"sort"
"testing"
@ -8,6 +9,7 @@ import (
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -322,6 +324,37 @@ func testExpandHostSecrets(t *testing.T, ds *Datastore) {
require.NoError(t, err)
assert.Equal(t, `Password: Pass&word<with>special"chars'`, expandedNonXML)
})
t.Run("mdm unlock token expansion", func(t *testing.T) {
// Create a host with an MDM unlock token
hostMDM, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host-mdm-unlock-token-test"),
NodeKey: ptr.String("host-mdm-unlock-token-test-key"),
UUID: "host-mdm-unlock-token-test-uuid",
Hostname: "host-mdm-unlock-token-test-hostname",
Platform: "ios",
})
require.NoError(t, err)
unlockToken := "TEST-MDM-UNLOCK-TOKEN" // nolint:gosec // G101: this is a constant identifier, not a credential
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO nano_devices (id, unlock_token, authenticate, platform) VALUES (?, ?, 'fake-auth', 'ios')`, hostMDM.UUID, unlockToken)
require.NoError(t, err)
_, err = q.ExecContext(ctx, `INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex, last_seen_at) VALUES (?, ?, 'Device', 'fake-topic', 'fake-push-magic', 'fake-token-hex', NOW())`, hostMDM.UUID, hostMDM.UUID)
return err
})
b64Encoded := base64.StdEncoding.EncodeToString([]byte(unlockToken))
doc := `<string>$FLEET_HOST_SECRET_MDM_UNLOCK_TOKEN</string>`
expected := `<string>` + b64Encoded + `</string>`
expanded, err := ds.ExpandHostSecrets(ctx, doc, hostMDM.UUID)
require.NoError(t, err)
assert.Equal(t, expected, expanded)
})
}
func testCreateSecretVariable(t *testing.T, ds *Datastore) {

View file

@ -1852,3 +1852,20 @@ func (a ActivityTypeInstalledCertificate) WasFromAutomation() bool {
func (a ActivityTypeInstalledCertificate) HostOnly() bool {
return true
}
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}
}
func (a ActivityTypeClearedPasscode) HostOnly() bool {
return true
}

View file

@ -40,6 +40,7 @@ type MDMAppleCommandIssuer interface {
DeviceConfigured(ctx context.Context, hostUUID, cmdUUID string) error
SetRecoveryLock(ctx context.Context, hostUUIDs []string, cmdUUID string) error
RotateRecoveryLock(ctx context.Context, hostUUID string, cmdUUID string) error
ClearPasscode(ctx context.Context, hostUUID []string, cmdUUID string) error
}
// MDMAppleEnrollmentType is the type for Apple MDM enrollments.

View file

@ -1469,7 +1469,7 @@ type Datastore interface {
// GetNanoMDMEnrollmentDetails returns the time of the most recent enrollment, the most recent
// MDM protocol seen time, and whether the enrollment is hardware attested for the host with the given UUID
GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error)
GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*NanoMDMEnrollmentDetails, error)
// IncreasePolicyAutomationIteration marks the policy to fire automation again.
IncreasePolicyAutomationIteration(ctx context.Context, policyID uint) error

View file

@ -31,6 +31,7 @@ var (
CantTurnOffMDMForPersonalHostsMessage = "Couldn't turn off MDM. This command isn't available for personal hosts."
CantWipePersonalHostsMessage = "Couldn't wipe. This command isn't available for personal hosts."
CantLockPersonalHostsMessage = "Couldn't lock. This command isn't available for personal hosts."
CantClearPasscodePersonalHostsMessage = "Unlock token is not available for this device. Unable to issue ClearPasscode command."
CantLockManualIOSIpadOSHostsMessage = "Couldn't lock. This command isn't available for manually enrolled iOS/iPadOS hosts."
CantDisableDiskEncryptionIfPINRequiredErrMsg = "Couldn't disable disk encryption, you need to disable the BitLocker PIN requirement first."
CantEnablePINRequiredIfDiskEncryptionEnabled = "Couldn't enable BitLocker PIN requirement, you must enable disk encryption first."

View file

@ -36,6 +36,8 @@ const (
// We wrap the key in braces to make Redis hash the keys to the same slot, avoiding CrossSlot errors.
MDMProfileProcessingKeyPrefix = "{mdm_profile_processing}" // + :hostUUID
MDMProfileProcessingTTL = 1 * time.Minute // We use a low time here, to avoid letting it sit for too long in case of errors.
AppleMDMCommandTypeClearPasscode = "ClearPasscode"
)
// FleetVarName represents the name of a Fleet variable (without the FLEET_VAR_ prefix).
@ -1285,3 +1287,10 @@ type HostMDMIdentifiers struct {
Platform string `db:"platform"`
TeamID *uint `db:"team_id"`
}
type NanoMDMEnrollmentDetails struct {
LastMDMEnrollmentTime *time.Time `db:"authenticate_at"`
LastMDMSeenTime *time.Time `db:"last_seen_at"`
HardwareAttested bool `db:"hardware_attested"`
UnlockToken *string `db:"unlock_token"`
}

View file

@ -25,6 +25,10 @@ const (
// during password rotation. The pending password is stored encrypted in host_recovery_key_passwords
// (pending_encrypted_password column) and injected as the NewPassword during rotation.
HostSecretRecoveryLockPendingPassword = "RECOVERY_LOCK_PENDING_PASSWORD"
// HostSecretMDMUnlockToken is the host secret type for MDM unlock tokens.
// The token is stored in the nano_devices table and injected at delivery time for ClearPasscode commands sent to Apple MDM-enrolled hosts.
HostSecretMDMUnlockToken = "MDM_UNLOCK_TOKEN" // nolint:gosec // G101: this is a constant identifier, not a credential
)
type MissingSecretsError struct {

View file

@ -1333,6 +1333,10 @@ type Service interface {
UnlockHost(ctx context.Context, hostID uint) (unlockPIN string, err error)
WipeHost(ctx context.Context, hostID uint, metadata *MDMWipeMetadata) error
// ClearPasscode is a method that clears the passcode on a host, primarily mobile devices.
// Not script based, only MDM based.
ClearPasscode(ctx context.Context, hostID uint) (*CommandEnqueueResult, error)
// RotateRecoveryLockPassword rotates the recovery lock password for a macOS host.
// This is only available for Apple Silicon Macs that are MDM-enrolled and have
// an existing recovery lock password.

View file

@ -492,6 +492,33 @@ func (svc *MDMAppleCommander) DeviceLocation(ctx context.Context, hostUUIDs []st
return svc.EnqueueCommand(ctx, hostUUIDs, raw)
}
func (svc *MDMAppleCommander) ClearPasscode(ctx context.Context, hostUUIDs []string, cmdUUID string) error {
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">
<plist version="1.0">
<dict>
<key>Command</key>
<dict>
<key>RequestType</key>
<string>ClearPasscode</string>
<key>UnlockToken</key>
<data>%s</data>
</dict>
<key>CommandUUID</key>
<string>%s</string>
</dict>
</plist>`, "$"+fleet.HostSecretPrefix+fleet.HostSecretMDMUnlockToken, cmdUUID)
// We skip EnqueueCommand here, to avoid decoding the command as <data> is binary, which fails to decode with placeholder.
cmd := &mdm.Command{
CommandUUID: cmdUUID,
Raw: []byte(raw),
}
cmd.Command.RequestType = fleet.AppleMDMCommandTypeClearPasscode
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone)
}
// EnqueueCommand takes care of enqueuing the commands and sending push
// notifications to the devices.
//

View file

@ -623,3 +623,53 @@ func TestMDMAppleCommanderSetRecoveryLock(t *testing.T) {
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
}
func TestMDMAppleCommanderClearPasscode(t *testing.T) {
ctx := context.Background()
mdmStorage := &mdmmock.MDMAppleStore{}
pushFactory, _ := newMockAPNSPushProviderFactory()
pusher := nanomdm_pushsvc.New(
mdmStorage,
mdmStorage,
pushFactory,
stdlogfmt.New(),
)
cmdr := NewMDMAppleCommander(mdmStorage, pusher)
hostUUIDs := []string{"host-uuid-1"}
cmdUUID := uuid.New().String()
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, "ClearPasscode", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), "$"+fleet.HostSecretPrefix+fleet.HostSecretMDMUnlockToken, "Clear passcode should not use direct unlock token but rather Host secret")
require.Contains(t, string(cmd.Raw), cmdUUID)
return nil, nil
}
mdmStorage.RetrievePushInfoFunc = func(ctx context.Context, targetUUIDs []string) (map[string]*mdm.Push, error) {
require.ElementsMatch(t, hostUUIDs, targetUUIDs)
pushes := make(map[string]*mdm.Push, len(targetUUIDs))
for _, uuid := range targetUUIDs {
pushes[uuid] = &mdm.Push{
PushMagic: "magic" + uuid,
Token: []byte("token" + uuid),
Topic: "topic" + uuid,
}
}
return pushes, 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
}
err := cmdr.ClearPasscode(ctx, hostUUIDs, cmdUUID)
require.NoError(t, err)
require.True(t, mdmStorage.EnqueueCommandFuncInvoked)
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
}

View file

@ -286,11 +286,9 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
cmd.Raw = []byte(expanded)
}
// Expand host-scoped secrets for SetRecoveryLock commands.
// SetRecoveryLock is device-only, so UDID is always present and matches host UUID.
if cmd.Command.Command.RequestType == fleet.SetRecoveryLockCmdName {
expandHostSecrets := func(cmdRaw string, onError func(hostUUID string, errorMsg string)) (expanded string, didError bool) {
hostUUID := results.UDID
hostExpanded, err := s.store.ExpandHostSecrets(r.Context, string(cmd.Raw), hostUUID)
hostExpanded, err := s.store.ExpandHostSecrets(r.Context, cmdRaw, hostUUID)
if err != nil {
errorMsg := fmt.Sprintf("failed to expand host secrets: %v", err)
logger.Info("level", "error", "msg", "expanding host secrets", "err", err)
@ -308,12 +306,41 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
if storeErr := s.store.StoreCommandReport(r, failedResult); storeErr != nil {
logger.Info("level", "error", "msg", "storing failed command result", "err", storeErr)
}
onError(hostUUID, errorMsg)
return "", true
}
return hostExpanded, false
}
// Expand host-scoped secrets for SetRecoveryLock commands.
// SetRecoveryLock is device-only, so UDID is always present and matches host UUID.
if cmd.Command.Command.RequestType == fleet.SetRecoveryLockCmdName {
hostExpanded, didError := expandHostSecrets(string(cmd.Raw), func(hostUUID string, errorMsg string) {
// Mark the host's recovery lock status as failed so it's not stuck in pending.
if storeErr := s.store.SetRecoveryLockFailed(r.Context, hostUUID, errorMsg); storeErr != nil {
logger.Info("level", "error", "msg", "setting recovery lock failed", "err", storeErr)
}
})
if didError {
return nil, nil
}
cmd.Raw = []byte(hostExpanded)
}
// Expand host-scoped secrets for ClearPasscode commands.
if cmd.Command.Command.RequestType == fleet.AppleMDMCommandTypeClearPasscode {
hostExpanded, didError := expandHostSecrets(string(cmd.Raw), func(hostUUID string, errorMsg string) {
// For ClearPasscode command, if we fail to expand host secrets, it likely means the unlock token is missing or invalid.
logger.Info("level", "error", "msg", "failed to expand host secrets for ClearPasscode command", "host_uuid", hostUUID, "err", errorMsg)
})
if didError {
return nil, nil
}
cmd.Raw = []byte(hostExpanded)
}

View file

@ -1007,7 +1007,7 @@ type GetNanoMDMUserEnrollmentUsernameAndUUIDFunc func(ctx context.Context, devic
type UpdateNanoMDMUserEnrollmentUsernameFunc func(ctx context.Context, deviceID string, userUUID string, username string) error
type GetNanoMDMEnrollmentDetailsFunc func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error)
type GetNanoMDMEnrollmentDetailsFunc func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error)
type IncreasePolicyAutomationIterationFunc func(ctx context.Context, policyID uint) error
@ -8056,7 +8056,7 @@ func (s *DataStore) UpdateNanoMDMUserEnrollmentUsername(ctx context.Context, dev
return s.UpdateNanoMDMUserEnrollmentUsernameFunc(ctx, deviceID, userUUID, username)
}
func (s *DataStore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
func (s *DataStore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
s.mu.Lock()
s.GetNanoMDMEnrollmentDetailsFuncInvoked = true
s.mu.Unlock()

View file

@ -812,6 +812,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 ClearPasscodeFunc func(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error)
type RotateRecoveryLockPasswordFunc func(ctx context.Context, hostID uint) error
type UploadSoftwareInstallerFunc func(ctx context.Context, payload *fleet.UploadSoftwareInstallerPayload) (*fleet.SoftwareInstaller, error)
@ -2095,6 +2097,9 @@ type Service struct {
WipeHostFunc WipeHostFunc
WipeHostFuncInvoked bool
ClearPasscodeFunc ClearPasscodeFunc
ClearPasscodeFuncInvoked bool
RotateRecoveryLockPasswordFunc RotateRecoveryLockPasswordFunc
RotateRecoveryLockPasswordFuncInvoked bool
@ -5011,6 +5016,13 @@ func (s *Service) WipeHost(ctx context.Context, hostID uint, metadata *fleet.MDM
return s.WipeHostFunc(ctx, hostID, metadata)
}
func (s *Service) ClearPasscode(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) {
s.mu.Lock()
s.ClearPasscodeFuncInvoked = true
s.mu.Unlock()
return s.ClearPasscodeFunc(ctx, hostID)
}
func (s *Service) RotateRecoveryLockPassword(ctx context.Context, hostID uint) error {
s.mu.Lock()
s.RotateRecoveryLockPasswordFuncInvoked = true

View file

@ -199,8 +199,12 @@ func setupAppleMDMService(t *testing.T, license *fleet.LicenseInfo) (fleet.Servi
ds.GetNanoMDMEnrollmentFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoEnrollment, error) {
return &fleet.NanoEnrollment{Enabled: false}, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{
LastMDMEnrollmentTime: nil,
LastMDMSeenTime: nil,
HardwareAttested: false,
}, nil
}
ds.GetMDMAppleCommandRequestTypeFunc = func(ctx context.Context, commandUUID string) (string, error) {
return "", nil
@ -451,6 +455,23 @@ func TestAppleMDMAuthorization(t *testing.T) {
_, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"})
require.Error(t, err)
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
rawB64PremiumCmd = base64.RawStdEncoding.EncodeToString(fmt.Appendf([]byte{}, `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Command</key>
<dict>
<key>RequestType</key>
<string>%s</string>
</dict>
<key>CommandUUID</key>
<string>uuid</string>
</dict>
</plist>`, "ClearPasscode"))
_, err = svc.EnqueueMDMAppleCommand(ctx, rawB64PremiumCmd, []string{"host1"})
require.Error(t, err)
require.ErrorContains(t, err, fleet.ErrMissingLicense.Error())
})
cmdUUIDToHostUUIDs := map[string][]string{

View file

@ -567,6 +567,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", clearPasscodeEndpoint, clearPasscodeRequest{})
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/recovery_lock_password/rotate", rotateRecoveryLockPasswordEndpoint, rotateRecoveryLockPasswordRequest{})
// Generative AI

View file

@ -1788,7 +1788,12 @@ func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts f
// fetch host last seen at and last enrolled at times, currently only supported for
// Apple platforms
mdmLastEnrollment, mdmLastCheckedIn, mdmHardwareAttested, err = svc.ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
details, err := svc.ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
if details != nil {
mdmLastCheckedIn = details.LastMDMSeenTime
mdmLastEnrollment = details.LastMDMEnrollmentTime
mdmHardwareAttested = details.HardwareAttested
}
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm enrollment times")
}

View file

@ -152,8 +152,8 @@ func TestHostDetailsMDMAppleDiskEncryption(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
@ -465,8 +465,12 @@ func TestHostDetailsMDMTimestamps(t *testing.T) {
ts1 := time.Now().Add(-1 * time.Hour).UTC()
ts2 := time.Now().Add(-2 * time.Hour).UTC()
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return &ts1, &ts2, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{
LastMDMEnrollmentTime: &ts1,
LastMDMSeenTime: &ts2,
HardwareAttested: false,
}, nil
}
cases := []struct {
@ -556,8 +560,8 @@ func TestHostDetailsOSSettings(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
@ -798,8 +802,8 @@ func TestHostDetailsRecoveryLockPasswordStatus(t *testing.T) {
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.GetHostDiskEncryptionKeyFunc = func(ctx context.Context, hostID uint) (*fleet.HostDiskEncryptionKey, error) {
return &fleet.HostDiskEncryptionKey{}, nil
@ -2865,8 +2869,8 @@ func TestHostMDMProfileDetail(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.UpdateHostIssuesFailingPoliciesFunc = func(ctx context.Context, hostIDs []uint) error {
return nil
@ -3009,8 +3013,8 @@ func TestHostMDMProfileScopes(t *testing.T) {
ds.ConditionalAccessBypassedAtFunc = func(ctx context.Context, hostID uint) (*time.Time, error) {
return nil, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
ds.UpdateHostIssuesFailingPoliciesFunc = func(ctx context.Context, hostIDs []uint) error {
return nil
@ -3204,8 +3208,8 @@ func TestLockUnlockWipeHostAuth(t *testing.T) {
ds.IsHostConnectedToFleetMDMFunc = func(ctx context.Context, host *fleet.Host) (bool, error) {
return true, nil
}
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*fleet.NanoMDMEnrollmentDetails, error) {
return &fleet.NanoMDMEnrollmentDetails{}, nil
}
cases := []struct {

View file

@ -3,6 +3,7 @@ package service
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
@ -760,3 +761,41 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeWindowsLinux() {
})
}
}
func (s *integrationMDMTestSuite) TestClearPasscodeCommand() {
t := s.T()
s.enableABM(t.Name())
// Create iOS host and enroll in MDM
iosHost, iosMDMClient := s.createAppleMobileHostThenDEPEnrollMDM("ios", mdmtest.RandSerialNumber())
// Trigger ClearPasscode endpoint
var clearPasscodeResp clearPasscodeResponse
s.DoJSON("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/clear_passcode", iosHost.ID), nil, http.StatusOK, &clearPasscodeResp)
require.Equal(t, fleet.AppleMDMCommandTypeClearPasscode, clearPasscodeResp.RequestType)
require.Equal(t, "ios", clearPasscodeResp.Platform)
s.lastHostActivityMatches(iosHost.ID, fleet.ActivityTypeClearedPasscode{}.ActivityName(), fmt.Sprintf(`{"host_id": %d, "host_display_name": %q}`, iosHost.ID, iosHost.DisplayName()), 0)
// Check in with the iOS device to receive the ClearPasscode command
cmd, err := iosMDMClient.Idle()
require.NoError(t, err)
require.NotNil(t, cmd)
require.Equal(t, fleet.AppleMDMCommandTypeClearPasscode, cmd.Command.RequestType)
b64Encoded := base64.StdEncoding.EncodeToString([]byte("unlocktoken" + iosMDMClient.SerialNumber))
require.Contains(t, string(cmd.Raw), b64Encoded)
// Acknowledge the ClearPasscode command
_, err = iosMDMClient.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
// Fetch the command result and check the response is acknowledged (+ Payload has the expected unlock token value)
commandResultResp := &getMDMCommandResultsResponse{}
s.DoJSON("GET", "/api/latest/fleet/commands/results", &getMDMCommandResultsRequest{
CommandUUID: clearPasscodeResp.CommandUUID,
}, http.StatusOK, commandResultResp)
require.Len(t, commandResultResp.Results, 1)
require.Equal(t, fleet.AppleMDMCommandTypeClearPasscode, commandResultResp.Results[0].RequestType)
require.NotNil(t, commandResultResp.Results[0].Result)
}

View file

@ -16108,6 +16108,9 @@ func (s *integrationMDMTestSuite) TestAppleMDMActionsOnPersonalHost() {
r = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/wipe", host.ID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), fleet.CantWipePersonalHostsMessage)
r = s.Do("POST", fmt.Sprintf("/api/latest/fleet/hosts/%d/clear_passcode", host.ID), nil, http.StatusBadRequest)
require.Contains(t, extractServerErrorText(r.Body), fleet.CantClearPasscodePersonalHostsMessage)
// Confirm that turning off MDM for personal hosts are allowed - NEEDS to be last, to not turn off MDM for the other checks.
s.Do("DELETE", fmt.Sprintf("/api/latest/fleet/hosts/%d/mdm", host.ID), nil, http.StatusNoContent)
}

View file

@ -644,8 +644,9 @@ func (svc *Service) validateAppleMDMCommand(ctx context.Context, rawXMLCmd []byt
}
var appleMDMPremiumCommands = map[string]bool{
"EraseDevice": true,
"DeviceLock": true,
"EraseDevice": true,
"DeviceLock": true,
"ClearPasscode": true,
}
func (svc *Service) enqueueAppleMDMCommand(ctx context.Context, rawXMLCmd []byte, deviceIDs []string) (result *fleet.CommandEnqueueResult, err error) {
@ -3707,3 +3708,31 @@ func (svc *Service) UnenrollMDM(ctx context.Context, hostID uint) error {
}
return nil
}
type clearPasscodeRequest struct {
HostID uint `url:"id"`
}
type clearPasscodeResponse struct {
*fleet.CommandEnqueueResult
Err error `json:"error,omitempty"`
}
func (r clearPasscodeResponse) Error() error { return r.Err }
func clearPasscodeEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*clearPasscodeRequest)
res, err := svc.ClearPasscode(ctx, req.HostID)
if err != nil {
return clearPasscodeResponse{Err: err}, nil
}
return clearPasscodeResponse{CommandEnqueueResult: res}, nil
}
func (svc *Service) ClearPasscode(ctx context.Context, hostID uint) (*fleet.CommandEnqueueResult, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)
return nil, fleet.ErrMissingLicense
}

View file

@ -39,10 +39,12 @@ import (
)
// testSAMLIDPBaseURL is the SAML IDP base URL, read from FLEET_SAML_IDP_HTTP_PORT (defaults to http://localhost:9080).
var testSAMLIDPBaseURL = getTestSAMLIDPBaseURL()
var testSAMLIDPMetadataURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/metadata.php"
var testSAMLIDPSSOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SSOService.php"
var testSAMLIDPSLOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SingleLogoutService.php"
var (
testSAMLIDPBaseURL = getTestSAMLIDPBaseURL()
testSAMLIDPMetadataURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/metadata.php"
testSAMLIDPSSOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SSOService.php"
testSAMLIDPSLOURL = testSAMLIDPBaseURL + "/simplesaml/saml2/idp/SingleLogoutService.php"
)
func getTestSAMLIDPBaseURL() string {
if port := os.Getenv("FLEET_SAML_IDP_HTTP_PORT"); port != "" {
@ -643,6 +645,27 @@ func (ts *withServer) lastActivityMatchesExtended(name, details string, id uint,
return act.ID
}
func (ts *withServer) lastHostActivityMatches(hostID uint, name, details string, id uint) uint {
t := ts.s.T()
var listActivities listActivitiesResponse
ts.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/hosts/%d/activities", hostID), nil, http.StatusOK, &listActivities, "order_key", "a.id", "order_direction", "desc", "per_page", "10")
require.NotEmpty(t, listActivities.Activities)
act := listActivities.Activities[0]
if name != "" {
assert.Equal(t, name, act.Type)
}
if details != "" {
require.NotNil(t, act.Details)
assert.JSONEq(t, details, string(*act.Details))
}
if id > 0 {
assert.Equal(t, id, act.ID)
}
return act.ID
}
// gets the latest activity with the specified type name and checks that it
// matches any provided properties. empty string or 0 id means do not check
// that property. It returns the ID of that latest activity.