diff --git a/changes/41787-windows-enrollment-default-fleet b/changes/41787-windows-enrollment-default-fleet new file mode 100644 index 0000000000..1adfc196a0 --- /dev/null +++ b/changes/41787-windows-enrollment-default-fleet @@ -0,0 +1 @@ +- Added a default fleet for new Windows MDM enrollments (Fleet Premium). IT admins can pick the fleet that hosts enrolling through user-driven Windows MDM enrollment (Windows Autopilot, Entra join) are automatically assigned to. The fleet is assigned before the Autopilot Enrollment Status Page runs, so the default fleet's software, scripts, and configuration profiles apply during out-of-box setup. diff --git a/cmd/fleetctl/fleetctl/generate_gitops.go b/cmd/fleetctl/fleetctl/generate_gitops.go index 7a4096cfcb..4124e1f4c5 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops.go +++ b/cmd/fleetctl/fleetctl/generate_gitops.go @@ -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) diff --git a/cmd/fleetctl/fleetctl/generate_gitops_test.go b/cmd/fleetctl/fleetctl/generate_gitops_test.go index 9f64b03641..3590f1ee64 100644 --- a/cmd/fleetctl/fleetctl/generate_gitops_test.go +++ b/cmd/fleetctl/fleetctl/generate_gitops_test.go @@ -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) { diff --git a/cmd/fleetctl/fleetctl/gitops.go b/cmd/fleetctl/fleetctl/gitops.go index 66cea81606..10abd25680 100644 --- a/cmd/fleetctl/fleetctl/gitops.go +++ b/cmd/fleetctl/fleetctl/gitops.go @@ -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, ) { diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index 91d0f5bd0a..f5367639a0 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -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 diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json index 5fc5b6ddff..c04525d625 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -196,6 +196,7 @@ "require_all_software_windows": false, "software": null }, + "windows_enrollment": null, "windows_settings": { "custom_settings": null, "configuration_profiles": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json index 634829525d..f2961fd00b 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json @@ -168,6 +168,7 @@ "require_all_software_windows": false, "software": null }, + "windows_enrollment": null, "windows_settings": { "custom_settings": null, "configuration_profiles": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml index 0f90148a6e..d3cb50c06d 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml @@ -103,6 +103,7 @@ spec: require_all_software_windows: false macos_script: software: + windows_enrollment: null windows_settings: custom_settings: null configuration_profiles: null diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index a332b441b3..ba8362e798 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -103,6 +103,7 @@ spec: require_all_software_windows: false macos_script: software: + windows_enrollment: null windows_settings: custom_settings: null configuration_profiles: null diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index b7af3a1097..047d6de9af 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -146,6 +146,7 @@ "require_all_software_windows": false, "software": null }, + "windows_enrollment": null, "windows_settings": { "custom_settings": null, "configuration_profiles": null, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index c5c8641798..3f9b62f43e 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -103,6 +103,7 @@ spec: require_all_software_windows: false macos_script: software: + windows_enrollment: null windows_settings: custom_settings: null configuration_profiles: null diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json b/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json index e5d24f7b7c..267471e0f2 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/appConfig.json @@ -291,6 +291,9 @@ "custom_settings": [] }, "volume_purchasing_program": null, + "windows_enrollment": { + "default_fleet": "💻 Workstations" + }, "android_enabled_and_configured": true, "android_settings": { "custom_settings": [] diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml index b7103d1395..799ed2d450 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings-insecure.yaml @@ -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 diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml index de835bb1c4..bb3ea9588f 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedOrgSettings.yaml @@ -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 diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml index df19cffe3e..86a55378ca 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/default.yml @@ -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 diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index 11228eb73f..cdd4992f36 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -62,6 +62,7 @@ spec: custom_settings: null apple_settings: configuration_profiles: null + windows_enrollment: null windows_settings: custom_settings: null configuration_profiles: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 95bfe07f2a..7b1d0a7fe6 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -62,6 +62,7 @@ spec: custom_settings: null apple_settings: configuration_profiles: null + windows_enrollment: null windows_settings: custom_settings: null configuration_profiles: null diff --git a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go index f1ac77b77d..0655aa1556 100644 --- a/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go +++ b/cmd/fleetctl/fleetctl/testing_utils/testing_utils.go @@ -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, ¬FoundError{} } + // 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 { diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 9ed705c5ea..0ed5199aca 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -1000,6 +1000,18 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { } } + // If this fleet is the Windows enrollment default, clear it explicitly to revoke the cache. + winDefaultFleetID, _, err := svc.ds.GetWindowsEnrollmentDefaultFleet(ctx) + if err != nil { + return ctxerr.Wrap(ctx, err, "get windows enrollment default fleet") + } + clearedWindowsEnrollmentDefaultFleet := winDefaultFleetID != nil && *winDefaultFleetID == teamID + if clearedWindowsEnrollmentDefaultFleet { + if err := svc.ds.SetWindowsEnrollmentDefaultFleet(ctx, nil); err != nil { + return ctxerr.Wrap(ctx, err, "clear windows enrollment default fleet") + } + } + if err := svc.ds.DeleteTeam(ctx, teamID); err != nil { return err } @@ -1038,6 +1050,17 @@ func (svc *Service) DeleteTeam(ctx context.Context, teamID uint) error { ); err != nil { return ctxerr.Wrap(ctx, err, "create activity for team deletion") } + + if clearedWindowsEnrollmentDefaultFleet { + // Record the change in Windows enrollment default + if err := svc.NewActivity( + ctx, + authz.UserFromContext(ctx), + fleet.ActivityTypeEditedWindowsEnrollmentDefaultFleet{}, + ); err != nil { + return ctxerr.Wrap(ctx, err, "create activity for cleared windows enrollment default fleet") + } + } return nil } diff --git a/ee/server/service/teams_test.go b/ee/server/service/teams_test.go index 38bd643cdc..d24ef5dc19 100644 --- a/ee/server/service/teams_test.go +++ b/ee/server/service/teams_test.go @@ -1521,3 +1521,83 @@ func TestModifyTeamMDMManagedLocalAccountRequiresMDM(t *testing.T) { require.Equal(t, []string{"enabled_managed_local_account:windows"}, activities) }) } + +func TestDeleteTeamWindowsEnrollmentDefaultFleet(t *testing.T) { + deletedTeamID, otherTeamID := uint(42), uint(43) + + testCases := []struct { + name string + defaultFleetID *uint + wantCleared bool + }{ + {name: "deleted fleet is the configured default", defaultFleetID: &deletedTeamID, wantCleared: true}, + {name: "another fleet is the configured default", defaultFleetID: &otherTeamID}, + {name: "no default configured"}, + } + + authorizer, err := authz.NewAuthorizer() + require.NoError(t, err) + ctx := test.UserContext(context.Background(), + &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}) + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ds := new(mock.Store) + ds.TeamLiteFunc = func(_ context.Context, tid uint) (*fleet.TeamLite, error) { + return &fleet.TeamLite{ID: tid, Name: "team-1"}, nil + } + ds.ListHostsFunc = func(context.Context, fleet.TeamFilter, fleet.HostListOptions) ([]*fleet.Host, error) { + return nil, nil + } + ds.GetCertificateTemplatesByTeamIDFunc = func(context.Context, uint, fleet.ListOptions) ( + []*fleet.CertificateTemplateResponseSummary, *fleet.PaginationMetadata, error, + ) { + return nil, nil, nil + } + ds.GetABMTokenOrgNamesAssociatedByDefaultTeamsFunc = func(context.Context, *uint) ([]string, error) { + return nil, nil + } + ds.GetWindowsEnrollmentDefaultFleetFunc = func(context.Context) (*uint, string, error) { + return tc.defaultFleetID, "default-fleet", nil + } + var clearedTo *uint + ds.SetWindowsEnrollmentDefaultFleetFunc = func(_ context.Context, fleetID *uint) error { + clearedTo = fleetID + return nil + } + ds.DeleteTeamFunc = func(context.Context, uint) error { return nil } + + var activities []string + mockSvc := &svcmock.Service{} + mockSvc.NewActivityFunc = func(_ context.Context, _ *fleet.User, act fleet.ActivityDetails) error { + if a, ok := act.(fleet.ActivityTypeEditedWindowsEnrollmentDefaultFleet); ok { + require.Nil(t, a.FleetID, "cleared default must not name a fleet") + require.Nil(t, a.FleetName, "cleared default must not name a fleet") + } + activities = append(activities, act.ActivityName()) + return nil + } + + svc := &Service{ + Service: mockSvc, + ds: ds, + authz: authorizer, + logger: slog.New(slog.DiscardHandler), + } + + require.NoError(t, svc.DeleteTeam(ctx, deletedTeamID)) + + // The deleted fleet activity always fires; the enrollment one only when the default was actually cleared. + require.Contains(t, activities, fleet.ActivityTypeDeletedTeam{}.ActivityName()) + clearedActivity := fleet.ActivityTypeEditedWindowsEnrollmentDefaultFleet{}.ActivityName() + if tc.wantCleared { + require.True(t, ds.SetWindowsEnrollmentDefaultFleetFuncInvoked) + require.Nil(t, clearedTo, "default fleet should be cleared, not reassigned") + require.Contains(t, activities, clearedActivity) + } else { + require.False(t, ds.SetWindowsEnrollmentDefaultFleetFuncInvoked) + require.NotContains(t, activities, clearedActivity) + } + }) + } +} diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx index 2715876f8b..f5c5f4c224 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tests.tsx @@ -99,4 +99,54 @@ describe("DropdownWrapper Component", () => { expect(screen.getByText(/no results found/i)).toBeInTheDocument(); }); + + test("shows the disabled tooltip on hover when disabled with content provided", async () => { + const { container } = render( + + ); + + const tooltipAnchor = container.querySelector( + ".dropdown-wrapper__disabled-tooltip .component__tooltip-wrapper__element" + ); + expect(tooltipAnchor).toBeInTheDocument(); + + // react-tooltip only mounts the tip content once the anchor is hovered + await userEvent.hover(tooltipAnchor as Element); + expect( + await screen.findByText(/reason it is disabled/i) + ).toBeInTheDocument(); + }); + + // The tooltip wraps the control only when isDisabled and disabledTooltipContent are both set. + // Each case below drops one of those two operands, so neither can be removed from the condition. + test.each([ + { + caseName: "enabled", + props: { disabledTooltipContent: "Reason it is disabled" }, + }, + { caseName: "disabled without content", props: { isDisabled: true } }, + ])("does not render the disabled tooltip when $caseName", ({ props }) => { + const { container } = render( + + ); + + expect( + container.querySelector(".dropdown-wrapper__disabled-tooltip") + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx index 23d6f3acb6..867104d26c 100644 --- a/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx +++ b/frontend/components/forms/fields/DropdownWrapper/DropdownWrapper.tsx @@ -28,6 +28,7 @@ import { PADDING } from "styles/var/padding"; import FormField from "components/forms/FormField"; import DropdownOptionTooltipWrapper from "components/forms/fields/Dropdown/DropdownOptionTooltipWrapper"; +import TooltipWrapper from "components/TooltipWrapper"; import Icon from "components/Icon"; import { IconNames } from "components/icons"; import { TooltipContent } from "interfaces/dropdownOption"; @@ -131,6 +132,8 @@ export interface IDropdownWrapper { * not infer any of these on its own; without a value here screen readers * announce a bare "combobox". */ ariaLabel?: string; + /** Tooltip explaining why the dropdown is disabled. Shown above the control, on hover over the control only (not the label or help text), and only while `isDisabled` is true. */ + disabledTooltipContent?: React.ReactNode; /** Defaults to "auto" so a menu near the viewport bottom flips upward * instead of stretching the page and triggering a scrollbar-driven * layout shift. */ @@ -381,6 +384,7 @@ const DropdownWrapper = ({ nowrapMenu, customNoOptionsMessage, ariaLabel, + disabledTooltipContent, menuPlacement = "auto", }: IDropdownWrapper) => { const wrapperClassNames = classnames(baseClass, className, { @@ -449,6 +453,39 @@ const DropdownWrapper = ({ ); }; + const selectElement = ( + + classNamePrefix="react-select" + isSearchable={isSearchable} + styles={generateCustomDropdownStyles( + variant, + isDisabled, + nowrapMenu, + maxMenuHeight + )} + options={options} + components={{ + Option: CustomOption, + DropdownIndicator: CustomDropdownIndicator, + IndicatorSeparator: () => null, + ValueContainer, + }} + value={getCurrentValue()} + onChange={handleChange} + isDisabled={isDisabled} + noOptionsMessage={() => customNoOptionsMessage ?? "No results found"} + tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility + placeholder={placeholder} + onMenuOpen={onMenuOpen} + menuPlacement={menuPlacement} + // Resolve accessible name: explicit prop wins, otherwise fall back + // to the placeholder (usually "Select X"), otherwise the required + // `name` (often a kebab-case identifier — least readable but + // guaranteed present). + aria-label={ariaLabel ?? placeholder ?? name} + /> + ); + return ( - - classNamePrefix="react-select" - isSearchable={isSearchable} - styles={generateCustomDropdownStyles( - variant, - isDisabled, - nowrapMenu, - maxMenuHeight - )} - options={options} - components={{ - Option: CustomOption, - DropdownIndicator: CustomDropdownIndicator, - IndicatorSeparator: () => null, - ValueContainer, - }} - value={getCurrentValue()} - onChange={handleChange} - isDisabled={isDisabled} - noOptionsMessage={() => customNoOptionsMessage ?? "No results found"} - tabIndex={isDisabled ? -1 : 0} // Ensures disabled dropdown has no keyboard accessibility - placeholder={placeholder} - onMenuOpen={onMenuOpen} - menuPlacement={menuPlacement} - // Resolve accessible name: explicit prop wins, otherwise fall back - // to the placeholder (usually "Select X"), otherwise the required - // `name` (often a kebab-case identifier — least readable but - // guaranteed present). - aria-label={ariaLabel ?? placeholder ?? name} - /> + {isDisabled && disabledTooltipContent ? ( + + {selectElement} + + ) : ( + selectElement + )} ); }; diff --git a/frontend/components/forms/fields/DropdownWrapper/_styles.scss b/frontend/components/forms/fields/DropdownWrapper/_styles.scss index ec7ada57b6..57f39a9094 100644 --- a/frontend/components/forms/fields/DropdownWrapper/_styles.scss +++ b/frontend/components/forms/fields/DropdownWrapper/_styles.scss @@ -12,6 +12,21 @@ } } + // Wraps the inside it to + // its text width. + width: 100%; + white-space: normal; + } + } + // Table dropdowns have height 40px &__table-filter { height: 36px; diff --git a/frontend/interfaces/activity.ts b/frontend/interfaces/activity.ts index bc40e3fa33..14be08fe73 100644 --- a/frontend/interfaces/activity.ts +++ b/frontend/interfaces/activity.ts @@ -108,6 +108,7 @@ export enum ActivityType { DisabledGitOpsException = "disabled_gitops_exception", EnabledWindowsMdmMigration = "enabled_windows_mdm_migration", DisabledWindowsMdmMigration = "disabled_windows_mdm_migration", + EditedWindowsEnrollmentDefaultFleet = "edited_windows_enrollment_default_fleet", RanScript = "ran_script", RanCustomMdmCommand = "ran_custom_mdm_command", RanScriptBatch = "ran_script_batch", @@ -491,6 +492,8 @@ export const ACTIVITY_TYPE_TO_FILTER_LABEL: Record = { edited_saved_query: "Edited report", edited_script: "Edited script", edited_software: "Edited software", + edited_windows_enrollment_default_fleet: + "Edited enrollment default fleet: Windows", edited_windows_profile: "Edited configuration profiles: Windows", edited_windows_updates: "OS updates: edited Windows", enabled_activity_automations: "Enabled activity automations", diff --git a/frontend/interfaces/config.ts b/frontend/interfaces/config.ts index 6d57fc343c..862677375c 100644 --- a/frontend/interfaces/config.ts +++ b/frontend/interfaces/config.ts @@ -100,9 +100,16 @@ export interface IMdmConfig { }; windows_entra_tenant_ids: string[] | null; windows_entra_client_ids: string[] | null; + windows_enrollment?: IWindowsEnrollment | null; apple_account_provisioning?: IAppleAccountProvisioning; } +/** Settings for new user-driven Windows MDM enrollments (Premium only). */ +export interface IWindowsEnrollment { + /** Name of the fleet new MDM-enrolled Windows hosts are assigned to; "" means Unassigned. */ + default_fleet: string; +} + // Note: IDeviceGlobalConfig is misnamed on the backend because in some cases it returns team config // values if the device is assigned to a team, e.g., features.enable_software_inventory reflects the // team config, if applicable, rather than the global config. diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx index c4278367ba..dcb43c1c7b 100644 --- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx +++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx @@ -1095,6 +1095,15 @@ const TAGGED_TEMPLATES = { const exception = activity.details?.exception ?? ""; return `disabled the ${exception} exception for GitOps.`; }, + editedWindowsEnrollmentDefaultFleet: (activity: IActivity) => { + return ( + <> + {" "} + edited the default fleet for Windows hosts to{" "} + {activity.details?.fleet_name || "Unassigned"}. + + ); + }, enabledWindowsMdmMigration: () => { return ( <> @@ -2524,6 +2533,9 @@ const getDetail = (activity: IActivity, isPremiumTier: boolean) => { case ActivityType.DisabledWindowsMdmMigration: { return TAGGED_TEMPLATES.disabledWindowsMdmMigration(); } + case ActivityType.EditedWindowsEnrollmentDefaultFleet: { + return TAGGED_TEMPLATES.editedWindowsEnrollmentDefaultFleet(activity); + } case ActivityType.RanCustomMdmCommand: { return TAGGED_TEMPLATES.ranCustomMdmCommand(activity); } diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tests.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tests.tsx index c80467a078..84ccdc0ca0 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tests.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tests.tsx @@ -4,69 +4,119 @@ import { screen } from "@testing-library/react"; import { createMockRouter, createCustomRenderer } from "test/test-utils"; import { createMockConfig, createMockMdmConfig } from "__mocks__/configMock"; +import { IMdmConfig } from "interfaces/config"; +import configAPI from "services/entities/config"; import WindowsMdmPage from "./WindowsMdmPage"; +jest.mock("services/entities/config"); + +const renderPage = (mdm: Partial = {}, isPremiumTier = true) => { + const render = createCustomRenderer({ + context: { + app: { + isPremiumTier, + config: createMockConfig({ mdm: createMockMdmConfig(mdm) }), + }, + }, + }); + + return render(); +}; + describe("WindowsMdmPage", () => { - it("renders only the windows mdm slider and description when on free tier", () => { - const render = createCustomRenderer({ - context: { - app: { - isPremiumTier: false, - config: createMockConfig(), - }, - }, - }); + it("renders only the windows mdm slider when on free tier", () => { + renderPage({}, false); - render(); - - // switch and description only shown expect(screen.getByRole("switch")).toBeInTheDocument(); - expect(screen.getByText(/On \(manual\)/)).toBeInTheDocument(); - // no end user experience form + // no premium-only sections expect( - screen.queryByLabelText("Fleet agent-driven") + screen.queryByText("Turn on MDM programmatically") ).not.toBeInTheDocument(); - expect(screen.queryByLabelText("End user-driven")).not.toBeInTheDocument(); + expect( + screen.queryByText("User driven enrollment") + ).not.toBeInTheDocument(); + expect(screen.queryByText("Migration")).not.toBeInTheDocument(); }); - it("renders the end user experience form as disabled when MDM is off", () => { - const render = createCustomRenderer({ - context: { - app: { - isPremiumTier: true, - config: createMockConfig({ - mdm: createMockMdmConfig({ windows_enabled_and_configured: false }), - }), - }, - }, - }); + it("renders the programmatic enrollment toggle as disabled when MDM is off", () => { + renderPage({ windows_enabled_and_configured: false }); - render(); - - expect(screen.getByLabelText("Fleet agent-driven")).toBeDisabled(); - expect(screen.getByLabelText("End user-driven")).toBeDisabled(); + expect(screen.getByText("Turn on MDM programmatically")).toBeVisible(); + expect(screen.getAllByRole("switch")[1]).toBeDisabled(); }); - it("renders the automatically migrate checkbox if automatic mdm enrollment is selected", () => { - const render = createCustomRenderer({ - context: { - app: { - isPremiumTier: true, - config: createMockConfig({ - mdm: createMockMdmConfig({ - enable_turn_on_windows_mdm_manually: false, - windows_enabled_and_configured: true, - }), - }), - }, - }, + it("renders the Migration section when MDM is on programmatically", () => { + renderPage({ + enable_turn_on_windows_mdm_manually: false, + windows_enabled_and_configured: true, }); - render(); - - // Fleet agent-driven is selected and the checkbox is visible - expect(screen.getByLabelText("Fleet agent-driven")).toBeChecked(); + expect(screen.getByText("Migration")).toBeVisible(); expect(screen.getByRole("checkbox")).toBeVisible(); }); + + it("disables the default fleet dropdown when Fleet is not connected to Entra", () => { + renderPage({ + windows_enabled_and_configured: true, + windows_entra_tenant_ids: [], + }); + + expect(screen.getByText("User driven enrollment")).toBeVisible(); + expect(screen.getByText("Default fleet")).toBeVisible(); + expect(screen.getByRole("combobox")).toBeDisabled(); + }); + + it("enables the default fleet dropdown when Fleet is connected to Entra", () => { + renderPage({ + windows_enabled_and_configured: true, + windows_entra_tenant_ids: ["tenant-1"], + }); + + expect(screen.getByRole("combobox")).toBeEnabled(); + }); + + it("saves the toggle states and the default fleet through the config API", async () => { + (configAPI.updateMDMConfig as jest.Mock).mockResolvedValue({}); + const { user } = renderPage({ + windows_enabled_and_configured: true, + enable_turn_on_windows_mdm_manually: false, + windows_entra_tenant_ids: ["tenant-1"], + windows_enrollment: { default_fleet: "Workstations" }, + }); + + // Turning programmatic enrollment off also forces auto migration off. + await user.click(screen.getAllByRole("switch")[1]); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(configAPI.updateMDMConfig).toHaveBeenCalledWith( + { + windows_enabled_and_configured: true, + enable_turn_on_windows_mdm_manually: true, + windows_migration_enabled: false, + windows_enrollment: { default_fleet: "Workstations" }, + }, + true + ); + }); + + it("does not re-save a stale migration setting when enrollment is manual", async () => { + (configAPI.updateMDMConfig as jest.Mock).mockResolvedValue({}); + // Inconsistent server state (settable via the API or GitOps): migration + // enabled while enrollment is manual, so the Migration checkbox is hidden. + const { user } = renderPage({ + windows_enabled_and_configured: true, + enable_turn_on_windows_mdm_manually: true, + windows_migration_enabled: true, + windows_entra_tenant_ids: ["tenant-1"], + }); + + expect(screen.queryByText("Migration")).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Save" })); + + expect(configAPI.updateMDMConfig).toHaveBeenCalledWith( + expect.objectContaining({ windows_migration_enabled: false }), + true + ); + }); }); diff --git a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tsx b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tsx index 60d97abe01..0c05f509d9 100644 --- a/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tsx +++ b/frontend/pages/admin/IntegrationsPage/cards/MdmSettings/WindowsMdmPage/WindowsMdmPage.tsx @@ -1,5 +1,6 @@ import React, { useContext, useState } from "react"; import { InjectedRouter } from "react-router"; +import { SingleValue } from "react-select-5"; import PATHS from "router/paths"; import configAPI from "services/entities/config"; @@ -10,8 +11,10 @@ import Button from "components/buttons/Button"; import BackButton from "components/BackButton"; import Slider from "components/forms/fields/Slider"; import Checkbox from "components/forms/fields/Checkbox"; +import DropdownWrapper, { + CustomOptionType, +} from "components/forms/fields/DropdownWrapper/DropdownWrapper"; import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; -import Radio from "components/forms/fields/Radio"; import CustomLink from "components/CustomLink"; import { notify } from "components/ToastNotification"; @@ -19,29 +22,40 @@ import { getErrorMessage } from "./helpers"; const baseClass = "windows-mdm-page"; +const UNASSIGNED_FLEET = ""; + interface ISetWindowsMdmOptions { enableMdm: boolean; enableAutoMigration: boolean; - enrollmentType: "automatic" | "manual" | null; + turnOnProgrammatically: boolean; + defaultFleet: string; router: InjectedRouter; } const useSetWindowsMdm = ({ enableMdm, enableAutoMigration, - enrollmentType, + turnOnProgrammatically, + defaultFleet, router, }: ISetWindowsMdmOptions) => { - const { setConfig } = useContext(AppContext); + const { setConfig, isPremiumTier } = useContext(AppContext); - const turnOnWindowsMdm = async () => { + const updateWindowsMdm = async () => { try { const updatedConfig = await configAPI.updateMDMConfig( { enable_turn_on_windows_mdm_manually: - enrollmentType !== null && enrollmentType === "manual", + enableMdm && !turnOnProgrammatically, windows_enabled_and_configured: enableMdm, - windows_migration_enabled: enableAutoMigration, + // Migration only applies when MDM is on and enrollment is programmatic (the checkbox is hidden otherwise), so + // derive the value to avoid re-saving a stale "enabled" state. + windows_migration_enabled: + enableMdm && turnOnProgrammatically && enableAutoMigration, + // The default fleet for user-driven enrollment is Premium only; the backend rejects it otherwise. + ...(isPremiumTier && { + windows_enrollment: { default_fleet: defaultFleet }, + }), }, true ); @@ -54,7 +68,7 @@ const useSetWindowsMdm = ({ router.push(PATHS.ADMIN_INTEGRATIONS_MDM); }; - return turnOnWindowsMdm; + return updateWindowsMdm; }; interface IWindowsMdmPageProps { @@ -62,7 +76,7 @@ interface IWindowsMdmPageProps { } const WindowsMdmPage = ({ router }: IWindowsMdmPageProps) => { - const { config, isPremiumTier } = useContext(AppContext); + const { config, isPremiumTier, availableTeams } = useContext(AppContext); const gitOpsModeEnabled = config?.gitops.gitops_mode_enabled; const [mdmOn, setMdmOn] = useState( @@ -71,45 +85,105 @@ const WindowsMdmPage = ({ router }: IWindowsMdmPageProps) => { const [autoMigration, setAutoMigration] = useState( config?.mdm?.windows_migration_enabled ?? false ); - const [enrollmentType, setEnrollmentType] = useState< - "automatic" | "manual" | null - >(() => { - if (!config?.mdm?.windows_enabled_and_configured) return null; - return config?.mdm?.enable_turn_on_windows_mdm_manually - ? "manual" - : "automatic"; - }); + const [turnOnProgrammatically, setTurnOnProgrammatically] = useState( + !(config?.mdm?.enable_turn_on_windows_mdm_manually ?? false) + ); + const [defaultFleet, setDefaultFleet] = useState( + config?.mdm?.windows_enrollment?.default_fleet ?? UNASSIGNED_FLEET + ); + + const isConnectedToEntra = !!config?.mdm?.windows_entra_tenant_ids?.length; const updateWindowsMdm = useSetWindowsMdm({ enableMdm: mdmOn, enableAutoMigration: autoMigration, - enrollmentType, + turnOnProgrammatically, + defaultFleet, router, }); const onChangeMdmOn = () => { setMdmOn(!mdmOn); - // if we are toggling off mdm we want to clear enrollment type. If we are toggling - // it on, we want to set enrollment type to automatic by default - !mdmOn ? setEnrollmentType("automatic") : setEnrollmentType(null); - - // if we are turning mdm off, also turn off auto migration - mdmOn && setAutoMigration(false); + // Turning MDM on defaults to programmatic enrollment; turning it off also turns off auto migration. + !mdmOn ? setTurnOnProgrammatically(true) : setAutoMigration(false); }; - const onChangeEnrollmentType = (value: string) => { - setAutoMigration(false); - setEnrollmentType(value === "automaticEnrollment" ? "automatic" : "manual"); + const onChangeTurnOnProgrammatically = () => { + // Auto migration only applies to programmatic enrollment. + turnOnProgrammatically && setAutoMigration(false); + setTurnOnProgrammatically(!turnOnProgrammatically); }; const onChangeAutoMigration = () => { setAutoMigration(!autoMigration); }; + const onChangeDefaultFleet = (option: SingleValue) => { + setDefaultFleet(option?.value ?? UNASSIGNED_FLEET); + }; + const onSaveMdm = () => { updateWindowsMdm(); }; + const fleetOptions: CustomOptionType[] = [ + { label: "Unassigned", value: UNASSIGNED_FLEET }, + ...(availableTeams ?? []) + .filter((t) => t.id > 0) + .map((t) => ({ label: t.name, value: t.name })), + ]; + + const defaultFleetDropdown = ( + + Fleet must be connected to Entra to set a default fleet.{" "} + + + ) : undefined + } + helpText={ + <> + New hosts enrolled into MDM are automatically assigned to this fleet.{" "} + + + } + /> + ); + + const programmaticToggleTooltip = ( + <> + When enabled, MDM is turned on when Fleet's agent is installed. When + disabled, end users turn on MDM manually in{" "} + Settings > Access work or school (requires Microsoft Entra). + Only applies to manual enrollment.{" "} + + + ); + return ( <> @@ -122,16 +196,6 @@ const WindowsMdmPage = ({ router }: IWindowsMdmPageProps) => {

Windows MDM

-

- 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 -
- {/* NOTE: we use this wrapper div to style the legend since legend - does not work well with flexbox. the wrapper div helps the gap styling apply. */} -
- - End user experience - -
- - - Requires{" "} - {" "} - End users have to sign in using{" "} - Settings > Access work or school. - - } - /> -
- )} - {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. }