- Hosts that turn on MDM manually will have a status of "On
- (manual)". To get a status of "On (company-owned)",
- use{" "}
-
-
{
disabled={gitOpsModeEnabled}
/>
{isPremiumTier && (
- // NOTE: first time using fieldset and legend. if we use this more we should make
- // a reusable component
-
- )}
- {isPremiumTier && enrollmentType !== "manual" && (
-
- Automatically migrate hosts connected to another MDM solution
-
+ />
+ )}
+ {isPremiumTier && (
+
+
+ User driven enrollment
+
+ {defaultFleetDropdown}
+
+ )}
+ {isPremiumTier && turnOnProgrammatically && (
+
+
Migration
+
+ Automatically migrate hosts connected to another MDM solution
+
+
)}
''
- AND (mwe.host_uuid = h.uuid OR mwe.host_uuid IS NULL OR mwe.host_uuid = '')
+ AND (mwe.host_uuid = h.uuid OR mwe.host_uuid = '')
ORDER BY mwe.created_at DESC, mwe.id DESC
LIMIT 1
`
diff --git a/server/datastore/mysql/testing_utils_test.go b/server/datastore/mysql/testing_utils_test.go
index fc3987a03c..071ca1fc40 100644
--- a/server/datastore/mysql/testing_utils_test.go
+++ b/server/datastore/mysql/testing_utils_test.go
@@ -456,6 +456,7 @@ func TruncateTables(t testing.TB, ds *Datastore, tables ...string) {
"mdm_apple_declaration_categories": true,
"mdm_delivery_status": true,
"mdm_operation_types": true,
+ "mdm_windows_enrollment_config": true,
"migration_status_tables": true,
"osquery_options": true,
"software_categories": true,
diff --git a/server/fleet/activities.go b/server/fleet/activities.go
index 63c42d2981..f2fae75423 100644
--- a/server/fleet/activities.go
+++ b/server/fleet/activities.go
@@ -2105,6 +2105,17 @@ func (a ActivityTypeDeletedMicrosoftEntraClientID) ActivityName() string {
return "deleted_microsoft_entra_client_id"
}
+// ActivityTypeEditedWindowsEnrollmentDefaultFleet is logged when the default fleet for new
+// user-driven Windows MDM enrollments changes. Both fields are null when the default is cleared.
+type ActivityTypeEditedWindowsEnrollmentDefaultFleet struct {
+ FleetID *uint `json:"fleet_id"`
+ FleetName *string `json:"fleet_name"`
+}
+
+func (a ActivityTypeEditedWindowsEnrollmentDefaultFleet) ActivityName() string {
+ return "edited_windows_enrollment_default_fleet"
+}
+
type ActivityTypeEditedEnrollSecrets struct {
TeamID *uint `json:"team_id" renameto:"fleet_id"`
TeamName *string `json:"team_name" renameto:"fleet_name"`
diff --git a/server/fleet/app.go b/server/fleet/app.go
index 5a3067cda0..5141541ff0 100644
--- a/server/fleet/app.go
+++ b/server/fleet/app.go
@@ -266,6 +266,10 @@ type MDM struct {
// Windows automatic enrollment.
WindowsEntraClientIDs optjson.Slice[string] `json:"windows_entra_client_ids"`
+ // WindowsEnrollment configures behavior for new user-driven Windows MDM enrollments. The DB row backing it is the
+ // source of truth (by fleet id); this field carries the setting through the config API and GitOps by fleet name.
+ WindowsEnrollment optjson.Any[WindowsEnrollment] `json:"windows_enrollment"`
+
// WindowsEnabledAndConfigured indicates if Fleet MDM is enabled for Windows.
// There is no other configuration required for Windows other than enabling
// the support, but it is still called "EnabledAndConfigured" for consistency
@@ -2144,6 +2148,16 @@ type WindowsSettings struct {
ManagedLocalAccountSettings ManagedLocalAccountSettings `json:"managed_local_account_settings"`
}
+// WindowsEnrollment are settings for new user-driven Windows MDM enrollments.
+type WindowsEnrollment struct {
+ // DefaultFleet is the name of the fleet that new user-driven Windows MDM enrollments are assigned to.
+ // Empty means no default: new hosts stay Unassigned.
+ //
+ // Do NOT read this field for logic: it is the transport/display shape only, and the copy stored in app_config_json can be stale
+ // after a fleet rename or deletion. The source of truth is via Datastore.GetWindowsEnrollmentDefaultFleet
+ DefaultFleet string `json:"default_fleet"`
+}
+
func (ws WindowsSettings) GetMDMProfileSpecs() []MDMProfileSpec {
return ws.CustomSettings.Value
}
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index 03fbcbb6cf..9e2a61db52 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -2348,6 +2348,21 @@ type Datastore interface {
// WindowsHostLiteByHardwareSerial returns a HostLite for the Windows host whose hardware_serial matches the given serial.
WindowsHostLiteByHardwareSerial(ctx context.Context, hardwareSerial string) (*HostLite, error)
+ // MDMWindowsSaveUnlinkedEnrollmentHardwareSerial stores the SMBIOS serial reported over OMA-DM (DevDetail) on a still-unlinked
+ // Windows MDM enrollment, so the orbit enrollment path can reverse-link the enrollment once the host record exists.
+ MDMWindowsSaveUnlinkedEnrollmentHardwareSerial(ctx context.Context, mdmDeviceID string, hardwareSerial string) error
+
+ // MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerial returns the most recent unlinked (host_uuid = "") Windows
+ // MDM enrollment whose device-reported SMBIOS serial matches. Returns a NotFound error when there is none.
+ MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerial(ctx context.Context, hardwareSerial string) (*MDMWindowsEnrolledDevice, error)
+
+ // GetWindowsEnrollmentDefaultFleet returns the configured default fleet for new user-driven Windows MDM enrollments: nil fleet
+ // id and empty name when unset.
+ GetWindowsEnrollmentDefaultFleet(ctx context.Context) (fleetID *uint, fleetName string, err error)
+
+ // SetWindowsEnrollmentDefaultFleet sets (or clears, with nil) the default fleet for new user-driven Windows MDM enrollments.
+ SetWindowsEnrollmentDefaultFleet(ctx context.Context, fleetID *uint) error
+
// MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id
MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error
diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go
index f0e3bf27f9..567ec0b633 100644
--- a/server/fleet/hosts.go
+++ b/server/fleet/hosts.go
@@ -1822,6 +1822,7 @@ type HostLite struct {
UUID string `db:"uuid"`
HardwareModel string `db:"hardware_model"`
HardwareSerial string `db:"hardware_serial"`
+ CreatedAt time.Time `db:"created_at"`
SeenTime time.Time `db:"seen_time"`
DistributedInterval uint `db:"distributed_interval"`
ConfigTLSRefresh uint `db:"config_tls_refresh"`
diff --git a/server/fleet/microsoft_mdm.go b/server/fleet/microsoft_mdm.go
index 4e46d17152..0c568ce308 100644
--- a/server/fleet/microsoft_mdm.go
+++ b/server/fleet/microsoft_mdm.go
@@ -939,9 +939,31 @@ type MDMWindowsEnrolledDevice struct {
// HasPendingCommands is the denormalized pending-commands flag as loaded at session start. The management session uses it to gate the
// per-session refresh: when it is already false and the pending fetch is empty, the refresh is skipped so idle check-ins do zero
// writer-side statements.
- HasPendingCommands bool `db:"has_pending_commands"`
- CreatedAt time.Time `db:"created_at"`
- UpdatedAt time.Time `db:"updated_at"`
+ HasPendingCommands bool `db:"has_pending_commands"`
+ // HardwareSerial is the SMBIOS serial the device reported over OMA-DM (DevDetail), persisted while the enrollment
+ // is still unlinked so the orbit enrollment path can reverse-link it.
+ HardwareSerial *string `db:"hardware_serial"`
+ CreatedAt time.Time `db:"created_at"`
+ UpdatedAt time.Time `db:"updated_at"`
+}
+
+// WindowsEnrollmentDefaultFleet is the cacheable shape of Datastore.GetWindowsEnrollmentDefaultFleet (see the cached_mysql
+// layer). Nil FleetID and empty FleetName mean no default is configured.
+type WindowsEnrollmentDefaultFleet struct {
+ FleetID *uint
+ FleetName string
+}
+
+func (w *WindowsEnrollmentDefaultFleet) Clone() (Cloner, error) {
+ return w.Copy(), nil
+}
+
+func (w *WindowsEnrollmentDefaultFleet) Copy() *WindowsEnrollmentDefaultFleet {
+ clone := *w
+ if w.FleetID != nil {
+ clone.FleetID = new(*w.FleetID)
+ }
+ return &clone
}
func (e MDMWindowsEnrolledDevice) AuthzType() string {
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index 451f8ab297..49145adeab 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -1406,6 +1406,14 @@ type MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc func(ctx context.Cont
type WindowsHostLiteByHardwareSerialFunc func(ctx context.Context, hardwareSerial string) (*fleet.HostLite, error)
+type MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFunc func(ctx context.Context, mdmDeviceID string, hardwareSerial string) error
+
+type MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc func(ctx context.Context, hardwareSerial string) (*fleet.MDMWindowsEnrolledDevice, error)
+
+type GetWindowsEnrollmentDefaultFleetFunc func(ctx context.Context) (fleetID *uint, fleetName string, err error)
+
+type SetWindowsEnrollmentDefaultFleetFunc func(ctx context.Context, fleetID *uint) error
+
type MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDeviceID string) error
type MDMWindowsInsertCommandForHostsFunc func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand) error
@@ -4334,6 +4342,18 @@ type DataStore struct {
WindowsHostLiteByHardwareSerialFunc WindowsHostLiteByHardwareSerialFunc
WindowsHostLiteByHardwareSerialFuncInvoked bool
+ MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFunc MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFunc
+ MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFuncInvoked bool
+
+ MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc
+ MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFuncInvoked bool
+
+ GetWindowsEnrollmentDefaultFleetFunc GetWindowsEnrollmentDefaultFleetFunc
+ GetWindowsEnrollmentDefaultFleetFuncInvoked bool
+
+ SetWindowsEnrollmentDefaultFleetFunc SetWindowsEnrollmentDefaultFleetFunc
+ SetWindowsEnrollmentDefaultFleetFuncInvoked bool
+
MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc
MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked bool
@@ -10455,6 +10475,34 @@ func (s *DataStore) WindowsHostLiteByHardwareSerial(ctx context.Context, hardwar
return s.WindowsHostLiteByHardwareSerialFunc(ctx, hardwareSerial)
}
+func (s *DataStore) MDMWindowsSaveUnlinkedEnrollmentHardwareSerial(ctx context.Context, mdmDeviceID string, hardwareSerial string) error {
+ s.mu.Lock()
+ s.MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFuncInvoked = true
+ s.mu.Unlock()
+ return s.MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFunc(ctx, mdmDeviceID, hardwareSerial)
+}
+
+func (s *DataStore) MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerial(ctx context.Context, hardwareSerial string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ s.mu.Lock()
+ s.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFuncInvoked = true
+ s.mu.Unlock()
+ return s.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc(ctx, hardwareSerial)
+}
+
+func (s *DataStore) GetWindowsEnrollmentDefaultFleet(ctx context.Context) (fleetID *uint, fleetName string, err error) {
+ s.mu.Lock()
+ s.GetWindowsEnrollmentDefaultFleetFuncInvoked = true
+ s.mu.Unlock()
+ return s.GetWindowsEnrollmentDefaultFleetFunc(ctx)
+}
+
+func (s *DataStore) SetWindowsEnrollmentDefaultFleet(ctx context.Context, fleetID *uint) error {
+ s.mu.Lock()
+ s.SetWindowsEnrollmentDefaultFleetFuncInvoked = true
+ s.mu.Unlock()
+ return s.SetWindowsEnrollmentDefaultFleetFunc(ctx, fleetID)
+}
+
func (s *DataStore) MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error {
s.mu.Lock()
s.MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked = true
diff --git a/server/service/appconfig.go b/server/service/appconfig.go
index d0f617fb07..0c798fff1a 100644
--- a/server/service/appconfig.go
+++ b/server/service/appconfig.go
@@ -30,6 +30,7 @@ import (
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
"github.com/fleetdm/fleet/v4/server/platform/logging"
+ "github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/version"
"golang.org/x/text/unicode/norm"
)
@@ -282,6 +283,23 @@ func (svc *Service) AppConfigObfuscated(ctx context.Context) (*fleet.AppConfig,
// svc.ds.AppConfig directly.
ac.OrgInfo.AbsolutizeLogoURLs(ac.ServerSettings.ServerURL)
+ // The Windows enrollment default fleet's source of truth is GetWindowsEnrollmentDefaultFleet (also cached), so hydrate the
+ // response from it when it disagrees with the name stored in the app config JSON.
+ winDefaultTeamID, winDefaultFleetName, err := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get windows enrollment default fleet")
+ }
+ winStoredName := ""
+ if ac.MDM.WindowsEnrollment.Set && ac.MDM.WindowsEnrollment.Valid {
+ winStoredName = ac.MDM.WindowsEnrollment.Value.DefaultFleet
+ }
+ if winDefaultTeamID != nil || winStoredName != winDefaultFleetName {
+ ac.MDM.WindowsEnrollment = optjson.Any[fleet.WindowsEnrollment]{
+ Set: true, Valid: true,
+ Value: fleet.WindowsEnrollment{DefaultFleet: winDefaultFleetName},
+ }
+ }
+
ac.Obfuscate()
return ac, nil
@@ -1017,10 +1035,26 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
}
}
+ windowsEnrollmentDefined, windowsEnrollmentTeamID, windowsEnrollmentFleetName, err := svc.validateWindowsEnrollment(ctx, &newAppConfig.MDM, invalid, lic)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "validating windows enrollment default fleet")
+ }
+
if invalid.HasErrors() {
return nil, ctxerr.Wrap(ctx, invalid)
}
+ // Normalize the stored JSON to the canonical fleet name when one was resolved.
+ if windowsEnrollmentDefined && windowsEnrollmentFleetName != "" {
+ appConfig.MDM.WindowsEnrollment = optjson.Any[fleet.WindowsEnrollment]{
+ Set: true, Valid: true,
+ Value: fleet.WindowsEnrollment{DefaultFleet: windowsEnrollmentFleetName},
+ }
+ } else if appConfig.MDM.WindowsEnrollment.Set && !appConfig.MDM.WindowsEnrollment.Valid {
+ // A null windows_enrollment keeps the persisted setting (validateWindowsEnrollment treated it as omitted), so restore the stored value.
+ appConfig.MDM.WindowsEnrollment = oldAppConfig.MDM.WindowsEnrollment
+ }
+
// ignore MDM.EnabledAndConfigured MDM.AppleBMTermsExpired, and MDM.AppleBMEnabledAndConfigured
// if provided in the modify payload we don't return an error in this case because it would
// prevent using the output of fleetctl get config as input to fleetctl apply or this endpoint.
@@ -1294,6 +1328,30 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle
}
}
+ // Persist the Windows enrollment default fleet to its config row and log the change.
+ if windowsEnrollmentDefined {
+ oldWindowsEnrollmentTeamID, _, err := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx)
+ if err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "get current windows enrollment default fleet")
+ }
+ if !ptr.Equal(oldWindowsEnrollmentTeamID, windowsEnrollmentTeamID) {
+ if err := svc.ds.SetWindowsEnrollmentDefaultFleet(ctx, windowsEnrollmentTeamID); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "saving windows enrollment default fleet")
+ }
+ var fleetName *string
+ if windowsEnrollmentTeamID != nil {
+ fleetName = &windowsEnrollmentFleetName
+ }
+ act := fleet.ActivityTypeEditedWindowsEnrollmentDefaultFleet{
+ FleetID: windowsEnrollmentTeamID,
+ FleetName: fleetName,
+ }
+ if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), act); err != nil {
+ return nil, ctxerr.Wrap(ctx, err, "create activity for edited windows enrollment default fleet")
+ }
+ }
+ }
+
// only create activities when config change has been persisted
switch {
@@ -2242,6 +2300,52 @@ func (svc *Service) validateMDM(
return nil
}
+// validateWindowsEnrollment validates the mdm.windows_enrollment section of a config modify payload and resolves its default
+// fleet name to a team id. Returns defined=false when the section was omitted (no-op). When defined, teamID is the resolved team
+// id (nil to clear) and fleetName is the canonical team name (empty when clearing).
+func (svc *Service) validateWindowsEnrollment(
+ ctx context.Context,
+ newMDM *fleet.MDM,
+ invalid *fleet.InvalidArgumentError,
+ lic *fleet.LicenseInfo,
+) (defined bool, teamID *uint, fleetName string, err error) {
+ if !newMDM.WindowsEnrollment.Set || !newMDM.WindowsEnrollment.Valid {
+ // Omitted key or explicit null: keep the persisted setting (same convention as
+ // enable_disk_encryption). Only an object clears or changes it.
+ return false, nil, "", nil
+ }
+
+ name := newMDM.WindowsEnrollment.Value.DefaultFleet
+ if name == "" {
+ // Explicitly clearing the default; allowed on any tier.
+ return true, nil, "", nil
+ }
+
+ if lic == nil || !lic.IsPremium() {
+ // Tolerate an unchanged value re-sent without Premium (e.g. gitops re-applying exported config after a license downgrade); only
+ // reject attempts to change it.
+ curTeamID, curName, dsErr := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx)
+ if dsErr != nil {
+ return true, nil, "", ctxerr.Wrap(ctx, dsErr, "get current windows enrollment default fleet")
+ }
+ if name == curName {
+ return true, curTeamID, curName, nil
+ }
+ invalid.Append("mdm.windows_enrollment.default_fleet", ErrMissingLicense.Error())
+ return true, nil, "", nil
+ }
+
+ tm, err := svc.ds.TeamByName(ctx, name)
+ if err != nil {
+ if fleet.IsNotFound(err) {
+ invalid.Append("mdm.windows_enrollment.default_fleet", fmt.Sprintf("fleet %q doesn't exist", name))
+ return true, nil, "", nil
+ }
+ return true, nil, "", ctxerr.Wrap(ctx, err, "get team by name for windows enrollment default fleet")
+ }
+ return true, &tm.ID, tm.Name, nil
+}
+
func (svc *Service) validateABMAssignments(
ctx context.Context,
mdm, oldMdm *fleet.MDM,
diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go
index ad5f8f7a06..4ec5d12b86 100644
--- a/server/service/appconfig_test.go
+++ b/server/service/appconfig_test.go
@@ -3194,3 +3194,159 @@ func TestModifyAppConfigManagedLocalAccount(t *testing.T) {
})
}
}
+
+func TestModifyAppConfigWindowsEnrollment(t *testing.T) {
+ admin := &fleet.User{GlobalRole: new(fleet.RoleAdmin)}
+ teamID := uint(7)
+
+ type testCase struct {
+ name string
+ licenseTier string
+ payload string
+ currentTeamID *uint
+ expectErr string
+ expectSet bool
+ expectSetTo *uint
+ expectActivity bool
+ }
+ testCases := []testCase{
+ {
+ name: "set to existing fleet",
+ licenseTier: fleet.TierPremium,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":"Workstations"}}}`,
+ expectSet: true,
+ expectSetTo: &teamID,
+ expectActivity: true,
+ },
+ {
+ name: "unchanged value writes nothing",
+ licenseTier: fleet.TierPremium,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":"Workstations"}}}`,
+ currentTeamID: &teamID,
+ expectSet: false,
+ expectActivity: false,
+ },
+ {
+ name: "clear with empty string",
+ licenseTier: fleet.TierPremium,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":""}}}`,
+ currentTeamID: &teamID,
+ expectSet: true,
+ expectSetTo: nil,
+ expectActivity: true,
+ },
+ {
+ name: "unknown fleet name is invalid",
+ licenseTier: fleet.TierPremium,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":"Nope"}}}`,
+ expectErr: `fleet "Nope" doesn't exist`,
+ },
+ {
+ name: "premium required to set",
+ licenseTier: fleet.TierFree,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":"Workstations"}}}`,
+ expectErr: "missing or invalid license",
+ },
+ {
+ name: "unchanged value tolerated without premium",
+ licenseTier: fleet.TierFree,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":"Workstations"}}}`,
+ currentTeamID: &teamID,
+ expectSet: false,
+ expectActivity: false,
+ },
+ {
+ name: "omitted key is a no-op",
+ licenseTier: fleet.TierPremium,
+ payload: `{"org_info":{"org_name":"Test2"}}`,
+ currentTeamID: &teamID,
+ expectSet: false,
+ expectActivity: false,
+ },
+ {
+ name: "null keeps the persisted setting",
+ licenseTier: fleet.TierPremium,
+ payload: `{"mdm":{"windows_enrollment":null}}`,
+ currentTeamID: &teamID,
+ expectSet: false,
+ expectActivity: false,
+ },
+ {
+ name: "null tolerated without premium",
+ licenseTier: fleet.TierFree,
+ payload: `{"mdm":{"windows_enrollment":null}}`,
+ currentTeamID: &teamID,
+ expectSet: false,
+ expectActivity: false,
+ },
+ {
+ name: "clear with empty string allowed without premium",
+ licenseTier: fleet.TierFree,
+ payload: `{"mdm":{"windows_enrollment":{"default_fleet":""}}}`,
+ currentTeamID: &teamID,
+ expectSet: true,
+ expectSetTo: nil,
+ expectActivity: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ ds := new(mock.Store)
+ opts := &TestServerOpts{License: &fleet.LicenseInfo{Tier: tc.licenseTier}}
+ svc, ctx := newTestService(t, ds, nil, nil, opts)
+ ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin})
+
+ var activities []string
+ opts.ActivityMock.NewActivityFunc = func(ctx context.Context, user *activity_api.User, act activity_api.ActivityDetails) error {
+ activities = append(activities, act.ActivityName())
+ return nil
+ }
+
+ dsAppConfig := &fleet.AppConfig{
+ OrgInfo: fleet.OrgInfo{OrgName: "Test"},
+ ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"},
+ }
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return dsAppConfig, nil }
+ ds.SaveAppConfigFunc = func(ctx context.Context, conf *fleet.AppConfig) error { *dsAppConfig = *conf; return nil }
+ ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error { return nil }
+ ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { return []*fleet.VPPTokenDB{}, nil }
+ ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { return []*fleet.ABMToken{}, nil }
+ ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) {
+ if name == "Workstations" {
+ return &fleet.Team{ID: teamID, Name: "Workstations"}, nil
+ }
+ return nil, newNotFoundError()
+ }
+ ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
+ if tc.currentTeamID != nil {
+ return tc.currentTeamID, "Workstations", nil
+ }
+ return nil, "", nil
+ }
+ var setTo *uint
+ ds.SetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context, id *uint) error {
+ setTo = id
+ return nil
+ }
+
+ _, err := svc.ModifyAppConfig(ctx, []byte(tc.payload), fleet.ApplySpecOptions{})
+ if tc.expectErr != "" {
+ require.Error(t, err)
+ require.Contains(t, err.Error(), tc.expectErr)
+ require.False(t, ds.SaveAppConfigFuncInvoked)
+ return
+ }
+ require.NoError(t, err)
+ require.Equal(t, tc.expectSet, ds.SetWindowsEnrollmentDefaultFleetFuncInvoked)
+ if tc.expectSet {
+ require.Equal(t, tc.expectSetTo, setTo)
+ }
+ if tc.expectActivity {
+ require.Contains(t, activities, "edited_windows_enrollment_default_fleet")
+ } else {
+ require.NotContains(t, activities, "edited_windows_enrollment_default_fleet")
+ }
+ })
+ }
+}
diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go
index 45947c01b6..ac143b6f47 100644
--- a/server/service/mdm_test.go
+++ b/server/service/mdm_test.go
@@ -4962,10 +4962,17 @@ func TestProcessIncomingMDMCmdsDevDetailLinkage(t *testing.T) {
ds.WindowsHostLiteByHardwareSerialFunc = func(_ context.Context, _ string) (*fleet.HostLite, error) {
return nil, ¬FoundError{}
}
+ // On this branch the serial is persisted on the unlinked enrollment row so the orbit enrollment path can reverse-link it.
+ ds.MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFunc = func(_ context.Context, mdmDeviceID string, hardwareSerial string) error {
+ assert.Equal(t, testDeviceID, mdmDeviceID)
+ assert.Equal(t, testSerial, hardwareSerial)
+ return nil
+ }
cmds, err := svc.processIncomingMDMCmds(ctx, enrolledDevice, buildReqMsg(t, serialResults(testSerial)), RequestAuthStateTrusted)
require.NoError(t, err)
assert.True(t, ds.WindowsHostLiteByHardwareSerialFuncInvoked)
+ assert.True(t, ds.MDMWindowsSaveUnlinkedEnrollmentHardwareSerialFuncInvoked, "serial should be persisted for the reverse-link path")
assert.False(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
assert.Empty(t, enrolledDevice.HostUUID, "no link means HostUUID stays empty")
assert.True(t, hasGetForDevDetailSerial(cmds), "without a host match, the Get is reinjected for the next session")
diff --git a/server/service/microsoft_mdm.go b/server/service/microsoft_mdm.go
index 97c27b7147..ff6482d801 100644
--- a/server/service/microsoft_mdm.go
+++ b/server/service/microsoft_mdm.go
@@ -1712,8 +1712,16 @@ scan:
if !fleet.IsNotFound(err) {
svc.logger.ErrorContext(ctx, "windows mdm: host lookup by serial failed", "err", err, "device_id", enrolledDevice.MDMDeviceID)
ctxerr.Handle(ctx, err)
+ return false
}
// NotFound means the host hasn't enrolled in osquery yet (hosts row not created yet); we'll retry next session.
+ // Persist the serial on the unlinked enrollment row so the orbit enrollment path can reverse-link it (and
+ // apply the Windows enrollment default fleet) the moment the host record is created, before orbit's one-shot
+ // setup-experience init reads the host's fleet. Best-effort: on failure the Get is reinjected next session.
+ if saveErr := svc.ds.MDMWindowsSaveUnlinkedEnrollmentHardwareSerial(ctx, enrolledDevice.MDMDeviceID, serial); saveErr != nil {
+ svc.logger.WarnContext(ctx, "windows mdm: failed to persist serial on unlinked enrollment",
+ "err", saveErr, "device_id", enrolledDevice.MDMDeviceID)
+ }
return false
}
updated, err := osquery_utils.LinkWindowsHostMDMEnrollment(ctx, svc.logger, svc.ds, host.ID, host.UUID, enrolledDevice.MDMDeviceID)
diff --git a/server/service/orbit.go b/server/service/orbit.go
index 4192b71a7b..e0b25fb7d1 100644
--- a/server/service/orbit.go
+++ b/server/service/orbit.go
@@ -233,7 +233,7 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
isEndUserAuthRequired = team.Config.MDM.MacOSSetup.EnableEndUserAuthentication
}
- var euaDeviceID, euaUPN, euaIdpAcctUUID string
+ var euaDeviceID, euaIdpAcctUUID string
if isEndUserAuthRequired {
if hostInfo.HardwareUUID == "" {
@@ -268,11 +268,10 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
case platform == "windows" && euaToken != "":
// A Windows host already authenticated during MDM enrollment and the
// EUA token was passed by the MSI installer.
- upn, deviceID, idpAcctUUID, err := svc.processWindowsEUAToken(ctx, hostInfo.HardwareUUID, euaToken)
+ _, deviceID, idpAcctUUID, err := svc.processWindowsEUAToken(ctx, hostInfo.HardwareUUID, euaToken)
if err != nil {
return "", err
}
- euaUPN = upn
euaDeviceID = deviceID
euaIdpAcctUUID = idpAcctUUID
// Continue enrollment — do not return END_USER_AUTH_REQUIRED.
@@ -353,29 +352,30 @@ func (svc *Service) EnrollOrbit(ctx context.Context, hostInfo fleet.OrbitHostInf
}
if euaDeviceID != "" {
- updated, err := svc.ds.UpdateMDMWindowsEnrollmentsHostUUID(ctx, host.UUID, euaDeviceID)
- if err != nil {
+ // LinkWindowsHostMDMEnrollment performs the full post-link bookkeeping: SCIM user mapping, plus IdP device mapping, the DEP flag,
+ // and the Windows enrollment default fleet assignment for newly created hosts.
+ if _, err := osquery_utils.LinkWindowsHostMDMEnrollment(ctx, svc.logger, svc.ds, host.ID, host.UUID, euaDeviceID); err != nil {
svc.logger.ErrorContext(ctx, "failed to link windows mdm enrollment to orbit host via EUA token",
"err", err, "host_uuid", host.UUID, "device_id", euaDeviceID)
}
-
- if updated {
- scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, euaUPN, euaUPN)
- //nolint:gocritic // ignore ifElseChain
- if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows {
- svc.logger.ErrorContext(ctx, "failed to find SCIM user for EUA token enrollment",
- "err", err, "host_id", host.ID)
- } else if err == nil && scimUser != nil {
- if _, err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, host.ID, scimUser.ID); err != nil {
- svc.logger.ErrorContext(ctx, "failed to set SCIM user mapping for EUA token enrollment",
- "err", err, "host_id", host.ID)
- }
- } else {
- if _, err := svc.ds.DeleteHostSCIMUserMapping(ctx, host.ID); err != nil && !fleet.IsNotFound(err) {
- svc.logger.ErrorContext(ctx, "failed to delete SCIM user mapping for EUA token enrollment",
- "err", err, "host_id", host.ID)
- }
+ } else if platform == "windows" && appConfig.MDM.WindowsEnabledAndConfigured && hostInfo.HardwareSerial != "" {
+ // Reverse link: an automatic (user-driven) Windows MDM enrollment may already exist for this device, created before fleetd was
+ // installed. The OMA-DM session stores the device-reported SMBIOS serial on the unlinked enrollment row; link it now, before
+ // orbit fetches its config and runs its one-shot setup-experience init, so the Windows enrollment default fleet (and therefore
+ // the ESP's software and profiles) applies to this host from the start.
+ device, err := svc.ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerial(ctx, hostInfo.HardwareSerial)
+ switch {
+ case err != nil && !fleet.IsNotFound(err):
+ svc.logger.ErrorContext(ctx, "failed to look up unlinked windows mdm enrollment by serial",
+ "err", err, "host_uuid", host.UUID, "hardware_serial", hostInfo.HardwareSerial)
+ case err == nil:
+ if _, err := osquery_utils.LinkWindowsHostMDMEnrollment(ctx, svc.logger, svc.ds, host.ID, host.UUID, device.MDMDeviceID); err != nil {
+ svc.logger.ErrorContext(ctx, "failed to reverse-link windows mdm enrollment at orbit enroll",
+ "err", err, "host_uuid", host.UUID, "device_id", device.MDMDeviceID)
}
+ // A Windows orbit enrollment is not linked when it is not MDM, when it is already linked, or when it is a
+ // programmatic fleetd-first enrollment. Note this matches on serial alone, so the lookup refuses when several
+ // unlinked enrollments share the serial.
}
}
diff --git a/server/service/orbit_eua_test.go b/server/service/orbit_eua_test.go
index de5c99641a..870b0575b7 100644
--- a/server/service/orbit_eua_test.go
+++ b/server/service/orbit_eua_test.go
@@ -3,10 +3,13 @@ package service
import (
"context"
"database/sql"
+ "errors"
"log/slog"
"strings"
"testing"
+ "time"
+ hostidentity_types "github.com/fleetdm/fleet/v4/ee/pkg/hostidentity/types"
"github.com/fleetdm/fleet/v4/server/fleet"
microsoft_mdm "github.com/fleetdm/fleet/v4/server/mdm/microsoft"
"github.com/fleetdm/fleet/v4/server/mock"
@@ -289,3 +292,146 @@ func TestGenerateWindowsEUAToken(t *testing.T) {
require.False(t, ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFuncInvoked, "should not query db when cert manager is nil")
})
}
+
+// enrollOrbitStore wraps mock.Store to make EnrollOrbit mockable
+type enrollOrbitStore struct {
+ *mock.Store
+ enrollOrbitFunc func(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error)
+ enrollOrbitInvoked bool
+}
+
+func (s *enrollOrbitStore) EnrollOrbit(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error) {
+ s.enrollOrbitInvoked = true
+ return s.enrollOrbitFunc(ctx, opts...)
+}
+
+// TestEnrollOrbitWindowsReverseLink covers the reverse-link-by-serial branch of EnrollOrbit: a still-unlinked user-driven Windows
+// MDM enrollment whose device-reported serial matches the enrolling host gets linked (and the Windows enrollment default fleet
+// applied) before the enroll response returns; lookup failures never fail the enrollment.
+func TestEnrollOrbitWindowsReverseLink(t *testing.T) {
+ const testSerial = "SER-123"
+ defaultTeamID := uint(7)
+
+ hostInfo := fleet.OrbitHostInfo{
+ HardwareUUID: "hw-uuid-1",
+ HardwareSerial: testSerial,
+ Hostname: "DESKTOP-1",
+ Platform: "windows",
+ }
+
+ newSvc := func(t *testing.T) (fleet.Service, *enrollOrbitStore) {
+ inner := new(mock.Store)
+ ds := &enrollOrbitStore{
+ Store: inner,
+ enrollOrbitFunc: func(ctx context.Context, opts ...fleet.DatastoreEnrollOrbitOption) (*fleet.Host, error) {
+ return &fleet.Host{ID: 42, UUID: "host-uuid-1", Platform: "windows"}, nil
+ },
+ }
+ svc, _ := newTestService(t, ds, nil, nil)
+ inner.VerifyEnrollSecretFunc = func(ctx context.Context, secret string) (*fleet.EnrollSecret, error) {
+ return &fleet.EnrollSecret{Secret: secret}, nil
+ }
+ inner.GetHostIdentityCertByNameFunc = func(ctx context.Context, name string) (*hostidentity_types.HostIdentityCertificate, error) {
+ return nil, newNotFoundError()
+ }
+ inner.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ cfg := &fleet.AppConfig{}
+ cfg.MDM.WindowsEnabledAndConfigured = true
+ return cfg, nil
+ }
+ inner.MaybeAssociateHostWithScimUserFunc = func(ctx context.Context, hostID uint) error { return nil }
+ return svc, ds
+ }
+
+ t.Run("windows mdm not configured: no reverse-link attempted", func(t *testing.T) {
+ svc, ds := newSvc(t)
+ ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
+ return &fleet.AppConfig{}, nil
+ }
+
+ nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
+ require.NoError(t, err)
+ require.NotEmpty(t, nodeKey)
+ require.False(t, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFuncInvoked)
+ })
+
+ t.Run("no unlinked enrollment: enrollment succeeds without linking", func(t *testing.T) {
+ svc, ds := newSvc(t)
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc = func(ctx context.Context, serial string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ require.Equal(t, testSerial, serial)
+ return nil, newNotFoundError()
+ }
+
+ nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
+ require.NoError(t, err)
+ require.NotEmpty(t, nodeKey)
+ require.True(t, ds.enrollOrbitInvoked)
+ require.True(t, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFuncInvoked)
+ require.False(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
+ })
+
+ t.Run("lookup error is non-fatal", func(t *testing.T) {
+ svc, ds := newSvc(t)
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc = func(ctx context.Context, serial string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return nil, errors.New("db unavailable")
+ }
+
+ nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
+ require.NoError(t, err, "a failed reverse-link lookup must not fail enrollment")
+ require.NotEmpty(t, nodeKey)
+ require.False(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
+ })
+
+ t.Run("unlinked enrollment found: linked and default fleet assigned before returning", func(t *testing.T) {
+ svc, ds := newSvc(t)
+ device := &fleet.MDMWindowsEnrolledDevice{
+ ID: 1,
+ MDMDeviceID: "device-1",
+ MDMEnrollUserID: "user@example.com", // valid UPN: user-driven enrollment
+ CreatedAt: time.Now().UTC().Add(-2 * time.Minute),
+ }
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithHardwareSerialFunc = func(ctx context.Context, serial string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return device, nil
+ }
+ ds.UpdateMDMWindowsEnrollmentsHostUUIDFunc = func(ctx context.Context, hostUUID string, deviceID string) (bool, error) {
+ require.Equal(t, "host-uuid-1", hostUUID)
+ require.Equal(t, "device-1", deviceID)
+ return true, nil
+ }
+ ds.MDMWindowsGetEnrolledDeviceWithDeviceIDFunc = func(ctx context.Context, deviceID string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return device, nil
+ }
+ ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
+ return &defaultTeamID, "Workstations", nil
+ }
+ ds.HostLiteByIDFunc = func(ctx context.Context, id uint) (*fleet.HostLite, error) {
+ return &fleet.HostLite{ID: id, CreatedAt: time.Now().UTC()}, nil
+ }
+ var assignedTeamID *uint
+ ds.AddHostsToTeamFunc = func(ctx context.Context, params *fleet.AddHostsToTeamParams) error {
+ assignedTeamID = params.TeamID
+ require.Equal(t, []uint{42}, params.HostIDs)
+ return nil
+ }
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (fleet.MDMProfilesUpdates, error) {
+ return fleet.MDMProfilesUpdates{}, nil
+ }
+ ds.ReplaceHostDeviceMappingFunc = func(ctx context.Context, hostID uint, mappings []*fleet.HostDeviceMapping, source string) error {
+ return nil
+ }
+ ds.ScimUserByUserNameOrEmailFunc = func(ctx context.Context, name string, email string) (*fleet.ScimUser, error) {
+ return nil, newNotFoundError()
+ }
+ ds.DeleteHostSCIMUserMappingFunc = func(ctx context.Context, hostID uint) ([]fleet.ActivityTypeResentCertificate, error) {
+ return nil, nil
+ }
+
+ nodeKey, err := svc.EnrollOrbit(t.Context(), hostInfo, "secret", "")
+ require.NoError(t, err)
+ require.NotEmpty(t, nodeKey)
+ require.True(t, ds.UpdateMDMWindowsEnrollmentsHostUUIDFuncInvoked)
+ require.True(t, ds.AddHostsToTeamFuncInvoked, "default fleet must be assigned before EnrollOrbit returns")
+ require.NotNil(t, assignedTeamID)
+ require.Equal(t, defaultTeamID, *assignedTeamID)
+ })
+}
diff --git a/server/service/osquery_utils/queries.go b/server/service/osquery_utils/queries.go
index d4bc9ed873..24be13f61c 100644
--- a/server/service/osquery_utils/queries.go
+++ b/server/service/osquery_utils/queries.go
@@ -17,6 +17,7 @@ import (
"github.com/fleetdm/fleet/v4/pkg/str"
"github.com/fleetdm/fleet/v4/server/config"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/contexts/publicip"
@@ -3214,6 +3215,12 @@ func LinkWindowsHostMDMEnrollment(ctx context.Context, logger *slog.Logger, ds f
return updated, nil
}
device.HostUUID = hostUUID // in case the read was stale due to replication lag
+ // Newly created hosts from user-driven enrollments are assigned the configured default fleet.
+ if err := maybeAssignWindowsEnrollmentDefaultFleet(ctx, logger, ds, hostID, device); err != nil {
+ // Best-effort. In the unlikely event of a failure, the host remains in Unassigned fleet.
+ logger.ErrorContext(ctx, "failed to assign windows enrollment default fleet", "err", err, "host_id", hostID)
+ ctxerr.Handle(ctx, err)
+ }
// Update the host's MDM enrolled flags to show it as a manual enrollment so it doesn't take two full refreshes to
// reflect this state.
if device.MDMNotInOOBE {
@@ -3251,6 +3258,44 @@ func LinkWindowsHostMDMEnrollment(ctx context.Context, logger *slog.Logger, ds f
return updated, nil
}
+// maybeAssignWindowsEnrollmentDefaultFleet moves a host to the configured Windows enrollment default fleet iff all of: the linked
+// enrollment is user-driven, a default fleet is configured, the host has no fleet, and the host record was created at or after
+// the enrollment row (MDM-first ordering, as in Autopilot, where Fleet installs fleetd after MDM enrollment). Hosts that enrolled
+// fleetd first keep the fleet their enroll secret chose. Pre-existing hosts are never moved, including hosts deliberately parked
+// in Unassigned, matching macOS ABM re-enrollment behavior.
+func maybeAssignWindowsEnrollmentDefaultFleet(ctx context.Context, logger *slog.Logger, ds fleet.Datastore, hostID uint, device *fleet.MDMWindowsEnrolledDevice) error {
+ teamID, teamName, err := ds.GetWindowsEnrollmentDefaultFleet(ctx)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get windows enrollment default fleet")
+ }
+ if teamID == nil {
+ return nil
+ }
+ // replica lag could permanently lose the assignment by a NotFound on a hosts row that orbit enroll inserted seconds ago.
+ ctxPrimary := ctxdb.RequirePrimary(ctx, true)
+ host, err := ds.HostLiteByID(ctxPrimary, hostID)
+ if err != nil {
+ return ctxerr.Wrap(ctx, err, "get host for windows enrollment default fleet assignment")
+ }
+ if host.TeamID != nil {
+ return nil
+ }
+ if host.CreatedAt.Before(device.CreatedAt) {
+ // The host existed before this MDM enrollment: keep its fleet (Unassigned included).
+ return nil
+ }
+ if err := ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(teamID, []uint{hostID})); err != nil {
+ return ctxerr.Wrap(ctx, err, "assign windows enrollment default fleet")
+ }
+ // Same side effect as a manual transfer so the new fleet's profiles reconcile immediately
+ if _, err := ds.BulkSetPendingMDMHostProfiles(ctx, []uint{hostID}, nil, nil, nil); err != nil {
+ return ctxerr.Wrap(ctx, err, "bulk set pending profiles after windows enrollment default fleet assignment")
+ }
+ logger.InfoContext(ctx, "assigned windows enrollment default fleet",
+ "host_id", hostID, "team_id", *teamID, "team_name", teamName, "mdm_device_id", device.MDMDeviceID)
+ return nil
+}
+
var luksVerifyQuery = DetailQuery{
Platforms: fleet.HostLinuxOSs,
Discovery: fmt.Sprintf(
diff --git a/server/service/osquery_utils/queries_test.go b/server/service/osquery_utils/queries_test.go
index f5d39b3a29..5a1edda4a2 100644
--- a/server/service/osquery_utils/queries_test.go
+++ b/server/service/osquery_utils/queries_test.go
@@ -22,6 +22,7 @@ import (
"github.com/WatchBeam/clock"
"github.com/fleetdm/fleet/v4/server/config"
+ "github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/contexts/publicip"
"github.com/fleetdm/fleet/v4/server/fleet"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
@@ -2784,6 +2785,9 @@ func TestDirectIngestMDMDeviceIDWindows(t *testing.T) {
ds.UpdateMDMInstalledFromDEPFunc = func(ctx context.Context, hostID uint, enrolledFromDEP bool) error {
return nil
}
+ ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
+ return nil, "", nil
+ }
baseEnrolledDeviceToReturn := fleet.MDMWindowsEnrolledDevice{
ID: 1,
@@ -4582,3 +4586,88 @@ func TestRpmLastOpenedAt(t *testing.T) {
}
}
}
+
+func TestMaybeAssignWindowsEnrollmentDefaultFleet(t *testing.T) {
+ ctx := t.Context()
+ logger := slog.New(slog.DiscardHandler)
+ defaultTeamID := uint(7)
+ enrollmentCreatedAt := time.Now().UTC()
+
+ userDrivenDevice := &fleet.MDMWindowsEnrolledDevice{
+ ID: 1,
+ MDMDeviceID: "device-1",
+ MDMEnrollUserID: "user@example.com",
+ CreatedAt: enrollmentCreatedAt,
+ }
+
+ testCases := []struct {
+ name string
+ defaultTeamID *uint
+ hostTeamID *uint
+ hostCreatedAt time.Time
+ expectTransfer bool
+ }{
+ {
+ name: "no default fleet configured",
+ defaultTeamID: nil,
+ hostCreatedAt: enrollmentCreatedAt.Add(2 * time.Minute),
+ expectTransfer: false,
+ },
+ {
+ name: "new host gets the default fleet",
+ defaultTeamID: &defaultTeamID,
+ hostCreatedAt: enrollmentCreatedAt.Add(2 * time.Minute),
+ expectTransfer: true,
+ },
+ {
+ name: "host created at the same time as the enrollment gets the default fleet",
+ defaultTeamID: &defaultTeamID,
+ hostCreatedAt: enrollmentCreatedAt,
+ expectTransfer: true,
+ },
+ {
+ name: "host created before the enrollment (incl. parked Unassigned) stays put",
+ defaultTeamID: &defaultTeamID,
+ hostCreatedAt: enrollmentCreatedAt.Add(-time.Minute),
+ expectTransfer: false,
+ },
+ {
+ name: "host already on a fleet stays put",
+ defaultTeamID: &defaultTeamID,
+ hostTeamID: new(uint(3)),
+ hostCreatedAt: enrollmentCreatedAt.Add(2 * time.Minute),
+ expectTransfer: false,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ ds := new(mock.Store)
+ ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
+ return tc.defaultTeamID, "Workstations", nil
+ }
+ ds.HostLiteByIDFunc = func(ctx context.Context, id uint) (*fleet.HostLite, error) {
+ require.True(t, ctxdb.IsPrimaryRequired(ctx), "host read must hit the primary (read-after-write with orbit enroll)")
+ return &fleet.HostLite{ID: id, TeamID: tc.hostTeamID, CreatedAt: tc.hostCreatedAt}, nil
+ }
+ ds.AddHostsToTeamFunc = func(ctx context.Context, params *fleet.AddHostsToTeamParams) error {
+ require.NotNil(t, params.TeamID)
+ require.Equal(t, defaultTeamID, *params.TeamID)
+ require.Equal(t, []uint{42}, params.HostIDs)
+ return nil
+ }
+ ds.BulkSetPendingMDMHostProfilesFunc = func(ctx context.Context, hostIDs []uint, teamIDs []uint, profileUUIDs []string, hostUUIDs []string) (updates fleet.MDMProfilesUpdates, err error) {
+ require.Equal(t, []uint{42}, hostIDs)
+ return fleet.MDMProfilesUpdates{}, nil
+ }
+
+ err := maybeAssignWindowsEnrollmentDefaultFleet(ctx, logger, ds, 42, userDrivenDevice)
+ require.NoError(t, err)
+ require.Equal(t, tc.expectTransfer, ds.AddHostsToTeamFuncInvoked)
+ require.Equal(t, tc.expectTransfer, ds.BulkSetPendingMDMHostProfilesFuncInvoked)
+ if tc.defaultTeamID == nil {
+ require.False(t, ds.HostLiteByIDFuncInvoked, "no host lookup needed when no default is configured")
+ }
+ })
+ }
+}
diff --git a/server/service/testing_utils_test.go b/server/service/testing_utils_test.go
index 92c8f36c5f..f4b3e29faf 100644
--- a/server/service/testing_utils_test.go
+++ b/server/service/testing_utils_test.go
@@ -107,6 +107,12 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf
return &fleet.ConditionalAccessMicrosoftIntegration{}, nil
}
}
+ // Config reads hydrate the Windows enrollment default fleet from its config row.
+ if mockDS.GetWindowsEnrollmentDefaultFleetFunc == nil {
+ mockDS.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
+ return nil, "", nil
+ }
+ }
}
lic := &fleet.LicenseInfo{Tier: fleet.TierFree}
diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt
index da7397f269..03f7a20875 100644
--- a/tools/cloner-check/generated_files/appconfig.txt
+++ b/tools/cloner-check/generated_files/appconfig.txt
@@ -193,6 +193,11 @@ github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Set bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Valid bool
github.com/fleetdm/fleet/v4/pkg/optjson/Slice[string] Value []string
github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsEntraClientIDs optjson.Slice[string]
+github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsEnrollment optjson.Any[github.com/fleetdm/fleet/v4/server/fleet.WindowsEnrollment]
+github.com/fleetdm/fleet/v4/pkg/optjson/Any[github.com/fleetdm/fleet/v4/server/fleet.WindowsEnrollment] Set bool
+github.com/fleetdm/fleet/v4/pkg/optjson/Any[github.com/fleetdm/fleet/v4/server/fleet.WindowsEnrollment] Valid bool
+github.com/fleetdm/fleet/v4/pkg/optjson/Any[github.com/fleetdm/fleet/v4/server/fleet.WindowsEnrollment] Value fleet.WindowsEnrollment
+github.com/fleetdm/fleet/v4/server/fleet/WindowsEnrollment DefaultFleet string
github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsEnabledAndConfigured bool
github.com/fleetdm/fleet/v4/server/fleet/MDM EnableDiskEncryption optjson.Bool
github.com/fleetdm/fleet/v4/server/fleet/MDM HostNameTemplate optjson.String
diff --git a/tools/cloner-check/generated_files/windowsenrollmentdefaultfleet.txt b/tools/cloner-check/generated_files/windowsenrollmentdefaultfleet.txt
new file mode 100644
index 0000000000..d51d374ba8
--- /dev/null
+++ b/tools/cloner-check/generated_files/windowsenrollmentdefaultfleet.txt
@@ -0,0 +1,2 @@
+github.com/fleetdm/fleet/v4/server/fleet/WindowsEnrollmentDefaultFleet FleetID *uint
+github.com/fleetdm/fleet/v4/server/fleet/WindowsEnrollmentDefaultFleet FleetName string
diff --git a/tools/cloner-check/main.go b/tools/cloner-check/main.go
index d474e44c9d..5dc32f31f0 100644
--- a/tools/cloner-check/main.go
+++ b/tools/cloner-check/main.go
@@ -51,6 +51,7 @@ var cacheableItems = []fleet.Cloner{
&fleet.MDMProfileSpec{},
&fleet.MDMConfigAsset{},
&fleet.YaraRule{},
+ &fleet.WindowsEnrollmentDefaultFleet{},
// TeamAgentOptions is not in the list because it is a json.RawMessage, no fields can change.
// Same for ResultCountForQuery, it's just an int.
}