Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1671,7 +1671,7 @@ const TAGGED_TEMPLATES = {
<>
{" "}
canceled setup experience on <b>{hostName}</b> because <b>{title}</b>{" "}
failed to install.
failed to install. End user was asked to restart.
</>
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,11 @@ const InstallSoftware = ({
/>
<PageDescription
variant="right-panel"
content="Install software on hosts that automatically enroll to Fleet."
content={
selectedPlatform === "windows" || selectedPlatform === "linux"
? "Install software on hosts that enroll to Fleet."
: "Install software on hosts that automatically enroll to Fleet."
}
/>
<SetupExperienceContentContainer>
{isLoadingConfig ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ const InstallSoftwareForm = ({
<TooltipWrapper
tipContent={
isWindowsMdmEnabled
? "If any software fails, the end user will be prompted to restart setup. Remaining software installs will be canceled."
? "For hosts that automatically enroll, if any software fails, the end user will be prompted to restart setup. Remaining software installs will be canceled."
: "Turn on Windows MDM to use this option."
}
disableTooltip={disableChildren}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const CanceledSetupExperienceActivityItem = ({
<>
<b>{activity.actor_full_name ?? "Fleet"}</b> canceled setup experience
on this host because <b>{activity.details.software_title}</b> failed to
install.
install. End user was asked to restart.
</>
</ActivityItem>
);
Expand Down
43 changes: 43 additions & 0 deletions server/datastore/mysql/microsoft_mdm.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,49 @@ func (ds *Datastore) MDMWindowsGetEnrolledDeviceWithHostUUID(ctx context.Context
return &winMDMDevice, nil
}

// MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName fetches the most recent Windows MDM enrollment whose host_uuid is
// not yet populated and whose device_name matches the given computer name. This is the BYOD-before-osquery-ingest
// fallback used by callers (e.g. the setup-experience cancel flow) that need to consult enrollment state during the
// brief window between orbit/enroll and osquery's directIngestMDMDeviceIDWindows linking host_uuid. Constraining on
// the empty host_uuid avoids matching rows already linked to a different host that happens to share the same Windows
// computer name.
func (ds *Datastore) MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error) {
if deviceName == "" {
return nil, ctxerr.Wrap(ctx, notFound("MDMWindowsEnrolledDevice").WithMessage("empty device name"))
}
stmt := `SELECT
id,
mdm_device_id,
mdm_hardware_id,
device_state,
device_type,
device_name,
enroll_type,
enroll_user_id,
enroll_proto_version,
enroll_client_version,
not_in_oobe,
awaiting_configuration,
awaiting_configuration_at,
credentials_hash,
credentials_acknowledged,
created_at,
updated_at,
host_uuid
FROM mdm_windows_enrollments
WHERE device_name = ? AND (host_uuid IS NULL OR host_uuid = '')
ORDER BY created_at DESC, id DESC LIMIT 1`

var winMDMDevice fleet.MDMWindowsEnrolledDevice
if err := sqlx.GetContext(ctx, ds.writer(ctx), &winMDMDevice, stmt, deviceName); err != nil {
if err == sql.ErrNoRows {
return nil, ctxerr.Wrap(ctx, notFound("MDMWindowsEnrolledDevice").WithMessage(deviceName))
}
return nil, ctxerr.Wrap(ctx, err, "get MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName")
}
return &winMDMDevice, nil
}

// HasWindowsSetupExperienceItemsForTeam returns true if any active Windows setup-experience software
// installers with install_during_setup=TRUE are configured for the given team. teamID=0 means "no team /
// global", matching the value EnqueueSetupExperienceItems passes in for hosts on no team.
Expand Down
68 changes: 68 additions & 0 deletions server/datastore/mysql/microsoft_mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func TestMDMWindows(t *testing.T) {
{"TestMDMWindowsProfilesToRemoveSkipsOrphanedHosts", testMDMWindowsProfilesToRemoveSkipsOrphanedHosts},
{"TestMDMWindowsInsertCommandSkipsUnenrolledHosts", testMDMWindowsInsertCommandSkipsUnenrolledHosts},
{"TestCleanupWindowsMDMCommandQueue", testCleanupWindowsMDMCommandQueue},
{"TestMDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName", testMDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName},
}

for _, c := range cases {
Expand Down Expand Up @@ -6077,3 +6078,70 @@ func testMDMWindowsHasSetupExperienceItems(t *testing.T, ds *Datastore) {
require.False(t, hasItemsB, "team-A installer must not appear for team-B")
})
}

func testMDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(t *testing.T, ds *Datastore) {
ctx := t.Context()

// 1. Empty device name → NotFound.
_, err := ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, "")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err), "empty device name should return NotFound")

// 2. No matching row → NotFound.
_, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, "DESKTOP-NONE")
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))

