Add default fleet for new Windows MDM enrollments (#41787) (#49922)

Demo: https://www.youtube.com/watch?v=cWxZlu9WuwA
Guide updates: https://github.com/fleetdm/fleet/pull/49603/changes

IT admins can configure the fleet that hosts enrolling through
user-driven Windows MDM enrollment (Windows Autopilot, Entra join) are
automatically assigned to, via the Windows MDM settings page, the
mdm.windows_enrollment.default_fleet config setting, or GitOps.

- New windows_enrollment_config row stores the default team; the config
API surfaces it by fleet name and hydrates reads from the row so team
renames and deletions never serve a stale name. Deleting the fleet
clears the setting.
- New edited_windows_enrollment_default_fleet activity, emitted only
when the value changes.
- The OMA-DM session persists the device-reported SMBIOS serial on
still-unlinked enrollments, and orbit enrollment reverse-links by that
serial and assigns the default fleet before orbit's one-shot
setup-experience init, so the default fleet's software, scripts, and
profiles apply during the Autopilot ESP. The DevDetail and osquery link
paths keep the same assignment as fallbacks, and the EUA-token link path
now shares the same post-link bookkeeping.
- Hosts are only assigned when new to Fleet in this enrollment cycle:
existing hosts, including ones parked in Unassigned, keep their fleet on
re-enrollment, matching macOS ABM behavior.
- GitOps defers applying the setting until teams declared in the same
run are created, and fleetctl generate-gitops exports it.
- Windows MDM settings page redesign per Figma: programmatic enrollment
toggle, User driven enrollment section with the Entra-gated Default
fleet dropdown, and a Migration section.

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

# 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.

- [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

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

## Database migrations

- [x] Checked schema for all modified table for columns that will
auto-update timestamps during migration.
- [x] Ensured the correct collation is explicitly set for character
columns (`COLLATE utf8mb4_unicode_ci`).

## New Fleet configuration settings

- [x] Verified that the setting is exported via `fleetctl
generate-gitops`
- [x] Verified the setting is documented in a separate PR to [the GitOps
documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485)
- [x] Verified that the setting is cleared on the server if it is not
supplied in a YAML file (or that it is documented as being optional)
- [x] Verified that any relevant UI is disabled when GitOps mode is
enabled

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added support for assigning a default Fleet Premium fleet to new
Windows MDM enrollments, including Autopilot and Entra join.
* Default-fleet settings can be configured, cleared, and managed through
Windows MDM settings and GitOps.
* Assigned fleet software, scripts, and profiles can apply during
out-of-box setup.
  * Added activity-feed visibility for default-fleet changes.
  * Improved Windows enrollment matching using hardware serial numbers.

* **Documentation**
  * Documented default-fleet assignment for Windows enrollment.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-08-04 16:30:02 -05:00
committed by GitHub
parent bd601fff84
commit a4af4d896c
56 changed files with 1936 additions and 197 deletions
+1
View File
@@ -1215,6 +1215,7 @@ func (cmd *GenerateGitopsCommand) generateMDM(mdm *fleet.MDM) (map[string]interf
}
if cmd.AppConfig.License.IsPremium() {
result[jsonFieldName(t, "AppleBusinessManager")] = mdm.AppleBusinessManager
result[jsonFieldName(t, "WindowsEnrollment")] = mdm.WindowsEnrollment
vppTokens, err := cmd.Client.GetVPPTokens()
if err != nil {
fmt.Fprintf(cmd.CLI.App.ErrWriter, "Error fetching VPP tokens: %s\n", err)
@@ -1235,6 +1235,20 @@ func TestGenerateOrgSettings(t *testing.T) {
// Compare.
require.Equal(t, expectedAppConfig, orgSettings)
// An unset mdm.windows_enrollment must serialize as null rather than an object with an empty default_fleet.
// Applying null is a no-op; an empty default_fleet would clear whatever default the target server has set.
appConfig.MDM.WindowsEnrollment = optjson.Any[fleet.WindowsEnrollment]{}
orgSettingsRaw, err = cmd.generateOrgSettings()
require.NoError(t, err)
b, err = yamlMarshalRenamed(orgSettingsRaw)
require.NoError(t, err)
require.NoError(t, yaml.Unmarshal(b, &orgSettings))
mdmSettings, ok := orgSettings["mdm"].(map[string]any)
require.True(t, ok)
we, present := mdmSettings["windows_enrollment"]
require.True(t, present, "windows_enrollment key should still be emitted")
require.Nil(t, we, "unset windows_enrollment must serialize as null so applying it is a no-op")
}
func TestGenerateOrgSettingsMaskedGoogleCalendarApiKey(t *testing.T) {
+99
View File
@@ -146,6 +146,8 @@ func gitopsCommand() *cli.Command {
var teamDryRunAssumptions *fleet.TeamSpecsDryRunAssumptions
var abmTeams, vppTeams, missingVPPTeams []string
var hasMissingABMTeam, usesLegacyABMConfig bool
var windowsEnrollmentDefaultFleet string
var windowsEnrollmentFleetMissing bool
type missingVPPTeamWithApps struct {
config *spec.GitOps
vppApps []*fleet.TeamSpecAppStoreApp
@@ -558,6 +560,24 @@ func gitopsCommand() *cli.Command {
}
}
// Runs outside the multi-file gate above: the resolved default fleet name is also needed by the --delete-other-fleets guard
// below, even on single-file runs.
if isGlobalConfig && appConfig.License.IsPremium() {
windowsEnrollmentDefaultFleet, windowsEnrollmentFleetMissing, err = checkWindowsEnrollmentAssignment(config, fleetClient)
if err != nil {
return err
}
if windowsEnrollmentFleetMissing {
if mdm, ok := config.OrgSettings["mdm"]; ok {
if mdmMap, ok := mdm.(map[string]any); ok {
// The referenced fleet may be created later in this run. Deleting the key makes the first apply a no-op for this
// setting (an omitted key keeps the stored value); it is applied separately after teams are processed.
delete(mdmMap, "windows_enrollment")
}
}
}
}
// Teams need a VPP token before VPP apps can be applied. When some VPP
// teams don't exist yet, the VPP config is temporarily removed from the
// global config, which clears all VPP token assignments. To avoid
@@ -648,6 +668,11 @@ func gitopsCommand() *cli.Command {
return err
}
}
if windowsEnrollmentDefaultFleet != "" && windowsEnrollmentFleetMissing {
if err = applyWindowsEnrollmentAssignmentIfNeeded(c, teamNames, windowsEnrollmentDefaultFleet, flDryRun, fleetClient); err != nil {
return err
}
}
// Now that VPP tokens have been assigned, we can apply VPP apps to the new team.
// For simplicity, we simply re-apply the entire config. This only happens once when the team is created.
for _, teamWithApps := range missingVPPTeamsWithApps {
@@ -687,6 +712,9 @@ func gitopsCommand() *cli.Command {
if slices.Contains(vppTeams, team.Name) {
return fmt.Errorf("volume_purchasing_program team %s cannot be deleted", team.Name)
}
if windowsEnrollmentDefaultFleet != "" && norm.NFC.String(team.Name) == windowsEnrollmentDefaultFleet {
return fmt.Errorf("windows_enrollment default_fleet %s cannot be deleted", team.Name)
}
if flDryRun {
_, _ = fmt.Fprintf(c.App.Writer, "[!] would've deleted team %s\n", team.Name)
} else {
@@ -1277,6 +1305,77 @@ func applyABMTokenAssignmentIfNeeded(
return nil
}
// checkWindowsEnrollmentAssignment reads org_settings.mdm.windows_enrollment.default_fleet and reports whether the referenced
// fleet doesn't exist in Fleet yet (it may be created later in the same gitops run). Returns an empty name when the section or
// the value is absent.
func checkWindowsEnrollmentAssignment(config *spec.GitOps, fleetClient *service.Client) (defaultFleet string, missingTeam bool, err error) {
mdm, ok := config.OrgSettings["mdm"]
if !ok {
return "", false, nil
}
mdmMap, ok := mdm.(map[string]any)
if !ok {
return "", false, nil
}
we, ok := mdmMap["windows_enrollment"]
if !ok {
return "", false, nil
}
// A wrong shape is passed through untouched so the server-side validation reports it.
weMap, ok := we.(map[string]any)
if !ok {
return "", false, nil
}
name, _ := weMap["default_fleet"].(string)
if name == "" {
return "", false, nil
}
// normalize for Unicode support
name = norm.NFC.String(name)
teams, err := fleetClient.ListTeams("")
if err != nil {
return "", false, err
}
for _, tm := range teams {
if norm.NFC.String(tm.Name) == name {
return name, false, nil
}
}
return name, true, nil
}
// applyWindowsEnrollmentAssignmentIfNeeded applies the deferred org_settings.mdm.windows_enrollment.default_fleet once teams have
// been processed, failing if the referenced fleet still doesn't exist.
func applyWindowsEnrollmentAssignmentIfNeeded(
ctx *cli.Context,
teamNames []string,
defaultFleet string,
flDryRun bool,
fleetClient *service.Client,
) error {
knownTeams, err := knownTeamNamesForTokenAssignment(teamNames, fleetClient)
if err != nil {
return err
}
if _, ok := knownTeams[norm.NFC.String(defaultFleet)]; !ok {
return fmt.Errorf("windows_enrollment default_fleet %q not found in team configs", defaultFleet)
}
if flDryRun {
_, _ = fmt.Fprint(ctx.App.Writer, "[!] would apply Windows enrollment default fleet\n")
return nil
}
_, _ = fmt.Fprintf(ctx.App.Writer, "[+] applying Windows enrollment default fleet\n")
appConfigUpdate := map[string]map[string]any{
"mdm": {
"windows_enrollment": map[string]any{"default_fleet": defaultFleet},
},
}
if err := fleetClient.ApplyAppConfig(appConfigUpdate, fleet.ApplySpecOptions{}); err != nil {
return fmt.Errorf("applying fleet config: %w", err)
}
return nil
}
func checkVPPTeamAssignments(config *spec.GitOps, fleetClient *service.Client) (
vppTeams []string, missingTeams []string, err error,
) {
+219
View File
@@ -4674,6 +4674,225 @@ software:
}
}
func TestGitOpsWindowsEnrollment(t *testing.T) {
global := func(mdm string) string {
return fmt.Sprintf(`
controls:
queries:
policies:
agent_options:
software:
org_settings:
server_settings:
server_url: "https://foo.example.com"
org_info:
org_name: GitOps Test
secrets:
- secret: "global"
mdm:
%s
`, mdm)
}
team := func(name string) string {
return fmt.Sprintf(`
name: %s
team_settings:
secrets:
- secret: "%s-secret"
agent_options:
controls:
policies:
queries:
software:
`, name, name)
}
workstations := team("💻 Workstations")
cases := []struct {
name string
cfgs []string
extraArgs []string
seedTeamName string
dryRunAssertion func(t *testing.T, out string, defaultTeamID *uint, err error)
realRunAssertion func(t *testing.T, out string, defaultTeamID *uint, err error)
}{
{
name: "delete-other-fleets cannot delete the default fleet",
cfgs: []string{
global(`windows_enrollment:
default_fleet: "💻 Workstations"`),
team("Other team"),
},
extraArgs: []string{"--delete-other-fleets"},
seedTeamName: "💻 Workstations",
dryRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.ErrorContains(t, err, "windows_enrollment default_fleet 💻 Workstations cannot be deleted")
},
realRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.ErrorContains(t, err, "windows_enrollment default_fleet 💻 Workstations cannot be deleted")
},
},
{
name: "fleet declared in the same run",
cfgs: []string{
global(`windows_enrollment:
default_fleet: "💻 Workstations"`),
workstations,
},
dryRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.NoError(t, err)
assert.Nil(t, defaultTeamID, "dry run must not persist the default fleet")
assert.Contains(t, out, "[!] would apply Windows enrollment default fleet")
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.NoError(t, err)
assert.NotNil(t, defaultTeamID)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "unknown fleet errors",
cfgs: []string{
global(`windows_enrollment:
default_fleet: "Ghosts"`),
workstations,
},
dryRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.ErrorContains(t, err, `windows_enrollment default_fleet "Ghosts" not found in team configs`)
assert.Nil(t, defaultTeamID)
},
realRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.ErrorContains(t, err, `windows_enrollment default_fleet "Ghosts" not found in team configs`)
assert.Nil(t, defaultTeamID)
},
},
{
name: "empty value is accepted and clears",
cfgs: []string{
global(`windows_enrollment:
default_fleet: ""`),
workstations,
},
dryRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.NoError(t, err)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.NoError(t, err)
assert.Nil(t, defaultTeamID)
assert.Contains(t, out, "[!] gitops succeeded")
},
},
{
name: "omitted key is a no-op",
cfgs: []string{
global(""),
workstations,
},
dryRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.NoError(t, err)
assert.Contains(t, out, "[!] gitops dry run succeeded")
},
realRunAssertion: func(t *testing.T, out string, defaultTeamID *uint, err error) {
require.NoError(t, err)
assert.Nil(t, defaultTeamID)
assert.NotContains(t, out, "applying Windows enrollment default fleet")
assert.Contains(t, out, "[!] gitops succeeded")
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
ds, _, savedTeams := testing_utils.SetupFullGitOpsPremiumServer(t)
ds.GetLabelSpecsFunc = func(ctx context.Context, filter fleet.TeamFilter) ([]*fleet.LabelSpec, error) {
return nil, nil
}
ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) {
return []*fleet.ABMToken{}, nil
}
ds.GetABMTokenCountFunc = func(ctx context.Context) (int, error) {
return 0, nil
}
ds.SaveABMTokenFunc = func(ctx context.Context, tok *fleet.ABMToken) error {
return nil
}
ds.TeamsSummaryFunc = func(ctx context.Context) ([]*fleet.TeamSummary, error) {
var res []*fleet.TeamSummary
for _, tm := range savedTeams {
res = append(res, &fleet.TeamSummary{Name: (*tm).Name, ID: (*tm).ID})
}
return res, nil
}
ds.DeleteIconsAssociatedWithTitlesWithoutInstallersFunc = func(ctx context.Context, teamID uint) error {
return nil
}
ds.GetCertificateTemplatesByTeamIDFunc = func(ctx context.Context, teamID uint, options fleet.ListOptions) ([]*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error) {
return []*fleet.CertificateTemplateResponseSummary{}, &fleet.PaginationMetadata{}, nil
}
ds.ListCertificateAuthoritiesFunc = func(ctx context.Context) ([]*fleet.CertificateAuthoritySummary, error) {
return nil, nil
}
ds.VerifyAppleConfigProfileScopesDoNotConflictFunc = func(ctx context.Context, cps []*fleet.MDMAppleConfigProfile) error {
return nil
}
if tt.seedTeamName != "" {
seeded := &fleet.Team{ID: 99, Name: tt.seedTeamName}
savedTeams[tt.seedTeamName] = &seeded
}
// Track the persisted default fleet, overriding the helper's stateful default so the test can assert on it directly.
var defaultTeamID *uint
ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
if defaultTeamID == nil {
return nil, "", nil
}
for _, tm := range savedTeams {
if (*tm).ID == *defaultTeamID {
return defaultTeamID, (*tm).Name, nil
}
}
return nil, "", nil
}
ds.SetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context, teamID *uint) error {
defaultTeamID = teamID
return nil
}
args := []string{"gitops"}
for _, cfg := range tt.cfgs {
if cfg != "" {
tmpFile, err := os.CreateTemp(t.TempDir(), "*.yml")
require.NoError(t, err)
_, err = tmpFile.WriteString(cfg)
require.NoError(t, err)
args = append(args, "-f", tmpFile.Name())
}
}
args = append(args, tt.extraArgs...)
// Dry run
out, err := runAppNoChecks(append(args, "--dry-run"))
tt.dryRunAssertion(t, out.String(), defaultTeamID, err)
if t.Failed() {
t.FailNow()
}
// Real run
out, err = runAppNoChecks(args)
tt.realRunAssertion(t, out.String(), defaultTeamID, err)
// Second real run, now that all the teams are saved
out, err = runAppNoChecks(args)
tt.realRunAssertion(t, out.String(), defaultTeamID, err)
})
}
}
func TestGitOpsWindowsMigration(t *testing.T) {
cases := []struct {
file string
@@ -196,6 +196,7 @@
"require_all_software_windows": false,
"software": null
},
"windows_enrollment": null,
"windows_settings": {
"custom_settings": null,
"configuration_profiles": null,
@@ -168,6 +168,7 @@
"require_all_software_windows": false,
"software": null
},
"windows_enrollment": null,
"windows_settings": {
"custom_settings": null,
"configuration_profiles": null,
@@ -103,6 +103,7 @@ spec:
require_all_software_windows: false
macos_script:
software:
windows_enrollment: null
windows_settings:
custom_settings: null
configuration_profiles: null
@@ -103,6 +103,7 @@ spec:
require_all_software_windows: false
macos_script:
software:
windows_enrollment: null
windows_settings:
custom_settings: null
configuration_profiles: null
@@ -146,6 +146,7 @@
"require_all_software_windows": false,
"software": null
},
"windows_enrollment": null,
"windows_settings": {
"custom_settings": null,
"configuration_profiles": null,
@@ -103,6 +103,7 @@ spec:
require_all_software_windows: false
macos_script:
software:
windows_enrollment: null
windows_settings:
custom_settings: null
configuration_profiles: null
@@ -291,6 +291,9 @@
"custom_settings": []
},
"volume_purchasing_program": null,
"windows_enrollment": {
"default_fleet": "💻 Workstations"
},
"android_enabled_and_configured": true,
"android_settings": {
"custom_settings": []
@@ -103,6 +103,8 @@ mdm:
- "\U0001F4BB\U0001F423 Workstations (canary)"
- "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
- "\U0001F4F1\U0001F510 Personal mobile devices"
windows_enrollment:
default_fleet: "\U0001F4BB Workstations"
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url_dark_mode: http://some-org-logo-url.com
@@ -101,6 +101,8 @@ mdm:
- "\U0001F4BB\U0001F423 Workstations (canary)"
- "\U0001F4F1\U0001F3E2 Company-owned mobile devices"
- "\U0001F4F1\U0001F510 Personal mobile devices"
windows_enrollment:
default_fleet: "\U0001F4BB Workstations"
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url_dark_mode: http://some-org-logo-url.com
@@ -143,6 +143,8 @@ org_settings:
- "📱🏢 Company-owned mobile devices"
- "📱🔐 Personal mobile devices"
location: Fleet Device Management Inc.
windows_enrollment:
default_fleet: "💻 Workstations"
org_info:
contact_url: https://fleetdm.com/company/contact
org_logo_url_dark_mode: http://some-org-logo-url.com
@@ -62,6 +62,7 @@ spec:
custom_settings: null
apple_settings:
configuration_profiles: null
windows_enrollment: null
windows_settings:
custom_settings: null
configuration_profiles: null
@@ -62,6 +62,7 @@ spec:
custom_settings: null
apple_settings:
configuration_profiles: null
windows_enrollment: null
windows_settings:
custom_settings: null
configuration_profiles: null
@@ -100,6 +100,9 @@ func RunServerWithMockedDS(t *testing.T, opts ...*service.TestServerOpts) (*http
ds.ConditionalAccessMicrosoftGetFunc = func(ctx context.Context) (*fleet.ConditionalAccessMicrosoftIntegration, error) {
return &fleet.ConditionalAccessMicrosoftIntegration{}, nil
}
ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
return nil, "", nil
}
ds.NewGlobalPolicyFunc = func(ctx context.Context, authorID *uint, args fleet.PolicyPayload) (*fleet.Policy, error) {
return &fleet.Policy{
PolicyData: fleet.PolicyData{
@@ -515,6 +518,23 @@ func SetupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
}
return nil, &notFoundError{}
}
// Stateful default for the Windows enrollment default fleet config row. Tests can override.
var windowsEnrollmentDefaultTeamID *uint
ds.GetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context) (*uint, string, error) {
if windowsEnrollmentDefaultTeamID == nil {
return nil, "", nil
}
for _, tm := range savedTeams {
if (*tm).ID == *windowsEnrollmentDefaultTeamID {
return windowsEnrollmentDefaultTeamID, (*tm).Name, nil
}
}
return nil, "", nil
}
ds.SetWindowsEnrollmentDefaultFleetFunc = func(ctx context.Context, teamID *uint) error {
windowsEnrollmentDefaultTeamID = teamID
return nil
}
ds.TeamByFilenameFunc = func(ctx context.Context, filename string) (*fleet.Team, error) {
for _, tm := range savedTeams {
if (*tm).Filename != nil && *(*tm).Filename == filename {