Do not clear MDM lock state on "idle" after lock (#42799) (#42825)

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

When a macOS device acknowledges a lock command it can immediately send
a trailing Idle check-in. CleanAppleMDMLock now requires that unlock_ref
to be set at least 5 minutes ago before clearing the lock state,
preventing that trailing Idle to prematurely clearing the MDM lock
state.
This commit is contained in:
Juan Fernandez 2026-04-02 11:02:50 -04:00 committed by GitHub
parent 8bc84d9a2d
commit 569d85340d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 48 additions and 6 deletions

View file

@ -0,0 +1 @@
* Fixed bug that cleared the MDM lock state if an "idle" message was received right after the lock ACK.

View file

@ -5038,17 +5038,25 @@ func (ds *Datastore) MDMResetEnrollment(ctx context.Context, hostUUID string, sc
})
}
// MDMLockCleanupMinutes is the minimum number of minutes that must have elapsed
// since unlock_ref was set before CleanAppleMDMLock will clear the lock state.
// This prevents the trailing Idle check-in (sent by the device right after
// acknowledging the lock command) from prematurely clearing the lock state.
const MDMLockCleanupMinutes = 5
func (ds *Datastore) CleanAppleMDMLock(ctx context.Context, hostUUID string) error {
const stmt = `
stmt := fmt.Sprintf(`
UPDATE host_mdm_actions hma
JOIN hosts h ON hma.host_id = h.id
SET hma.unlock_ref = NULL,
hma.lock_ref = NULL,
hma.unlock_pin = NULL
WHERE h.uuid = ? AND (
(hma.unlock_ref IS NOT NULL AND hma.unlock_pin IS NOT NULL AND h.platform = 'darwin')
(hma.unlock_ref IS NOT NULL AND hma.unlock_pin IS NOT NULL AND h.platform = 'darwin'
AND (STR_TO_DATE(hma.unlock_ref, '%%Y-%%m-%%d %%H:%%i:%%s') IS NULL
OR STR_TO_DATE(hma.unlock_ref, '%%Y-%%m-%%d %%H:%%i:%%s') <= UTC_TIMESTAMP() - INTERVAL %d MINUTE))
OR (hma.unlock_ref IS NOT NULL AND (h.platform = 'ios' OR h.platform = 'ipados'))
)`
)`, MDMLockCleanupMinutes)
if _, err := ds.writer(ctx).ExecContext(ctx, stmt, hostUUID); err != nil {
return ctxerr.Wrap(ctx, err, "cleaning up macOS lock")

View file

@ -5577,6 +5577,21 @@ func testLockUnlockWipeMacOS(t *testing.T, ds *Datastore) {
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, false, false, false)
err = ds.CleanAppleMDMLock(ctx, host.UUID)
require.NoError(t, err)
status, err = ds.GetHostLockWipeStatus(ctx, host)
require.NoError(t, err)
checkLockWipeState(t, status, false, true, false, false, false, false)
// backdate unlock_ref to simulate the device having been locked for more than 5 minutes
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx,
fmt.Sprintf(`UPDATE host_mdm_actions hma JOIN hosts h ON hma.host_id = h.id
SET hma.unlock_ref = DATE_FORMAT(UTC_TIMESTAMP() - INTERVAL %d MINUTE, '%%Y-%%m-%%d %%H:%%i:%%s')
WHERE h.uuid = ?`, MDMLockCleanupMinutes+1), host.UUID)
return err
})
// execute CleanAppleMDMLock to simulate successful unlock
err = ds.CleanAppleMDMLock(ctx, host.UUID)
require.NoError(t, err)

View file

@ -2228,6 +2228,8 @@ func (ds *Datastore) WipeHostViaScript(ctx context.Context, request *fleet.HostS
})
}
// UnlockHostManually records a manual unlock request for the given host.
// ts must be in UTC to ensure consistency with the STR_TO_DATE comparison in CleanAppleMDMLock.
func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error {
const stmt = `
INSERT INTO host_mdm_actions
@ -2249,7 +2251,7 @@ func (ds *Datastore) UnlockHostManually(ctx context.Context, hostID uint, hostFl
// from then on, the host is marked as "pending unlock" until the device is
// actually unlocked with the PIN. The actual unlocking happens when the
// device sends an Idle MDM request.
unlockRef := ts.Format(time.DateTime)
unlockRef := ts.UTC().Format(time.DateTime)
_, err := ds.writer(ctx).ExecContext(ctx, stmt, hostID, unlockRef, hostFleetPlatform)
return ctxerr.Wrap(ctx, err, "record manual unlock host request")
}
@ -2276,7 +2278,8 @@ func buildHostLockWipeStatusUpdateStmt(refCol string, succeeded bool, joinPart s
// Currently only used for Apple MDM devices.
// We set the unlock_ref to current time since the device can be unlocked any time after the lock.
// Apple MDM does not have a concept of unlock pending.
stmt += fmt.Sprintf("%sunlock_ref = '%s', %[1]swipe_ref = NULL", alias, time.Now().Format(time.DateTime))
// UTC_TIMESTAMP() is used to ensure timezone consistency with the comparison in CleanAppleMDMLock.
stmt += fmt.Sprintf("%sunlock_ref = UTC_TIMESTAMP(), %[1]swipe_ref = NULL", alias)
}
case "unlock_ref":
// a successful unlock clears itself as well as the lock ref, because

View file

@ -2124,7 +2124,10 @@ type Datastore interface {
UnlockHostManually(ctx context.Context, hostID uint, hostFleetPlatform string, ts time.Time) error
// CleanAppleMDMLock cleans the lock status and pin for a macOS device
// after it has been unlocked.
// after it has been unlocked. CleanAppleMDMLock will be a no-op when
// unlock_ref was set within the last 5 minutes, to prevent the trailing
// Idle (sent right after the device acknowledges the lock command)
// from prematurely clearing the lock state.
CleanAppleMDMLock(ctx context.Context, hostUUID string) error
InsertHostLocationData(ctx context.Context, locData HostLocationData) error

View file

@ -11,11 +11,13 @@ import (
"testing"
"github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanodep/godep"
mdmtesting "github.com/fleetdm/fleet/v4/server/mdm/testing_utils"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -110,6 +112,16 @@ func (s *integrationMDMTestSuite) TestLockUnlockWipeMacOS() {
fmt.Sprintf(`{"host_id": %d, "host_display_name": %q, "host_platform": %q}`, host.ID, host.DisplayName(), host.FleetPlatform()), 0)
require.NotEqual(t, unlockActID, newUnlockActID)
// simulate passage of time: backdate unlock_ref so that CleanAppleMDMLock's
// 5-minute guard doesn't block the upcoming Idle from clearing the lock state.
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(context.Background(),
fmt.Sprintf(`UPDATE host_mdm_actions hma JOIN hosts h ON hma.host_id = h.id
SET hma.unlock_ref = DATE_FORMAT(UTC_TIMESTAMP() - INTERVAL %d MINUTE, '%%Y-%%m-%%d %%H:%%i:%%s')
WHERE h.uuid = ?`, mysql.MDMLockCleanupMinutes+1), host.UUID)
return err
})
// as soon as the host sends an Idle MDM request, it is marked as unlocked
cmd, err = mdmClient.Idle()
require.NoError(t, err)