diff --git a/server/datastore/mysql/nanomdm_storage.go b/server/datastore/mysql/nanomdm_storage.go index c2e0ead15a..3c318e9a83 100644 --- a/server/datastore/mysql/nanomdm_storage.go +++ b/server/datastore/mysql/nanomdm_storage.go @@ -191,6 +191,7 @@ func (s *NanoMDMStorage) GetPendingLockCommand(ctx context.Context, hostUUID str LEFT JOIN nano_command_results ncr ON ncr.command_uuid = nc.command_uuid INNER JOIN nano_enrollment_queue neq ON neq.command_uuid = nc.command_uuid WHERE neq.id = ? + AND neq.active = 1 AND nc.request_type = 'DeviceLock' AND ncr.command_uuid IS NULL ORDER BY nc.created_at DESC @@ -244,10 +245,21 @@ func (s *NanoMDMStorage) EnqueueDeviceLockCommand( `SELECT lock_ref FROM host_mdm_actions WHERE host_id = ? FOR UPDATE`, host.ID) - // If we got a row and it has a lock_ref, fail with conflict + // A non-null lock_ref only blocks a new lock if it still points to a + // deliverable command. Re-enrollment, SCEP renewal, and wipe flip the + // queued command to active=0 (see nanomdm ClearQueue), and an inactive + // command is never sent to the device, so treat it as an orphan ref and + // let the new lock overwrite it below. if err == nil && existingLockRef != nil && *existingLockRef != "" { - // A lock command already exists, don't overwrite - return lockConflictError{hostUUID: host.UUID} + var active bool + if err := sqlx.GetContext(ctx, tx, &active, + `SELECT EXISTS(SELECT 1 FROM nano_enrollment_queue WHERE command_uuid = ? AND id = ? AND active = 1)`, + *existingLockRef, host.UUID); err != nil { + return ctxerr.Wrap(ctx, err, "checking if existing lock command is active") + } + if active { + return lockConflictError{hostUUID: host.UUID} + } } // If the row doesn't exist, that's OK, we'll insert it diff --git a/server/datastore/mysql/nanomdm_storage_test.go b/server/datastore/mysql/nanomdm_storage_test.go index d3bf9c7acc..8eb71c15cb 100644 --- a/server/datastore/mysql/nanomdm_storage_test.go +++ b/server/datastore/mysql/nanomdm_storage_test.go @@ -28,6 +28,7 @@ func TestNanoMDMStorage(t *testing.T) { }{ {"TestEnqueueDeviceLockCommand", testEnqueueDeviceLockCommand}, {"TestGetPendingLockCommand", testGetPendingLockCommand}, + {"TestEnqueueDeviceLockReplacesOrphanRef", testEnqueueDeviceLockReplacesOrphanRef}, {"TestEnqueueDeviceLockCommandRaceCondition", testEnqueueDeviceLockCommandRaceCondition}, {"TestEnqueueDeviceUnlockCommand", testEnqueueDeviceUnlockCommand}, {"TestStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal", testStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal}, @@ -242,6 +243,73 @@ func testGetPendingLockCommand(t *testing.T, ds *Datastore) { require.Empty(t, pin) } +// testEnqueueDeviceLockReplacesOrphanRef verifies that a lock_ref pointing to a +// command that is no longer deliverable (nano_enrollment_queue.active = 0, e.g. +// after re-enrollment, SCEP renewal, or wipe) is treated as an orphan: it does +// not count as a pending lock and does not block a fresh lock command. +// See https://github.com/fleetdm/fleet/issues/45931 +func testEnqueueDeviceLockReplacesOrphanRef(t *testing.T, ds *Datastore) { + ctx := context.Background() + ns, err := ds.NewMDMAppleMDMStorage() + require.NoError(t, err) + + host, err := ds.NewHost(ctx, &fleet.Host{ + Hostname: "orphan-relock-name", + OsqueryHostID: new("4242"), + NodeKey: new("4242"), + UUID: "orphan-relock-uuid", + TeamID: nil, + Platform: "darwin", + }) + require.NoError(t, err) + nanoEnroll(t, ds, host, false) + + // Enqueue an initial lock command: active=1, no result yet -> genuinely pending. + lockCmd := &mdm.Command{} + lockCmd.CommandUUID = "orphan-lock-cmd-1" + lockCmd.Command.RequestType = "DeviceLock" + lockCmd.Raw = []byte("