// 3. Insert an unlinked enrollment (host_uuid="") with a known device_name; method returns it.
deviceName := "DESKTOP-RACE-1"
unlinked := &fleet.MDMWindowsEnrolledDevice{
MDMDeviceID: uuid.New().String(),
MDMHardwareID: uuid.New().String() + uuid.New().String(),
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
MDMDeviceType: "CIMClient_Windows",
MDMDeviceName: deviceName,
MDMEnrollType: "AzureADJoin",
MDMEnrollProtoVersion: "5.0",
MDMEnrollClientVersion: "10.0.19045.2965",
MDMNotInOOBE: true,
HostUUID: "",
}
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, unlinked))

got, err := ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, deviceName)
require.NoError(t, err)
require.Equal(t, unlinked.MDMDeviceID, got.MDMDeviceID)
require.Empty(t, got.HostUUID)
require.True(t, got.MDMNotInOOBE)

// 4. Linked enrollment with the same name (host_uuid populated) must be excluded.
linked := &fleet.MDMWindowsEnrolledDevice{
MDMDeviceID: uuid.New().String(),
MDMHardwareID: uuid.New().String() + uuid.New().String(),
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
MDMDeviceType: "CIMClient_Windows",
MDMDeviceName: deviceName,
MDMEnrollType: "AzureADJoin",
MDMEnrollProtoVersion: "5.0",
MDMEnrollClientVersion: "10.0.19045.2965",
MDMNotInOOBE: true,
HostUUID: "11111111-1111-1111-1111-111111111111",
}
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, linked))

got, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, deviceName)
require.NoError(t, err)
require.Equal(t, unlinked.MDMDeviceID, got.MDMDeviceID,
"must return the unlinked row even when another row with the same device_name is linked")

// 5. After linking the original row too, no unlinked row remains → NotFound.
_, err = ds.writer(ctx).ExecContext(ctx,
`UPDATE mdm_windows_enrollments SET host_uuid = ? WHERE mdm_device_id = ?`,
"22222222-2222-2222-2222-222222222222", unlinked.MDMDeviceID)
require.NoError(t, err)

_, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, deviceName)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err),
"all matching rows now linked; method must return NotFound")
}
84 changes: 65 additions & 19 deletions server/datastore/mysql/setup_experience.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,32 +81,78 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo
// If the host was enrolled more than 24 hours ago, don't enqueue any items.
// Note: if the last enroll date is our "zero date" (1/1/2000), treat it as if it's never enrolled.
if lastEnrolledAt.Valid && lastEnrolledAt.Time.Before(time.Now().Add(-24*time.Hour)) && lastEnrolledAt.Time.After(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)) {
// On Windows, the 24h-old-host guard races with last_enrolled_at on re-Autopilot:
// orbit calls SetupExperienceInit before the new last_enrolled_at lands, so a
// previously-enrolled host that's mid-Autopilot-OOBE looks "old" and gets skipped
// even though it IS in ESP and we DO want setup-experience to run. Fall back to a
// direct check of mdm_windows_enrollments.awaiting_configuration: if the host is
// in Pending/Active, it's actively in ESP, bypass the age guard.
// mdm_windows_enrollments.host_uuid stores the Fleet host UUID (hosts.uuid), but the
// hostUUID parameter here comes from fleet.HostUUIDForSetupExperience, which on Windows
// resolves to OsqueryHostID. Resolve via JOIN and match either identifier so the lookup
// works regardless of how OsqueryHostID and the Fleet host UUID relate (this depends on
// the osquery host_identifier mode).
// On Windows, the 24h-old-host guard races with last_enrolled_at when a previously-enrolled
// device re-enrolls (Autopilot wipe, Entra OOBE on a recycled VM, BYOD reconnect, etc.). Orbit
// calls SetupExperienceInit shortly after orbit/enroll, before EnrollOrbit's last_enrolled_at
// update has committed, so the host's last_enrolled_at still reflects the prior enrollment and
// looks "old" even though this IS a fresh enrollment we want to run setup-experience for.
//
// Fall back to mdm_windows_enrollments to detect a genuine re-enrollment. Two signals work:
// 1. awaiting_configuration in Pending/Active: device is actively in ESP. Covers re-Autopilot
// and re-Entra-OOBE.
// 2. mdm_windows_enrollments.created_at within the last few minutes: a fresh MDM enrollment
// row was just inserted. Covers BYOD (which never enters awaiting_configuration) plus the
// OOBE cases above as a belt-and-suspenders fallback.
//
// The original #35717 protection (skip setup-experience for a fleetd upgrade on a long-running
// host) is preserved: a fleetd MSI upgrade doesn't create a new mdm_windows_enrollments row, so
// neither lookup finds a fresh row.
//
// hostUUID here comes from fleet.HostUUIDForSetupExperience, which on Windows resolves to
// OsqueryHostID. Match either identifier on the hosts side so the lookup works regardless of
// the osquery host_identifier mode.
if hostPlatform == "windows" {
var awaiting fleet.WindowsMDMAwaitingConfiguration
stmtAwaiting := `
SELECT mwe.awaiting_configuration
var mdmState struct {
AwaitingConfiguration fleet.WindowsMDMAwaitingConfiguration `db:"awaiting_configuration"`
CreatedAt time.Time `db:"created_at"`
}
// Primary lookup: JOIN by mwe.host_uuid (the populated link).
stmtByHostUUID := `
SELECT mwe.awaiting_configuration, mwe.created_at
FROM mdm_windows_enrollments mwe
JOIN hosts h ON mwe.host_uuid = h.uuid
WHERE (h.osquery_host_id = ? OR h.uuid = ?) AND h.platform = 'windows'
ORDER BY mwe.created_at DESC, mwe.id DESC
LIMIT 1
`
if err := sqlx.GetContext(ctx, ds.reader(ctx), &awaiting, stmtAwaiting, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, ctxerr.Wrap(ctx, err, "checking windows awaiting_configuration for setup experience age guard")
} else if err == nil && awaiting != fleet.WindowsMDMAwaitingConfigurationNone {
ds.logger.DebugContext(ctx, "Windows host enrolled >24h ago but is in awaiting_configuration; running setup experience for re-Autopilot",
"host_uuid", hostUUID, "awaiting_configuration", awaiting)
found := false
if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmState, stmtByHostUUID, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, ctxerr.Wrap(ctx, err, "checking windows mdm enrollment state by host_uuid for setup experience age guard")
} else if err == nil {
found = true
}
// Secondary lookup: JOIN by device_name = computer_name. Only needed when the primary
// link is not yet populated, which is the BYOD-before-osquery-ingest case.
//
// Cross-host collision protection: also constrain on mwe.host_uuid so we reject rows
// already linked to a different host (e.g. another device on the network shares a Windows
// computer name and has finished osquery ingest). A residual
// edge case is two hosts sharing the same computer_name both freshly enrolling within the
// 5-minute window with neither linked yet. Follow-up bug: https://github.com/fleetdm/fleet/issues/45380
if !found {
stmtByName := `
SELECT mwe.awaiting_configuration, mwe.created_at
FROM mdm_windows_enrollments mwe
JOIN hosts h ON mwe.device_name = h.computer_name
WHERE (h.osquery_host_id = ? OR h.uuid = ?)
AND h.platform = 'windows'
AND h.computer_name <> ''
AND (mwe.host_uuid = h.uuid OR mwe.host_uuid IS NULL OR mwe.host_uuid = '')
ORDER BY mwe.created_at DESC, mwe.id DESC
LIMIT 1
`
Comment thread
getvictor marked this conversation as resolved.
if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmState, stmtByName, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, ctxerr.Wrap(ctx, err, "checking windows mdm enrollment state by device_name for setup experience age guard")
} else if err == nil {
found = true
}
Comment thread
getvictor marked this conversation as resolved.
}
freshEnrollmentWindow := 5 * time.Minute
if found && (mdmState.AwaitingConfiguration != fleet.WindowsMDMAwaitingConfigurationNone || time.Since(mdmState.CreatedAt) < freshEnrollmentWindow) {
ds.logger.DebugContext(ctx, "Windows host enrolled >24h ago but has fresh MDM enrollment state; running setup experience for re-enrollment",
Comment thread
getvictor marked this conversation as resolved.
"host_uuid", hostUUID,
"awaiting_configuration", mdmState.AwaitingConfiguration,
"mdm_enrollment_created_at", mdmState.CreatedAt)
// fall through to enqueue
} else {
ds.logger.DebugContext(ctx, "Host enrolled more than 24 hours ago, skipping enqueueing setup experience items", "host_uuid", hostUUID, "platform_like", hostPlatformLike, "last_enrolled_at", lastEnrolledAt.Time)
Expand Down
62 changes: 62 additions & 0 deletions server/datastore/mysql/setup_experience_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,68 @@ func testEnqueueSetupExperienceItemsWindows(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, anythingEnqueued,
"re-Autopilot of an existing host (>24h old) with awaiting_configuration!=None must bypass the age guard")

// Re-BYOD of an existing host: last_enrolled_at is >24h old AND the host's BYOD enrollment never
// enters awaiting_configuration (not_in_oobe=1). The freshly-created mdm_windows_enrollments row
// is the signal that this IS a real re-enrollment we want setup-experience for.
host4UUID := "44444444-4444-4444-4444-444444444444"
test.NewHost(t, ds, "windows-test-4-rebyod", "", "node-key-windows-4", host4UUID, time.Now(), test.WithPlatform("windows"))
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host4UUID)
return err
})
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
MDMDeviceID: "device-host4",
MDMHardwareID: "hw-host4",
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
MDMDeviceType: "CIMClient_Windows",
MDMDeviceName: "DESKTOP-H4",
MDMEnrollType: "ProgrammaticEnrollment",
MDMEnrollProtoVersion: "5.0",
MDMEnrollClientVersion: "10.0.19045.2965",
MDMNotInOOBE: true,
HostUUID: host4UUID,
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
}))

anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host4UUID, team1.ID)
require.NoError(t, err)
require.True(t, anythingEnqueued,
"re-BYOD of an existing host (>24h old) with a fresh mdm_windows_enrollments row must bypass the age guard")

Comment thread
getvictor marked this conversation as resolved.
// Original #35717 protection: a fleetd MSI upgrade on a long-running host does NOT create a new
// mdm_windows_enrollments row, so the existing one is also old. Both fallback signals must miss,
// and the age guard must still skip enqueueing.
host5UUID := "55555555-5555-5555-5555-555555555555"
test.NewHost(t, ds, "windows-test-5-fleetd-upgrade", "", "node-key-windows-5", host5UUID, time.Now(), test.WithPlatform("windows"))
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host5UUID)
return err
})
require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
MDMDeviceID: "device-host5",
MDMHardwareID: "hw-host5",
MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
MDMDeviceType: "CIMClient_Windows",
MDMDeviceName: "DESKTOP-H5",
MDMEnrollType: "ProgrammaticEnrollment",
MDMEnrollProtoVersion: "5.0",
MDMEnrollClientVersion: "10.0.19045.2965",
MDMNotInOOBE: true,
HostUUID: host5UUID,
AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
}))
// Backdate the MDM enrollment row beyond the freshEnrollmentWindow (5m) to simulate a long-running
// host whose orbit just started supporting setup-experience.
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE mdm_windows_enrollments SET created_at = ? WHERE host_uuid = ?", time.Now().Add(-72*time.Hour), host5UUID)
return err
})

anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host5UUID, team1.ID)
require.NoError(t, err)
require.False(t, anythingEnqueued,
"long-running host with a stale mdm_windows_enrollments row (e.g. fleetd MSI upgrade per #35717) must still skip enqueueing")
}

func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
Expand Down
6 changes: 6 additions & 0 deletions server/fleet/datastore.go
Original file line number Diff line number Diff line change
Expand Up @@ -2042,6 +2042,12 @@ type Datastore interface {
// MDMWindowsGetEnrolledDeviceWithHostUUID returns the MDMWindowsEnrolledDevice information for a given HostUUID
MDMWindowsGetEnrolledDeviceWithHostUUID(ctx context.Context, hostUUID string) (*MDMWindowsEnrolledDevice, error)

// MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName returns the most recent MDMWindowsEnrolledDevice whose host_uuid
// has not yet been populated (i.e. osquery's directIngestMDMDeviceIDWindows has not run since enrollment) and whose
// device_name matches the given computer name. Used as a fallback when MDMWindowsGetEnrolledDeviceWithHostUUID can't
// find a row because the host->enrollment link has not been resolved yet.
MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx context.Context, deviceName string) (*MDMWindowsEnrolledDevice, error)

// MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id
MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error

Expand Down
Loading
Loading