diff --git a/changes/39085-os-updates-latest-version b/changes/39085-os-updates-latest-version new file mode 100644 index 0000000000..f761b046d1 --- /dev/null +++ b/changes/39085-os-updates-latest-version @@ -0,0 +1 @@ +- Added the option to keep macOS, iOS, and iPadOS hosts on the latest OS version (Fleet Premium). Setting `minimum_version` to `latest` along with `deadline_days` tells Fleet to automatically track the newest version Apple publishes for each host's hardware and to set the update deadline that many days after the version has been released. diff --git a/cmd/fleetctl/fleetctl/gitops_test.go b/cmd/fleetctl/fleetctl/gitops_test.go index f5367639a0..daae2c2364 100644 --- a/cmd/fleetctl/fleetctl/gitops_test.go +++ b/cmd/fleetctl/fleetctl/gitops_test.go @@ -7498,6 +7498,23 @@ software: return false, nil } }) + + t.Run("update_new_hosts derives true in latest mode", func(t *testing.T) { + savedTeam = existingTeamWithMacOSUpdates("", "") + + teamFile, err := os.CreateTemp(t.TempDir(), "*.yml") + require.NoError(t, err) + // "latest" has no deadline, so deriving from the deadline alone would + // leave new hosts unenforced. + _, err = teamFile.WriteString(teamYAML( + " macos_updates:\n minimum_version: \"latest\"\n deadline_days: 7")) + require.NoError(t, err) + + _ = runAppForTest(t, []string{"gitops", "-f", teamFile.Name()}) + + require.Equal(t, optjson.SetBool(true), savedTeam.Config.MDM.MacOSUpdates.UpdateNewHosts) + require.Equal(t, optjson.SetInt(7), savedTeam.Config.MDM.MacOSUpdates.DeadlineDays) + }) }) t.Run("ios_updates", func(t *testing.T) { diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json index c04525d625..7d5338649f 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigJson.json @@ -135,16 +135,19 @@ "macos_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "ios_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "ipados_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "windows_updates": { diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json index f2961fd00b..41108cc34d 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerJson.json @@ -108,16 +108,19 @@ "macos_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "ios_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "ipados_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "windows_updates": { diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml index d3cb50c06d..3df8273423 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigTeamMaintainerYaml.yml @@ -62,14 +62,17 @@ spec: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null ios_updates: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null ipados_updates: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null windows_updates: deadline_days: 7 grace_period_days: 3 diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml index ba8362e798..1945ecb556 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml @@ -61,14 +61,17 @@ spec: macos_updates: minimum_version: null deadline: null + deadline_days: null update_new_hosts: null ios_updates: minimum_version: null deadline: null + deadline_days: null update_new_hosts: null ipados_updates: minimum_version: null deadline: null + deadline_days: null update_new_hosts: null windows_updates: deadline_days: 7 diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json index 047d6de9af..96aee97e7f 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigJson.json @@ -88,16 +88,19 @@ "macos_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "ios_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "ipados_updates": { "minimum_version": null, "deadline": null, + "deadline_days": null, "update_new_hosts": null }, "windows_updates": { diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml index 3f9b62f43e..17a052da79 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetConfigIncludeServerConfigYaml.yml @@ -61,14 +61,17 @@ spec: macos_updates: minimum_version: null deadline: null + deadline_days: null update_new_hosts: null ios_updates: minimum_version: null deadline: null + deadline_days: null update_new_hosts: null ipados_updates: minimum_version: null deadline: null + deadline_days: null update_new_hosts: null windows_updates: deadline_days: 7 diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json index caca0b3f8e..723d380c4c 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsJson.json @@ -37,16 +37,19 @@ "enable_recovery_lock_password": false, "ios_updates": { "deadline": null, + "deadline_days": null, "minimum_version": null, "update_new_hosts": null }, "ipados_updates": { "deadline": null, + "deadline_days": null, "minimum_version": null, "update_new_hosts": null }, "macos_updates": { "deadline": null, + "deadline_days": null, "minimum_version": null, "update_new_hosts": null }, @@ -122,11 +125,13 @@ "enable_recovery_lock_password": false, "ios_updates": { "deadline": null, + "deadline_days": null, "minimum_version": null, "update_new_hosts": null }, "ipados_updates": { "deadline": null, + "deadline_days": null, "minimum_version": null, "update_new_hosts": null }, @@ -149,6 +154,7 @@ }, "macos_updates": { "deadline": null, + "deadline_days": null, "minimum_version": null, "update_new_hosts": null }, @@ -234,16 +240,19 @@ "enable_recovery_lock_password": true, "ios_updates": { "deadline": "2022-11-15", + "deadline_days": null, "minimum_version": "17.5", "update_new_hosts": null }, "ipados_updates": { "deadline": "2023-01-01", + "deadline_days": null, "minimum_version": "18.0", "update_new_hosts": null }, "macos_updates": { "deadline": "2021-12-14", + "deadline_days": null, "minimum_version": "12.3.1", "update_new_hosts": null }, @@ -334,11 +343,13 @@ "enable_recovery_lock_password": true, "ios_updates": { "deadline": "2022-11-15", + "deadline_days": null, "minimum_version": "17.5", "update_new_hosts": null }, "ipados_updates": { "deadline": "2023-01-01", + "deadline_days": null, "minimum_version": "18.0", "update_new_hosts": null }, @@ -361,6 +372,7 @@ }, "macos_updates": { "deadline": "2021-12-14", + "deadline_days": null, "minimum_version": "12.3.1", "update_new_hosts": null }, diff --git a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml index b43a9623c3..a451f0a39b 100644 --- a/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml +++ b/cmd/fleetctl/fleetctl/testdata/expectedGetTeamsYaml.yml @@ -24,14 +24,17 @@ spec: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null ios_updates: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null ipados_updates: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null windows_updates: deadline_days: null grace_period_days: null @@ -85,14 +88,17 @@ spec: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null ios_updates: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null ipados_updates: update_new_hosts: null minimum_version: null deadline: null + deadline_days: null windows_updates: deadline_days: null grace_period_days: null @@ -158,9 +164,11 @@ spec: ios_updates: minimum_version: "17.5" deadline: "2022-11-15" + deadline_days: null macos_updates: minimum_version: "18.0" deadline: "2023-01-01" + deadline_days: null windows_updates: deadline_days: 7 grace_period_days: 3 @@ -218,9 +226,11 @@ spec: ios_updates: minimum_version: "17.5" deadline: "2022-11-15" + deadline_days: null macos_updates: minimum_version: "18.0" deadline: "2023-01-01" + deadline_days: null windows_updates: deadline_days: 7 grace_period_days: 3 diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml index a8eb827f47..8221a39a77 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/expectedGlobalControls.yaml @@ -18,14 +18,17 @@ android_settings: macos_updates: minimum_version: "15.1" deadline: "2024-12-31" + deadline_days: update_new_hosts: true ios_updates: minimum_version: "18.1" deadline: "2025-12-31" + deadline_days: update_new_hosts: ipados_updates: minimum_version: "18.2" deadline: "2026-12-31" + deadline_days: update_new_hosts: windows_updates: deadline_days: 5 diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml index 089a2d8fbe..ebc2b16842 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/team-a-thumbsup.yml @@ -31,14 +31,17 @@ controls: enable_recovery_lock_password: false ios_updates: deadline: "2021-12-31" + deadline_days: minimum_version: "98.1" update_new_hosts: ipados_updates: deadline: "2022-12-31" + deadline_days: minimum_version: "98.2" update_new_hosts: macos_updates: deadline: "2020-12-31" + deadline_days: minimum_version: "95.1" update_new_hosts: true name_template: iPad $FLEET_VAR_HOST_HARDWARE_SERIAL diff --git a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/unassigned.yml b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/unassigned.yml index 53ea8caa25..d3061c31b3 100644 --- a/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/unassigned.yml +++ b/cmd/fleetctl/fleetctl/testdata/generateGitops/test_dir_premium/fleets/unassigned.yml @@ -6,10 +6,12 @@ controls: enable_turn_on_windows_mdm_manually: false ios_updates: deadline: "2025-12-31" + deadline_days: minimum_version: "18.1" update_new_hosts: ipados_updates: deadline: "2026-12-31" + deadline_days: minimum_version: "18.2" update_new_hosts: macos_migration: @@ -18,6 +20,7 @@ controls: webhook_url: https://some-macos-migration-webhook-url.com macos_updates: deadline: "2024-12-31" + deadline_days: minimum_version: "15.1" update_new_hosts: true name_template: No team Mac $FLEET_VAR_HOST_UUID diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml index cdd4992f36..c8d4842dcb 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigEmpty.yml @@ -100,14 +100,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml index 7b1d0a7fe6..ec019499d8 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml @@ -100,14 +100,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml index 372a36e0ee..76ddc9f72c 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Empty.yml @@ -44,14 +44,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: @@ -105,14 +108,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: @@ -167,12 +173,15 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null ios_updates: deadline: null + deadline_days: null minimum_version: null ipados_updates: deadline: null + deadline_days: null minimum_version: null windows_updates: deadline_days: null @@ -221,12 +230,15 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null ios_updates: deadline: null + deadline_days: null minimum_version: null ipados_updates: deadline: null + deadline_days: null minimum_version: null windows_updates: deadline_days: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml index b0628d9f4e..79dc3294de 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1And2Set.yml @@ -44,14 +44,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: @@ -105,14 +108,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: @@ -167,12 +173,15 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null ios_updates: deadline: null + deadline_days: null minimum_version: null ipados_updates: deadline: null + deadline_days: null minimum_version: null windows_updates: deadline_days: null @@ -222,12 +231,15 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null ios_updates: deadline: null + deadline_days: null minimum_version: null ipados_updates: deadline: null + deadline_days: null minimum_version: null windows_updates: deadline_days: null diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml index e4157401ae..44a83a541e 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Empty.yml @@ -40,14 +40,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: @@ -101,14 +104,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: diff --git a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml index cb78a8b75a..22d4914a48 100644 --- a/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml +++ b/cmd/fleetctl/fleetctl/testdata/macosSetupExpectedTeam1Set.yml @@ -43,14 +43,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: @@ -104,14 +107,17 @@ spec: software: null macos_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ios_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null ipados_updates: deadline: null + deadline_days: null minimum_version: null update_new_hosts: null windows_updates: diff --git a/ee/server/service/mdm.go b/ee/server/service/mdm.go index 19f5cea7bd..ff99a52f77 100644 --- a/ee/server/service/mdm.go +++ b/ee/server/service/mdm.go @@ -1486,6 +1486,24 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui // OS updates enabled, create or update the profile with the current settings. + targetOSVersion := updates.MinimumVersion.Value + targetDeadline := updates.Deadline.Value + var usesFleetVars []fleet.FleetVarName + if updates.EnforcesLatestVersion() { + // In "latest" mode the target version and deadline differ per host (they + // depend on the host's hardware and on when Apple released the version it + // can run), so emit placeholders that are resolved at declaration fetch + // time. The deadline placeholder is brace-delimited so it doesn't absorb + // the time suffix appended below, and resolves to a YYYY-MM-DD date, the + // same shape as updates.Deadline in specific-version mode. + targetOSVersion = fmt.Sprintf("$FLEET_VAR_%s", fleet.FleetVarHostTargetOSVersion) + targetDeadline = fmt.Sprintf("${FLEET_VAR_%s}", fleet.FleetVarHostTargetOSDeadline) + usesFleetVars = []fleet.FleetVarName{ + fleet.FleetVarHostTargetOSVersion, + fleet.FleetVarHostTargetOSDeadline, + } + } + rawDecl := []byte(fmt.Sprintf(`{ "Identifier": %q, "Type": %q, @@ -1493,7 +1511,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui "TargetOSVersion": %q, "TargetLocalDateTime": "%sT12:00:00" } -}`, softwareUpdateIdentifier, apple_mdm.DeclarationTypeSoftwareUpdate, updates.MinimumVersion.Value, updates.Deadline.Value)) +}`, softwareUpdateIdentifier, apple_mdm.DeclarationTypeSoftwareUpdate, targetOSVersion, targetDeadline)) d := fleet.NewMDMAppleDeclaration(rawDecl, teamID, osUpdatesProfileName, apple_mdm.DeclarationTypeSoftwareUpdate, softwareUpdateIdentifier) @@ -1506,7 +1524,7 @@ func (svc *Service) mdmAppleEditedAppleOSUpdates(ctx context.Context, teamID *ui {LabelName: labelName, LabelID: lblIDs[labelName]}, } - _, err = svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d, nil) + _, err = svc.ds.SetOrUpdateMDMAppleDeclaration(ctx, d, usesFleetVars) if err != nil { return err } diff --git a/ee/server/service/mdm_test.go b/ee/server/service/mdm_test.go index 1df9907fed..ac5d427cbd 100644 --- a/ee/server/service/mdm_test.go +++ b/ee/server/service/mdm_test.go @@ -604,3 +604,119 @@ func TestUpdateABMTokenTeams(t *testing.T) { assert.Equal(t, validTeamName, appCfgToken.IpadOSTeam) }) } +func TestMDMAppleEditedAppleOSUpdatesDeclaration(t *testing.T) { + ctx := context.Background() + teamID := uint(1) + + // captured records what the datastore was handed, so the tests assert on the + // generated declaration rather than on a real write. + type captured struct { + decl *fleet.MDMAppleDeclaration + vars []fleet.FleetVarName + deleted string + labels []string + } + + newSvc := func() (*Service, *captured) { + got := &captured{} + ds := new(mock.Store) + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, filter fleet.TeamFilter) (map[string]uint, error) { + got.labels = names + ids := make(map[string]uint, len(names)) + for i, name := range names { + ids[name] = uint(i + 1) //nolint:gosec + } + return ids, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, decl *fleet.MDMAppleDeclaration, + usesFleetVars []fleet.FleetVarName, + ) (*fleet.MDMAppleDeclaration, error) { + got.decl = decl + got.vars = usesFleetVars + decl.DeclarationUUID = "decl-uuid" + return decl, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, declTeamID *uint, name string) error { + got.deleted = name + return nil + } + return &Service{ds: ds}, got + } + + // Each platform gets its own declaration name and built-in label; a mix-up + // would send the OS update declaration to the wrong devices. + platforms := []struct { + name string + device fleet.AppleDevice + declName string + labelName string + }{ + {"macos", fleet.MacOS, mdm.FleetMacOSUpdatesProfileName, fleet.BuiltinLabelMacOS14Plus}, + {"ios", fleet.IOS, mdm.FleetIOSUpdatesProfileName, fleet.BuiltinLabelIOS}, + {"ipados", fleet.IPadOS, mdm.FleetIPadOSUpdatesProfileName, fleet.BuiltinLabelIPadOS}, + } + + t.Run("latest emits Fleet variable placeholders", func(t *testing.T) { + for _, p := range platforms { + t.Run(p.name, func(t *testing.T) { + svc, got := newSvc() + + err := svc.mdmAppleEditedAppleOSUpdates(ctx, &teamID, p.device, fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + }) + require.NoError(t, err) + require.NotNil(t, got.decl) + require.Empty(t, got.deleted) + + // The literal placeholder text matters: it is what gets substituted + // per host at declaration fetch time. + require.Contains(t, string(got.decl.RawJSON), `"TargetOSVersion": "$FLEET_VAR_HOST_TARGET_OS_VERSION"`) + require.Contains(t, string(got.decl.RawJSON), `"TargetLocalDateTime": "${FLEET_VAR_HOST_TARGET_OS_DEADLINE}T12:00:00"`) + // Without these the declaration is stored but never expanded. + require.ElementsMatch(t, []fleet.FleetVarName{ + fleet.FleetVarHostTargetOSVersion, + fleet.FleetVarHostTargetOSDeadline, + }, got.vars) + + require.Equal(t, p.declName, got.decl.Name) + require.Equal(t, []string{p.labelName}, got.labels) + }) + } + }) + + t.Run("specific version emits literal values and no variables", func(t *testing.T) { + for _, p := range platforms { + t.Run(p.name, func(t *testing.T) { + svc, got := newSvc() + + err := svc.mdmAppleEditedAppleOSUpdates(ctx, &teamID, p.device, fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("15.7.8"), + Deadline: optjson.SetString("2026-09-01"), + }) + require.NoError(t, err) + require.NotNil(t, got.decl) + require.Contains(t, string(got.decl.RawJSON), `"TargetOSVersion": "15.7.8"`) + require.Contains(t, string(got.decl.RawJSON), `"TargetLocalDateTime": "2026-09-01T12:00:00"`) + require.NotContains(t, string(got.decl.RawJSON), "FLEET_VAR_") + require.Empty(t, got.vars) + + require.Equal(t, p.declName, got.decl.Name) + require.Equal(t, []string{p.labelName}, got.labels) + }) + } + }) + + t.Run("disabled deletes the declaration", func(t *testing.T) { + for _, p := range platforms { + t.Run(p.name, func(t *testing.T) { + svc, got := newSvc() + + err := svc.mdmAppleEditedAppleOSUpdates(ctx, &teamID, p.device, fleet.AppleOSUpdateSettings{}) + require.NoError(t, err) + require.Nil(t, got.decl, "no declaration should be written when OS updates are off") + require.Equal(t, p.declName, got.deleted) + }) + } + }) +} diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index 0ed5199aca..5828172524 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -242,9 +242,11 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T if err := payload.MDM.MacOSUpdates.Validate(); err != nil { return nil, fleet.NewInvalidArgumentError("macos_updates", err.Error()) } - if payload.MDM.MacOSUpdates.MinimumVersion.Set || payload.MDM.MacOSUpdates.Deadline.Set || payload.MDM.MacOSUpdates.UpdateNewHosts.Set { + if payload.MDM.MacOSUpdates.MinimumVersion.Set || payload.MDM.MacOSUpdates.Deadline.Set || payload.MDM.MacOSUpdates.DeadlineDays.Set || payload.MDM.MacOSUpdates.UpdateNewHosts.Set { macOSMinVersionUpdated = team.Config.MDM.MacOSUpdates.MinimumVersion.Value != payload.MDM.MacOSUpdates.MinimumVersion.Value || - team.Config.MDM.MacOSUpdates.Deadline.Value != payload.MDM.MacOSUpdates.Deadline.Value + team.Config.MDM.MacOSUpdates.Deadline.Value != payload.MDM.MacOSUpdates.Deadline.Value || + team.Config.MDM.MacOSUpdates.DeadlineDays.Value != payload.MDM.MacOSUpdates.DeadlineDays.Value || + team.Config.MDM.MacOSUpdates.DeadlineDays.Valid != payload.MDM.MacOSUpdates.DeadlineDays.Valid updateNewHostsChanged = team.Config.MDM.MacOSUpdates.UpdateNewHosts.Value != payload.MDM.MacOSUpdates.UpdateNewHosts.Value team.Config.MDM.MacOSUpdates = *payload.MDM.MacOSUpdates } @@ -254,9 +256,11 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T return nil, fleet.NewInvalidArgumentError("ios_updates", err.Error()) } - if payload.MDM.IOSUpdates.MinimumVersion.Set || payload.MDM.IOSUpdates.Deadline.Set { + if payload.MDM.IOSUpdates.MinimumVersion.Set || payload.MDM.IOSUpdates.Deadline.Set || payload.MDM.IOSUpdates.DeadlineDays.Set { iOSMinVersionUpdated = team.Config.MDM.IOSUpdates.MinimumVersion.Value != payload.MDM.IOSUpdates.MinimumVersion.Value || - team.Config.MDM.IOSUpdates.Deadline.Value != payload.MDM.IOSUpdates.Deadline.Value + team.Config.MDM.IOSUpdates.Deadline.Value != payload.MDM.IOSUpdates.Deadline.Value || + team.Config.MDM.IOSUpdates.DeadlineDays.Value != payload.MDM.IOSUpdates.DeadlineDays.Value || + team.Config.MDM.IOSUpdates.DeadlineDays.Valid != payload.MDM.IOSUpdates.DeadlineDays.Valid team.Config.MDM.IOSUpdates = *payload.MDM.IOSUpdates } } @@ -264,9 +268,11 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T if err := payload.MDM.IPadOSUpdates.Validate(); err != nil { return nil, fleet.NewInvalidArgumentError("ipados_updates", err.Error()) } - if payload.MDM.IPadOSUpdates.MinimumVersion.Set || payload.MDM.IPadOSUpdates.Deadline.Set { + if payload.MDM.IPadOSUpdates.MinimumVersion.Set || payload.MDM.IPadOSUpdates.Deadline.Set || payload.MDM.IPadOSUpdates.DeadlineDays.Set { iPadOSMinVersionUpdated = team.Config.MDM.IPadOSUpdates.MinimumVersion.Value != payload.MDM.IPadOSUpdates.MinimumVersion.Value || - team.Config.MDM.IPadOSUpdates.Deadline.Value != payload.MDM.IPadOSUpdates.Deadline.Value + team.Config.MDM.IPadOSUpdates.Deadline.Value != payload.MDM.IPadOSUpdates.Deadline.Value || + team.Config.MDM.IPadOSUpdates.DeadlineDays.Value != payload.MDM.IPadOSUpdates.DeadlineDays.Value || + team.Config.MDM.IPadOSUpdates.DeadlineDays.Valid != payload.MDM.IPadOSUpdates.DeadlineDays.Valid team.Config.MDM.IPadOSUpdates = *payload.MDM.IPadOSUpdates } } @@ -1468,11 +1474,17 @@ func (svc *Service) ApplyTeamSpecs(ctx context.Context, specs []*fleet.TeamSpec, return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("secrets", "enroll secret must not be empty"), "validate secrets") } } - // TODO: should we be we validating the other Apple platforms? if so, we should also include - // ValidateMDMSettingsAppleSupportedOSVersion for each platform + // TODO: we should also include ValidateMDMSettingsAppleSupportedOSVersion for + // each platform here, as the API paths do. if err := spec.MDM.MacOSUpdates.Validate(); err != nil { return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("macos_updates", err.Error())) } + if err := spec.MDM.IOSUpdates.Validate(); err != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("ios_updates", err.Error())) + } + if err := spec.MDM.IPadOSUpdates.Validate(); err != nil { + return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("ipados_updates", err.Error())) + } if err := spec.MDM.WindowsUpdates.Validate(); err != nil { return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("windows_updates", err.Error())) } @@ -1827,19 +1839,25 @@ func (svc *Service) editTeamFromSpec( mdmIPadOSUpdatesEdited bool mdmWindowsUpdatesEdited bool ) - if spec.MDM.MacOSUpdates.Deadline.Set || spec.MDM.MacOSUpdates.MinimumVersion.Set || spec.MDM.MacOSUpdates.UpdateNewHosts.Set { + if spec.MDM.MacOSUpdates.Deadline.Set || spec.MDM.MacOSUpdates.MinimumVersion.Set || spec.MDM.MacOSUpdates.DeadlineDays.Set || spec.MDM.MacOSUpdates.UpdateNewHosts.Set { mdmMacOSUpdatesEdited = team.Config.MDM.MacOSUpdates.MinimumVersion.Value != spec.MDM.MacOSUpdates.MinimumVersion.Value || - team.Config.MDM.MacOSUpdates.Deadline.Value != spec.MDM.MacOSUpdates.Deadline.Value + team.Config.MDM.MacOSUpdates.Deadline.Value != spec.MDM.MacOSUpdates.Deadline.Value || + team.Config.MDM.MacOSUpdates.DeadlineDays.Value != spec.MDM.MacOSUpdates.DeadlineDays.Value || + team.Config.MDM.MacOSUpdates.DeadlineDays.Valid != spec.MDM.MacOSUpdates.DeadlineDays.Valid team.Config.MDM.MacOSUpdates = spec.MDM.MacOSUpdates } - if spec.MDM.IOSUpdates.Deadline.Set || spec.MDM.IOSUpdates.MinimumVersion.Set { + if spec.MDM.IOSUpdates.Deadline.Set || spec.MDM.IOSUpdates.MinimumVersion.Set || spec.MDM.IOSUpdates.DeadlineDays.Set { mdmIOSUpdatesEdited = team.Config.MDM.IOSUpdates.MinimumVersion.Value != spec.MDM.IOSUpdates.MinimumVersion.Value || - team.Config.MDM.IOSUpdates.Deadline.Value != spec.MDM.IOSUpdates.Deadline.Value + team.Config.MDM.IOSUpdates.Deadline.Value != spec.MDM.IOSUpdates.Deadline.Value || + team.Config.MDM.IOSUpdates.DeadlineDays.Value != spec.MDM.IOSUpdates.DeadlineDays.Value || + team.Config.MDM.IOSUpdates.DeadlineDays.Valid != spec.MDM.IOSUpdates.DeadlineDays.Valid team.Config.MDM.IOSUpdates = spec.MDM.IOSUpdates } - if spec.MDM.IPadOSUpdates.Deadline.Set || spec.MDM.IPadOSUpdates.MinimumVersion.Set { + if spec.MDM.IPadOSUpdates.Deadline.Set || spec.MDM.IPadOSUpdates.MinimumVersion.Set || spec.MDM.IPadOSUpdates.DeadlineDays.Set { mdmIPadOSUpdatesEdited = team.Config.MDM.IPadOSUpdates.MinimumVersion.Value != spec.MDM.IPadOSUpdates.MinimumVersion.Value || - team.Config.MDM.IPadOSUpdates.Deadline.Value != spec.MDM.IPadOSUpdates.Deadline.Value + team.Config.MDM.IPadOSUpdates.Deadline.Value != spec.MDM.IPadOSUpdates.Deadline.Value || + team.Config.MDM.IPadOSUpdates.DeadlineDays.Value != spec.MDM.IPadOSUpdates.DeadlineDays.Value || + team.Config.MDM.IPadOSUpdates.DeadlineDays.Valid != spec.MDM.IPadOSUpdates.DeadlineDays.Valid team.Config.MDM.IPadOSUpdates = spec.MDM.IPadOSUpdates } diff --git a/ee/server/service/teams_test.go b/ee/server/service/teams_test.go index d24ef5dc19..b4e1e6646f 100644 --- a/ee/server/service/teams_test.go +++ b/ee/server/service/teams_test.go @@ -11,6 +11,7 @@ import ( "github.com/fleetdm/fleet/v4/server/authz" "github.com/fleetdm/fleet/v4/server/config" "github.com/fleetdm/fleet/v4/server/fleet" + mdmtest "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" "github.com/fleetdm/fleet/v4/server/mock" svcmock "github.com/fleetdm/fleet/v4/server/mock/service" "github.com/fleetdm/fleet/v4/server/ptr" @@ -1601,3 +1602,391 @@ func TestDeleteTeamWindowsEnrollmentDefaultFleet(t *testing.T) { }) } } + +func TestModifyTeamOSUpdatesDeadlineDays(t *testing.T) { + // A deadline_days-only edit must be treated as a change: the setting has to be + // stored and the OS update declaration regenerated. Before deadline_days was + // part of the change detection, both were silently skipped. + testCases := []struct { + name string + storedDays optjson.Int + payloadDays optjson.Int + wantSaved int + wantRedeploy bool + }{ + { + name: "deadline_days changed", + storedDays: optjson.SetInt(14), + payloadDays: optjson.SetInt(21), + wantSaved: 21, + wantRedeploy: true, + }, + { + name: "deadline_days set from unset", + storedDays: optjson.Int{}, + payloadDays: optjson.SetInt(14), + wantSaved: 14, + wantRedeploy: true, + }, + { + name: "deadline_days unchanged", + storedDays: optjson.SetInt(14), + payloadDays: optjson.SetInt(14), + wantSaved: 14, + wantRedeploy: false, + }, + } + + 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) { + var gotActivities []fleet.ActivityDetails + mockSvc := &svcmock.Service{} + mockSvc.NewActivityFunc = func(_ context.Context, _ *fleet.User, a fleet.ActivityDetails) error { + gotActivities = append(gotActivities, a) + return nil + } + + ds := new(mock.Store) + ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.TeamWithExtrasFunc = func(_ context.Context, tid uint) (*fleet.Team, error) { + return &fleet.Team{ID: tid, Name: "team-1", Config: fleet.TeamConfig{ + MDM: fleet.TeamMDM{ + MacOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: tc.storedDays, + }, + }, + }}, nil + } + var savedTeam *fleet.Team + ds.SaveTeamFunc = func(_ context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.HasAppleUpdateConfigProfileConfiguredFunc = func(_ context.Context, teamID uint) (bool, error) { + return false, nil + } + ds.LabelIDsByNameFunc = func(_ context.Context, names []string, _ fleet.TeamFilter) (map[string]uint, error) { + ids := make(map[string]uint, len(names)) + for i, name := range names { + ids[name] = uint(i + 1) //nolint:gosec + } + return ids, nil + } + var gotDecl *fleet.MDMAppleDeclaration + var gotVars []fleet.FleetVarName + ds.SetOrUpdateMDMAppleDeclarationFunc = func(_ context.Context, decl *fleet.MDMAppleDeclaration, + usesFleetVars []fleet.FleetVarName, + ) (*fleet.MDMAppleDeclaration, error) { + gotDecl = decl + gotVars = usesFleetVars + decl.DeclarationUUID = "decl-uuid" + return decl, nil + } + + svc := &Service{ + Service: mockSvc, + ds: ds, + config: config.FleetConfig{Server: config.ServerConfig{PrivateKey: "something"}}, + authz: authorizer, + } + + payload := fleet.TeamPayload{MDM: &fleet.TeamPayloadMDM{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: tc.payloadDays, + }, + }} + team, err := svc.ModifyTeam(ctx, 1, payload) + require.NoError(t, err) + require.NotNil(t, team) + + // The outer Set guard also controls whether the value is stored at all. + require.NotNil(t, savedTeam) + require.Equal(t, tc.wantSaved, savedTeam.Config.MDM.MacOSUpdates.DeadlineDays.Value) + + require.Equal(t, tc.wantRedeploy, ds.SetOrUpdateMDMAppleDeclarationFuncInvoked, + "declaration regeneration must follow the change detection") + if tc.wantRedeploy { + require.NotNil(t, gotDecl) + require.Contains(t, string(gotDecl.RawJSON), "$FLEET_VAR_HOST_TARGET_OS_VERSION") + require.Len(t, gotVars, 2) + } + + // The activity feed renders "updated macOS version to latest" from + // minimum_version, so the payload has to carry the sentinel through. + // Deadline stays empty in latest mode, which is what makes the + // renderer drop its "(deadline: ...)" clause. + var osUpdateActivities []fleet.ActivityTypeEditedMacOSMinVersion + for _, a := range gotActivities { + if edited, ok := a.(fleet.ActivityTypeEditedMacOSMinVersion); ok { + osUpdateActivities = append(osUpdateActivities, edited) + } + } + if !tc.wantRedeploy { + require.Empty(t, osUpdateActivities, "an unchanged setting must not emit an activity") + return + } + require.Len(t, osUpdateActivities, 1) + require.Equal(t, fleet.AppleOSUpdateLatestVersion, osUpdateActivities[0].MinimumVersion) + require.Empty(t, osUpdateActivities[0].Deadline) + require.NotNil(t, osUpdateActivities[0].TeamID) + require.Equal(t, uint(1), *osUpdateActivities[0].TeamID) + }) + } +} + +// ModifyTeam validates the incoming payload and then replaces the whole +// AppleOSUpdateSettings struct, so a stored deadline field can't leak into the +// validated value. ModifyAppConfig merges the payload over the stored config +// instead, which is why it needed clearStaleAppleOSUpdateDeadline and this +// doesn't. These cases lock that in for both directions: a sparse PATCH that +// switches mode must succeed and must not persist the outgoing mode's deadline. +func TestModifyTeamSwitchingOSUpdateModes(t *testing.T) { + authorizer, err := authz.NewAuthorizer() + require.NoError(t, err) + ctx := test.UserContext(context.Background(), + &fleet.User{ID: 1, GlobalRole: new(fleet.RoleAdmin)}) + + storedLatest := fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + } + + setup := func(t *testing.T, stored fleet.AppleOSUpdateSettings) (*Service, func() *fleet.Team) { + // ModifyTeam checks minimum_version against GDMF, so serve Apple's asset + // list from the local fixture rather than reaching out to Apple. + mdmtest.StartNewAppleGDMFTestServer(t) + + mockSvc := &svcmock.Service{} + mockSvc.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error { + return nil + } + + ds := new(mock.Store) + ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.TeamWithExtrasFunc = func(_ context.Context, tid uint) (*fleet.Team, error) { + return &fleet.Team{ID: tid, Name: "team-1", Config: fleet.TeamConfig{ + MDM: fleet.TeamMDM{MacOSUpdates: stored}, + }}, nil + } + var savedTeam *fleet.Team + ds.SaveTeamFunc = func(_ context.Context, team *fleet.Team) (*fleet.Team, error) { + savedTeam = team + return team, nil + } + ds.HasAppleUpdateConfigProfileConfiguredFunc = func(context.Context, uint) (bool, error) { + return false, nil + } + ds.LabelIDsByNameFunc = func(_ context.Context, names []string, _ fleet.TeamFilter) (map[string]uint, error) { + ids := make(map[string]uint, len(names)) + for i, name := range names { + ids[name] = uint(i + 1) //nolint:gosec // G115: small test values + } + return ids, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(_ context.Context, decl *fleet.MDMAppleDeclaration, + _ []fleet.FleetVarName, + ) (*fleet.MDMAppleDeclaration, error) { + decl.DeclarationUUID = "decl-uuid" + return decl, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(context.Context, *uint, string) error { + return nil + } + + return &Service{ + Service: mockSvc, + ds: ds, + config: config.FleetConfig{Server: config.ServerConfig{PrivateKey: "something"}}, + authz: authorizer, + }, func() *fleet.Team { return savedTeam } + } + + t.Run("switching to a specific version", func(t *testing.T) { + svc, saved := setup(t, storedLatest) + + // deadline_days is deliberately absent, as a sparse PATCH would leave it. + _, err := svc.ModifyTeam(ctx, 1, fleet.TeamPayload{MDM: &fleet.TeamPayloadMDM{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.6.1"), + Deadline: optjson.SetString("2026-09-01"), + }, + }}) + require.NoError(t, err) + + require.NotNil(t, saved()) + require.Equal(t, "14.6.1", saved().Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.False(t, saved().Config.MDM.MacOSUpdates.DeadlineDays.Valid, + "the stored deadline_days must not survive the mode change") + }) + + t.Run("clearing enforcement entirely", func(t *testing.T) { + svc, saved := setup(t, storedLatest) + + _, err := svc.ModifyTeam(ctx, 1, fleet.TeamPayload{MDM: &fleet.TeamPayloadMDM{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(""), + Deadline: optjson.SetString(""), + }, + }}) + require.NoError(t, err) + + require.NotNil(t, saved()) + require.Empty(t, saved().Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.False(t, saved().Config.MDM.MacOSUpdates.DeadlineDays.Valid) + }) + + t.Run("switching into latest mode from a specific version", func(t *testing.T) { + // the mirror direction: a stored deadline is the stale field here, and the + // wholesale replace has to drop it just the same. + svc, saved := setup(t, fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.6.1"), + Deadline: optjson.SetString("2026-09-01"), + }) + + // deadline is deliberately absent, as a sparse PATCH would leave it. + _, err := svc.ModifyTeam(ctx, 1, fleet.TeamPayload{MDM: &fleet.TeamPayloadMDM{ + MacOSUpdates: &fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + }, + }}) + require.NoError(t, err) + + require.NotNil(t, saved()) + require.Equal(t, fleet.AppleOSUpdateLatestVersion, saved().Config.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, 14, saved().Config.MDM.MacOSUpdates.DeadlineDays.Value) + require.Empty(t, saved().Config.MDM.MacOSUpdates.Deadline.Value, + "the stored deadline must not survive the mode change") + }) +} + +func TestApplyTeamSpecsOSUpdatesValidation(t *testing.T) { + // GitOps applies team settings through editTeamFromSpec, which validates each + // Apple platform's OS update settings. All three must reject invalid settings, + // keyed by the platform that is at fault. + latest := func(days optjson.Int) fleet.AppleOSUpdateSettings { + return fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: days, + } + } + valid := latest(optjson.SetInt(14)) + missingDays := latest(optjson.Int{}) + + testCases := []struct { + name string + mdm fleet.TeamSpecMDM + wantErr string + }{ + { + name: "all platforms valid", + mdm: fleet.TeamSpecMDM{MacOSUpdates: valid, IOSUpdates: valid, IPadOSUpdates: valid}, + }, + { + name: "macos missing deadline_days", + mdm: fleet.TeamSpecMDM{MacOSUpdates: missingDays}, + wantErr: "macos_updates", + }, + { + name: "ios missing deadline_days", + mdm: fleet.TeamSpecMDM{IOSUpdates: missingDays}, + wantErr: "ios_updates", + }, + { + name: "ipados missing deadline_days", + mdm: fleet.TeamSpecMDM{IPadOSUpdates: missingDays}, + wantErr: "ipados_updates", + }, + { + name: "macos deadline with latest", + mdm: fleet.TeamSpecMDM{MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), Deadline: optjson.SetString("2026-09-01"), DeadlineDays: optjson.SetInt(14)}}, + wantErr: "macos_updates", + }, + { + // Not a "latest" case: a half-configured block was accepted before iOS + // was validated here, then enforced nothing because Configured() needs + // both fields. Existing fleet files like this now fail the apply. + name: "ios version without deadline", + mdm: fleet.TeamSpecMDM{IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.SetString("17.5")}}, + wantErr: "ios_updates", + }, + } + + 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) { + mockSvc := &svcmock.Service{} + mockSvc.NewActivityFunc = func(context.Context, *fleet.User, fleet.ActivityDetails) error { + return nil + } + + ds := new(mock.Store) + ds.AppConfigFunc = func(context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{MDM: fleet.MDM{EnabledAndConfigured: true}}, nil + } + ds.TeamByNameFunc = func(_ context.Context, name string) (*fleet.Team, error) { + return &fleet.Team{ID: 1, Name: name}, nil + } + ds.TeamConflictsWithNameFunc = func(context.Context, string, uint) (*fleet.Team, error) { + return nil, nil + } + ds.IsEnrollSecretAvailableFunc = func(context.Context, string, bool, *uint) (bool, error) { + return true, nil + } + ds.SaveTeamFunc = func(_ context.Context, team *fleet.Team) (*fleet.Team, error) { + return team, nil + } + ds.HasAppleUpdateConfigProfileConfiguredFunc = func(context.Context, uint) (bool, error) { + return false, nil + } + ds.LabelIDsByNameFunc = func(_ context.Context, names []string, _ fleet.TeamFilter) (map[string]uint, error) { + ids := make(map[string]uint, len(names)) + for i, name := range names { + ids[name] = uint(i + 1) //nolint:gosec + } + return ids, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(_ context.Context, decl *fleet.MDMAppleDeclaration, + _ []fleet.FleetVarName, + ) (*fleet.MDMAppleDeclaration, error) { + decl.DeclarationUUID = "decl-uuid" + return decl, nil + } + + svc := &Service{ + Service: mockSvc, + ds: ds, + config: config.FleetConfig{Server: config.ServerConfig{PrivateKey: "something"}}, + authz: authorizer, + } + + _, err := svc.ApplyTeamSpecs(ctx, + []*fleet.TeamSpec{{Name: "team-1", MDM: tc.mdm}}, + fleet.ApplyTeamSpecOptions{}) + + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr, + "the error must name the platform whose settings are invalid") + require.False(t, ds.SaveTeamFuncInvoked, "an invalid spec must not be persisted") + }) + } +} diff --git a/server/datastore/mysql/schema.sql b/server/datastore/mysql/schema.sql index d9d81e6c28..0b8855548c 100644 --- a/server/datastore/mysql/schema.sql +++ b/server/datastore/mysql/schema.sql @@ -231,7 +231,7 @@ CREATE TABLE `app_config_json` ( PRIMARY KEY (`id`) ) /*!50100 TABLESPACE `innodb_system` */ ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; -INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"lock_end_user_info\": false, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"require_all_software_macos\": false, \"end_user_local_account_type\": \"admin\", \"enable_managed_local_account\": false, \"require_all_software_windows\": false, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": false}, \"name_template\": null, \"ipados_updates\": {\"deadline\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"android_settings\": {\"certificates\": null, \"custom_settings\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null, \"managed_local_account_settings\": {\"enabled\": false}}, \"windows_enrollment\": null, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_entra_client_ids\": [], \"windows_entra_tenant_ids\": [], \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"apple_account_provisioning\": {\"oauth_idp_client_id\": null, \"oauth_idp_token_url\": null, \"oauth_idp_client_secret\": null}, \"enable_recovery_lock_password\": false, \"windows_require_bitlocker_pin\": null, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false, \"apple_require_hardware_attestation\": false, \"enable_turn_on_windows_mdm_manually\": false}, \"gitops\": {\"exceptions\": {\"labels\": true, \"secrets\": true, \"software\": false}, \"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"historical_data\": {\"uptime\": true, \"vulnerabilities\": true}, \"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_dark_mode\": \"\", \"org_logo_url_light_mode\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"sso_server_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\", \"alternative_browser_host\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false, \"preserve_host_activities_on_reenrollment\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); +INSERT INTO `app_config_json` VALUES (1,'{\"mdm\": {\"ios_updates\": {\"deadline\": null, \"deadline_days\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_setup\": {\"script\": null, \"software\": null, \"bootstrap_package\": null, \"lock_end_user_info\": false, \"manual_agent_install\": null, \"macos_setup_assistant\": null, \"require_all_software_macos\": false, \"end_user_local_account_type\": \"admin\", \"enable_managed_local_account\": false, \"require_all_software_windows\": false, \"enable_end_user_authentication\": false, \"enable_release_device_manually\": false}, \"macos_updates\": {\"deadline\": null, \"deadline_days\": null, \"minimum_version\": null, \"update_new_hosts\": false}, \"name_template\": null, \"ipados_updates\": {\"deadline\": null, \"deadline_days\": null, \"minimum_version\": null, \"update_new_hosts\": null}, \"macos_settings\": {\"custom_settings\": null}, \"macos_migration\": {\"mode\": \"\", \"enable\": false, \"webhook_url\": \"\"}, \"windows_updates\": {\"deadline_days\": null, \"grace_period_days\": null}, \"android_settings\": {\"certificates\": null, \"custom_settings\": null}, \"apple_server_url\": \"\", \"windows_settings\": {\"custom_settings\": null, \"managed_local_account_settings\": {\"enabled\": false}}, \"windows_enrollment\": null, \"apple_bm_terms_expired\": false, \"apple_business_manager\": null, \"enable_disk_encryption\": false, \"enabled_and_configured\": false, \"end_user_authentication\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"issuer_uri\": \"\", \"metadata_url\": \"\"}, \"windows_entra_client_ids\": [], \"windows_entra_tenant_ids\": [], \"volume_purchasing_program\": null, \"windows_migration_enabled\": false, \"apple_account_provisioning\": {\"oauth_idp_client_id\": null, \"oauth_idp_token_url\": null, \"oauth_idp_client_secret\": null}, \"enable_recovery_lock_password\": false, \"windows_require_bitlocker_pin\": null, \"android_enabled_and_configured\": false, \"windows_enabled_and_configured\": false, \"apple_bm_enabled_and_configured\": false, \"apple_require_hardware_attestation\": false, \"enable_turn_on_windows_mdm_manually\": false}, \"gitops\": {\"exceptions\": {\"labels\": true, \"secrets\": true, \"software\": false}, \"repository_url\": \"\", \"gitops_mode_enabled\": false}, \"scripts\": null, \"features\": {\"historical_data\": {\"uptime\": true, \"vulnerabilities\": true}, \"enable_host_users\": true, \"enable_software_inventory\": false}, \"org_info\": {\"org_name\": \"\", \"contact_url\": \"\", \"org_logo_url\": \"\", \"org_logo_url_dark_mode\": \"\", \"org_logo_url_light_mode\": \"\", \"org_logo_url_light_background\": \"\"}, \"integrations\": {\"jira\": null, \"zendesk\": null, \"google_calendar\": null, \"conditional_access_enabled\": null}, \"sso_settings\": {\"idp_name\": \"\", \"metadata\": \"\", \"entity_id\": \"\", \"enable_sso\": false, \"issuer_uri\": \"\", \"metadata_url\": \"\", \"idp_image_url\": \"\", \"sso_server_url\": \"\", \"enable_jit_role_sync\": false, \"enable_sso_idp_login\": false, \"enable_jit_provisioning\": false}, \"agent_options\": {\"config\": {\"options\": {\"logger_plugin\": \"tls\", \"pack_delimiter\": \"/\", \"logger_tls_period\": 10, \"distributed_plugin\": \"tls\", \"disable_distributed\": false, \"logger_tls_endpoint\": \"/api/osquery/log\", \"distributed_interval\": 10, \"distributed_tls_max_attempts\": 3}, \"decorators\": {\"load\": [\"SELECT uuid AS host_uuid FROM system_info;\", \"SELECT hostname AS hostname FROM system_info;\"]}}, \"overrides\": {}}, \"fleet_desktop\": {\"transparency_url\": \"\", \"alternative_browser_host\": \"\"}, \"smtp_settings\": {\"port\": 587, \"domain\": \"\", \"server\": \"\", \"password\": \"\", \"user_name\": \"\", \"configured\": false, \"enable_smtp\": false, \"enable_ssl_tls\": true, \"sender_address\": \"\", \"enable_start_tls\": true, \"verify_ssl_certs\": true, \"authentication_type\": \"0\", \"authentication_method\": \"0\"}, \"server_settings\": {\"server_url\": \"\", \"enable_analytics\": false, \"query_report_cap\": 0, \"scripts_disabled\": false, \"deferred_save_host\": false, \"live_query_disabled\": false, \"ai_features_disabled\": false, \"query_reports_disabled\": false}, \"webhook_settings\": {\"interval\": \"0s\", \"activities_webhook\": {\"destination_url\": \"\", \"enable_activities_webhook\": false}, \"host_status_webhook\": {\"days_count\": 0, \"destination_url\": \"\", \"host_percentage\": 0, \"enable_host_status_webhook\": false}, \"vulnerabilities_webhook\": {\"destination_url\": \"\", \"host_batch_size\": 0, \"enable_vulnerabilities_webhook\": false}, \"failing_policies_webhook\": {\"policy_ids\": null, \"destination_url\": \"\", \"host_batch_size\": 0, \"enable_failing_policies_webhook\": false}}, \"host_expiry_settings\": {\"host_expiry_window\": 0, \"host_expiry_enabled\": false}, \"vulnerability_settings\": {\"databases_path\": \"\"}, \"activity_expiry_settings\": {\"activity_expiry_window\": 0, \"activity_expiry_enabled\": false, \"preserve_host_activities_on_reenrollment\": false}}','2020-01-01 01:01:01','2020-01-01 01:01:01'); /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `apple_software_update_assets` ( diff --git a/server/datastore/mysql/teams_test.go b/server/datastore/mysql/teams_test.go index 9b76d36c51..10fd48440c 100644 --- a/server/datastore/mysql/teams_test.go +++ b/server/datastore/mysql/teams_test.go @@ -869,20 +869,25 @@ func testTeamsMDMConfig(t *testing.T, ds *Datastore) { mdm, err := ds.TeamMDMConfig(ctx, team.ID) require.NoError(t, err) + // The config round-trips through JSON, which always carries + // deadline_days, so it reads back set-but-null rather than unset. assert.Equal(t, &fleet.TeamMDM{ MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("10.15.0"), Deadline: optjson.SetString("2025-10-01"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("11.11.11"), Deadline: optjson.SetString("2024-04-04"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("12.12.12"), Deadline: optjson.SetString("2023-03-03"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, WindowsUpdates: fleet.WindowsUpdates{ diff --git a/server/fleet/app.go b/server/fleet/app.go index 5141541ff0..f82a7f8d4c 100644 --- a/server/fleet/app.go +++ b/server/fleet/app.go @@ -398,15 +398,56 @@ type AppleOSUpdateSettings struct { // Deadline the required installation date for Nudge to enforce the required // operating system version. Deadline optjson.String `json:"deadline"` + // DeadlineDays is the number of days after an OS version's release date + // before the update is enforced. It is only valid when MinimumVersion is + // "latest", where the deadline is relative to each version's release rather + // than a fixed calendar date. + DeadlineDays optjson.Int `json:"deadline_days"` +} + +// AppleOSUpdateLatestVersion is the sentinel MinimumVersion value meaning +// "enforce the newest version Apple offers for each host's hardware". The +// target version is resolved per host, and the deadline is derived from that +// version's release date plus DeadlineDays rather than being a fixed date. +const AppleOSUpdateLatestVersion = "latest" + +// EnforcesLatestVersion returns whether these settings enforce the latest +// available OS version rather than a specific one. +func (m AppleOSUpdateSettings) EnforcesLatestVersion() bool { + return m.MinimumVersion.Value == AppleOSUpdateLatestVersion } // Configured returns a boolean indicating if updates are configured func (m AppleOSUpdateSettings) Configured() bool { + if m.EnforcesLatestVersion() { + // In "latest" mode the deadline is relative to each version's release + // date, so DeadlineDays stands in for Deadline. + return m.DeadlineDays.Valid && m.DeadlineDays.Value > 0 + } return m.Deadline.Value != "" && m.MinimumVersion.Value != "" } func (m AppleOSUpdateSettings) Validate() error { + if m.EnforcesLatestVersion() { + if m.Deadline.Value != "" { + return errors.New(`deadline cannot be set when minimum_version is set to "latest". Use deadline_days instead`) + } + if !m.DeadlineDays.Valid { + return errors.New(`deadline_days is required when minimum_version is set to "latest"`) + } + if m.DeadlineDays.Value < 1 { + return errors.New("deadline_days must be greater than 0") + } + return nil + } + + // DeadlineDays is meaningless without a version to resolve it against, so + // reject it for a specific version and when no version is provided at all. + if m.DeadlineDays.Valid { + return errors.New(`deadline_days can only be set when minimum_version is set to "latest". Use deadline instead`) + } + // if no settings are provided it's okay to skip further validation if m.MinimumVersion.Value == "" && m.Deadline.Value == "" { // if one is set and empty, the other must be set and empty too, otherwise diff --git a/server/fleet/app_test.go b/server/fleet/app_test.go index b2edc9c316..9d66d22083 100644 --- a/server/fleet/app_test.go +++ b/server/fleet/app_test.go @@ -119,6 +119,107 @@ func TestMacOSUpdatesValidate(t *testing.T) { }) } +func TestAppleOSUpdatesLatestValidate(t *testing.T) { + t.Run("valid", func(t *testing.T) { + cases := []struct { + name string + m AppleOSUpdateSettings + }{ + { + "latest with deadline_days", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + DeadlineDays: optjson.SetInt(14), + }, + }, + { + "latest with deadline_days of 1", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + DeadlineDays: optjson.SetInt(1), + }, + }, + { + "latest with explicitly empty deadline", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + Deadline: optjson.SetString(""), + DeadlineDays: optjson.SetInt(14), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.NoError(t, tc.m.Validate()) + }) + } + }) + + t.Run("invalid", func(t *testing.T) { + cases := []struct { + name string + m AppleOSUpdateSettings + }{ + { + "latest without deadline_days", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + }, + }, + { + "latest with null deadline_days", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + DeadlineDays: optjson.Int{Set: true, Valid: false}, + }, + }, + { + "latest with deadline", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + Deadline: optjson.SetString("2026-09-01"), + DeadlineDays: optjson.SetInt(14), + }, + }, + { + "latest with zero deadline_days", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + DeadlineDays: optjson.SetInt(0), + }, + }, + { + "latest with negative deadline_days", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("latest"), + DeadlineDays: optjson.SetInt(-1), + }, + }, + { + "specific version with deadline_days", + AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("15.1"), + Deadline: optjson.SetString("2026-09-01"), + DeadlineDays: optjson.SetInt(14), + }, + }, + { + "deadline_days with no version", + AppleOSUpdateSettings{ + DeadlineDays: optjson.SetInt(14), + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Error(t, tc.m.Validate()) + }) + } + }) +} + func TestWindowsUpdatesValidate(t *testing.T) { cases := []struct { name string @@ -174,24 +275,39 @@ func TestWindowsUpdatesEqual(t *testing.T) { } func TestMacOSUpdatesConfigured(t *testing.T) { + // nullDeadlineDays is what `"deadline_days": null` unmarshals to: the key was + // present but carried no value. + nullDeadlineDays := optjson.Int{Set: true, Valid: false} + cases := []struct { - version string - deadline string - out bool + name string + version string + deadline string + deadlineDays optjson.Int + out bool }{ - {"", "", false}, - {"", "", false}, - {"12.3", "", false}, - {"", "12-03-2022", false}, - {"12.3", "12-03-2022", true}, + {"empty", "", "", optjson.Int{}, false}, + {"version only", "12.3", "", optjson.Int{}, false}, + {"deadline only", "", "12-03-2022", optjson.Int{}, false}, + {"version and deadline", "12.3", "12-03-2022", optjson.Int{}, true}, + + // "latest" mode: DeadlineDays stands in for Deadline. + {"latest with deadline_days", AppleOSUpdateLatestVersion, "", optjson.SetInt(14), true}, + {"latest without deadline_days", AppleOSUpdateLatestVersion, "", optjson.Int{}, false}, + {"latest with null deadline_days", AppleOSUpdateLatestVersion, "", nullDeadlineDays, false}, + {"latest with zero deadline_days", AppleOSUpdateLatestVersion, "", optjson.SetInt(0), false}, + {"cleared", "", "", nullDeadlineDays, false}, } for _, tc := range cases { - m := AppleOSUpdateSettings{ - MinimumVersion: optjson.SetString(tc.version), - Deadline: optjson.SetString(tc.deadline), - } - require.Equal(t, tc.out, m.Configured()) + t.Run(tc.name, func(t *testing.T) { + m := AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(tc.version), + Deadline: optjson.SetString(tc.deadline), + DeadlineDays: tc.deadlineDays, + } + require.Equal(t, tc.out, m.Configured()) + }) } } diff --git a/server/fleet/mdm.go b/server/fleet/mdm.go index 1a43b54b02..204b1d8436 100644 --- a/server/fleet/mdm.go +++ b/server/fleet/mdm.go @@ -79,6 +79,15 @@ const ( FleetVarHostUUID FleetVarName = "HOST_UUID" FleetVarHostPlatform FleetVarName = "HOST_PLATFORM" + // FleetVarHostTargetOSVersion and FleetVarHostTargetOSDeadline are + // Fleet-internal: they are only ever placed in Fleet's own OS-update + // declaration when the platform's minimum_version is "latest", and are + // resolved per host at declaration fetch time from host_mdm_apple_os_updates. + // They are deliberately absent from the lists of variables admins may use in + // their own profiles and declarations. + FleetVarHostTargetOSVersion FleetVarName = "HOST_TARGET_OS_VERSION" + FleetVarHostTargetOSDeadline FleetVarName = "HOST_TARGET_OS_DEADLINE" + // FleetVarPSSODeviceRegistrationToken is the admin-facing variable placed in // the RegistrationToken key of a Fleet com.apple.extensiblesso (Platform SSO // v2) payload. It resolves to the FLEET_HOST_SECRET_ placeholder of the same diff --git a/server/mdm/apple/apple_mdm.go b/server/mdm/apple/apple_mdm.go index 76133ddbe1..74419ab603 100644 --- a/server/mdm/apple/apple_mdm.go +++ b/server/mdm/apple/apple_mdm.go @@ -1901,7 +1901,15 @@ func ValidateMDMSettingsAppleSupportedOSVersion[T fleet.MDM | fleet.TeamMDM](set return nil, errors.New("invalid settings type") } - if macOSUpdates.MinimumVersion.Value == "" && iOSUpdates.MinimumVersion.Value == "" && iPadOSUpdates.MinimumVersion.Value == "" { + // "latest" is a sentinel, not a version: the concrete target is resolved per + // host from Apple's published versions later on, so there is nothing to look + // up here. + needsVersionCheck := func(s fleet.AppleOSUpdateSettings) bool { + return s.MinimumVersion.Value != "" && !s.EnforcesLatestVersion() + } + + if !needsVersionCheck(macOSUpdates) && !needsVersionCheck(iOSUpdates) && !needsVersionCheck(iPadOSUpdates) { + // nothing to validate, so don't pay for the round trip to Apple. return nil, nil } @@ -1914,12 +1922,12 @@ func ValidateMDMSettingsAppleSupportedOSVersion[T fleet.MDM | fleet.TeamMDM](set } invalid := make(map[string]string, 3) - if macOSUpdates.MinimumVersion.Value != "" { + if needsVersionCheck(macOSUpdates) { if ok := am.IsSupportedMacOSVersion(macOSUpdates.MinimumVersion.Value, excludeNonPublicAssetSets); !ok { invalid["macos"] = fleet.AppleOSVersionUnsupportedMessage } } - if iOSUpdates.MinimumVersion.Value != "" { + if needsVersionCheck(iOSUpdates) { // NOTE: iPod generally falls in the category of iOS in Fleet, but we're only validating against iPhone here // because we assume Apple will eventually remove iPod versions from the Apple Software Lookup Service // and we want to avoid breaking workflows for users in that event @@ -1927,7 +1935,7 @@ func ValidateMDMSettingsAppleSupportedOSVersion[T fleet.MDM | fleet.TeamMDM](set invalid["ios"] = fleet.AppleOSVersionUnsupportedMessage } } - if iPadOSUpdates.MinimumVersion.Value != "" { + if needsVersionCheck(iPadOSUpdates) { if ok := am.IsSupportedIOSVersion(iPadOSUpdates.MinimumVersion.Value, "ipad", excludeNonPublicAssetSets); !ok { invalid["ipados"] = fleet.AppleOSVersionUnsupportedMessage } diff --git a/server/mdm/apple/apple_mdm_test.go b/server/mdm/apple/apple_mdm_test.go index fa57d28848..69a001b65e 100644 --- a/server/mdm/apple/apple_mdm_test.go +++ b/server/mdm/apple/apple_mdm_test.go @@ -794,6 +794,68 @@ func TestValidateMDMSettingsAppleSupportedOSVersion(t *testing.T) { }) }) + t.Run("latest", func(t *testing.T) { + // "latest" is a sentinel resolved per host later, so it must never be + // looked up against Apple's published versions. + t.Run("accepted on every platform", func(t *testing.T) { + t.Run("app config mdm settings", func(t *testing.T) { + ac := mockAppConfigMDM() + ac.MacOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + ac.IOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + ac.IPadOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + + got, err := ValidateMDMSettingsAppleSupportedOSVersion(ac, false) + require.NoError(t, err) + assert.Empty(t, got, "expect latest to be accepted when including non-public asset sets") + + got, err = ValidateMDMSettingsAppleSupportedOSVersion(ac, true) + require.NoError(t, err) + assert.Empty(t, got, "expect latest to be accepted when excluding non-public asset sets") + }) + + t.Run("team mdm settings", func(t *testing.T) { + tm := mockTeamMDM() + tm.MacOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + tm.IOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + tm.IPadOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + + got, err := ValidateMDMSettingsAppleSupportedOSVersion(tm, false) + require.NoError(t, err) + assert.Empty(t, got, "expect latest to be accepted when including non-public asset sets") + + got, err = ValidateMDMSettingsAppleSupportedOSVersion(tm, true) + require.NoError(t, err) + assert.Empty(t, got, "expect latest to be accepted when excluding non-public asset sets") + }) + }) + + t.Run("mixed with a real version still validates that version", func(t *testing.T) { + t.Run("app config mdm settings", func(t *testing.T) { + ac := mockAppConfigMDM() + ac.MacOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + // only supported for Apple Watch, so iOS should still be flagged + ac.IOSUpdates.MinimumVersion = optjson.SetString("5.3.9") + ac.IPadOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + + got, err := ValidateMDMSettingsAppleSupportedOSVersion(ac, false) + require.NoError(t, err) + checkErr("ios", fleet.AppleOSVersionUnsupportedMessage, got, "expect only the concrete version to be validated") + }) + + t.Run("team mdm settings", func(t *testing.T) { + tm := mockTeamMDM() + tm.MacOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + // only supported for Apple Watch, so iOS should still be flagged + tm.IOSUpdates.MinimumVersion = optjson.SetString("5.3.9") + tm.IPadOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + + got, err := ValidateMDMSettingsAppleSupportedOSVersion(tm, false) + require.NoError(t, err) + checkErr("ios", fleet.AppleOSVersionUnsupportedMessage, got, "expect only the concrete version to be validated") + }) + }) + }) + // These subtests are placed last so that the dev_mode override cleanup for the error server // doesn't interfere with the earlier subtests that rely on the valid mock server. t.Run("GetAssetMetadata error", func(t *testing.T) { @@ -815,6 +877,16 @@ func TestValidateMDMSettingsAppleSupportedOSVersion(t *testing.T) { got, err = ValidateMDMSettingsAppleSupportedOSVersion(tm, false) require.Error(t, err) assert.Nil(t, got) + + // With every platform set to "latest" there is nothing to look up, so the + // broken metadata endpoint must never be contacted. + ac = mockAppConfigMDM() + ac.MacOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + ac.IOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + ac.IPadOSUpdates.MinimumVersion = optjson.SetString(fleet.AppleOSUpdateLatestVersion) + got, err = ValidateMDMSettingsAppleSupportedOSVersion(ac, false) + require.NoError(t, err, "latest-only settings must not fetch Apple metadata") + assert.Nil(t, got) }) } diff --git a/server/service/appconfig.go b/server/service/appconfig.go index 0c798fff1a..ffcae75ee3 100644 --- a/server/service/appconfig.go +++ b/server/service/appconfig.go @@ -691,6 +691,10 @@ func (svc *Service) ModifyAppConfig(ctx context.Context, p []byte, applyOpts fle appConfig.MDM.IOSUpdates.UpdateNewHosts = optjson.Bool{} appConfig.MDM.IPadOSUpdates.UpdateNewHosts = optjson.Bool{} + clearStaleAppleOSUpdateDeadline(&appConfig.MDM.MacOSUpdates, newAppConfig.MDM.MacOSUpdates) + clearStaleAppleOSUpdateDeadline(&appConfig.MDM.IOSUpdates, newAppConfig.MDM.IOSUpdates) + clearStaleAppleOSUpdateDeadline(&appConfig.MDM.IPadOSUpdates, newAppConfig.MDM.IPadOSUpdates) + // Handle Google Calendar API key preservation/replacement. // The custom GoogleCalendarApiKey type handles unmarshaling "********" as masked. if newAppConfig.Integrations.GoogleCalendar != nil { @@ -1833,7 +1837,10 @@ func (svc *Service) processAppleOSUpdateSettings( newOSUpdateSettings fleet.AppleOSUpdateSettings, ) error { if oldOSUpdateSettings.MinimumVersion.Value != newOSUpdateSettings.MinimumVersion.Value || - oldOSUpdateSettings.Deadline.Value != newOSUpdateSettings.Deadline.Value { + oldOSUpdateSettings.Deadline.Value != newOSUpdateSettings.Deadline.Value || + // Valid as well as Value: going from unset to 0, or 14 to unset, is a change. + oldOSUpdateSettings.DeadlineDays.Value != newOSUpdateSettings.DeadlineDays.Value || + oldOSUpdateSettings.DeadlineDays.Valid != newOSUpdateSettings.DeadlineDays.Valid { if lic.IsPremium() { if err := svc.EnterpriseOverrides.MDMAppleEditedAppleOSUpdates(ctx, nil, appleDevice, newOSUpdateSettings); err != nil { return ctxerr.Wrap(ctx, err, "update DDM profile after Apple OS updates change") @@ -1956,6 +1963,33 @@ func diffStringSlices(old, current []string) (added, removed []string) { return added, removed } +// clearStaleAppleOSUpdateDeadline drops whichever deadline field belongs to the +// mode a PATCH is leaving. The two modes are mutually exclusive — "latest" +// derives its deadline from deadline_days, a specific version uses deadline — +// and Validate rejects the wrong one being present. Because the payload is +// merged over the stored config, a mode switch that doesn't mention the old +// field keeps it and fails validation, forcing callers to send an explicit null +// or empty string just to change modes. +// +// merged is the stored config with the payload already applied; incoming is the +// payload on its own, so its Set flags say what the caller actually sent. A +// value the caller supplied is left alone, so a genuine mismatch still fails +// validation with the error that explains it. +func clearStaleAppleOSUpdateDeadline(merged *fleet.AppleOSUpdateSettings, incoming fleet.AppleOSUpdateSettings) { + if merged.EnforcesLatestVersion() { + if !incoming.Deadline.Set { + // SetString("") rather than the zero value so this still marshals as + // "" — deadline has always been a string on the wire, and null would + // be a breaking change for API consumers. + merged.Deadline = optjson.SetString("") + } + return + } + if !incoming.DeadlineDays.Set { + merged.DeadlineDays = optjson.Int{} + } +} + func (svc *Service) validateMDM( ctx context.Context, lic *fleet.LicenseInfo, @@ -2081,24 +2115,45 @@ func (svc *Service) validateMDM( mdm.MacOSUpdates.MinimumVersion != oldMdm.MacOSUpdates.MinimumVersion updatingMacOSDeadline := mdm.MacOSUpdates.Deadline.Value != "" && mdm.MacOSUpdates.Deadline != oldMdm.MacOSUpdates.Deadline + // deadline_days is the "latest" mode counterpart of deadline, so it has to + // gate on the license too: without it a lapsed-premium instance that already + // enforces "latest" could still edit the deadline. + updatingMacOSDeadlineDays := mdm.MacOSUpdates.DeadlineDays.Valid && + mdm.MacOSUpdates.DeadlineDays != oldMdm.MacOSUpdates.DeadlineDays // IOSUpdates updatingIOSVersion := mdm.IOSUpdates.MinimumVersion.Value != "" && mdm.IOSUpdates.MinimumVersion != oldMdm.IOSUpdates.MinimumVersion updatingIOSDeadline := mdm.IOSUpdates.Deadline.Value != "" && mdm.IOSUpdates.Deadline != oldMdm.IOSUpdates.Deadline + updatingIOSDeadlineDays := mdm.IOSUpdates.DeadlineDays.Valid && + mdm.IOSUpdates.DeadlineDays != oldMdm.IOSUpdates.DeadlineDays // IPadOSUpdates updatingIPadOSVersion := mdm.IPadOSUpdates.MinimumVersion.Value != "" && mdm.IPadOSUpdates.MinimumVersion != oldMdm.IPadOSUpdates.MinimumVersion updatingIPadOSDeadline := mdm.IPadOSUpdates.Deadline.Value != "" && mdm.IPadOSUpdates.Deadline != oldMdm.IPadOSUpdates.Deadline + updatingIPadOSDeadlineDays := mdm.IPadOSUpdates.DeadlineDays.Valid && + mdm.IPadOSUpdates.DeadlineDays != oldMdm.IPadOSUpdates.DeadlineDays - if updatingMacOSVersion || updatingMacOSDeadline || - updatingIOSVersion || updatingIOSDeadline || - updatingIPadOSVersion || updatingIPadOSDeadline { + updatingMacOS := updatingMacOSVersion || updatingMacOSDeadline || updatingMacOSDeadlineDays + updatingIOS := updatingIOSVersion || updatingIOSDeadline || updatingIOSDeadlineDays + updatingIPadOS := updatingIPadOSVersion || updatingIPadOSDeadline || updatingIPadOSDeadlineDays + + if updatingMacOS || updatingIOS || updatingIPadOS { // TODO: Should we validate MDM configured on here too? if !lic.IsPremium() { - invalid.Append("macos_updates.minimum_version", ErrMissingLicense.Error()) + // The gate is shared by all three platforms, so a fixed field name + // would report macOS for an iOS-only edit. + field := "macos_updates.minimum_version" + switch { + case updatingMacOS: + case updatingIOS: + field = "ios_updates.minimum_version" + default: + field = "ipados_updates.minimum_version" + } + invalid.Append(field, ErrMissingLicense.Error()) return nil } } diff --git a/server/service/appconfig_test.go b/server/service/appconfig_test.go index 4ec5d12b86..1a3ec84de8 100644 --- a/server/service/appconfig_test.go +++ b/server/service/appconfig_test.go @@ -21,6 +21,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" nanodep_client "github.com/fleetdm/fleet/v4/server/mdm/nanodep/client" + mdmtest "github.com/fleetdm/fleet/v4/server/mdm/testing_utils" "github.com/fleetdm/fleet/v4/server/mock" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" "github.com/fleetdm/fleet/v4/server/ptr" @@ -1219,9 +1220,9 @@ func TestMDMConfig(t *testing.T) { Script: optjson.String{Set: true}, ManualAgentInstall: optjson.Bool{Set: true}, }, - MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}}, - IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, - IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}}, + MacOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}}, + IOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, DeadlineDays: optjson.Int{Set: true}}, + IPadOSUpdates: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, DeadlineDays: optjson.Int{Set: true}}, VolumePurchasingProgram: optjson.Slice[fleet.MDMAppleVolumePurchasingProgramInfo]{Set: true, Value: []fleet.MDMAppleVolumePurchasingProgramInfo{}}, WindowsUpdates: fleet.WindowsUpdates{DeadlineDays: optjson.Int{Set: true}, GracePeriodDays: optjson.Int{Set: true}}, WindowsSettings: fleet.WindowsSettings{ @@ -1298,6 +1299,50 @@ func TestMDMConfig(t *testing.T) { m.DeprecatedAppleBMDefaultTeam = "foobar" }), }, + { + // A lapsed-premium instance can still have "latest" stored, so editing + // only deadline_days must hit the license gate like any other OS update + // change would. + name: "deadlineDaysFree", + licenseTier: "free", + oldMDM: fleet.MDM{MacOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + }}, + newMDM: fleet.MDM{MacOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(21), + }}, + expectedError: "macos_updates.minimum_version " + licenseErr, + }, + { + // The license gate is shared by the three Apple platforms, so the + // reported field has to follow the one that changed. + name: "deadlineDaysFreeIOS", + licenseTier: "free", + oldMDM: fleet.MDM{IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + }}, + newMDM: fleet.MDM{IOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(21), + }}, + expectedError: "ios_updates.minimum_version " + licenseErr, + }, + { + name: "deadlineDaysFreeIPadOS", + licenseTier: "free", + oldMDM: fleet.MDM{IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + }}, + newMDM: fleet.MDM{IPadOSUpdates: fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(21), + }}, + expectedError: "ipados_updates.minimum_version " + licenseErr, + }, { name: "ssoFree", licenseTier: "free", @@ -1580,6 +1625,11 @@ func TestMDMConfig(t *testing.T) { *dsAppConfig = *conf return nil } + // Reached whenever OS updates are configured, including "latest" mode, + // before the license gate runs. + ds.HasAppleUpdateConfigProfileConfiguredFunc = func(context.Context, uint) (bool, error) { + return false, nil + } ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { if tt.findTeam { return &fleet.Team{}, nil @@ -1643,6 +1693,185 @@ func TestMDMConfig(t *testing.T) { } } +// A sparse PATCH that switches mode doesn't mention the outgoing mode's +// deadline field, so the merged config keeps the stale value and validation +// rejects it. Both directions are affected: "latest" rejects a deadline, a +// specific version rejects deadline_days. TestMDMConfig can't cover either: it +// builds payloads with json.Marshal of a whole fleet.MDM, and optjson emits an +// explicit null for every unset field, which clears the value on the way in. +func TestModifyAppConfigClearsStaleAppleOSUpdateDeadline(t *testing.T) { + admin := &fleet.User{GlobalRole: new(fleet.RoleAdmin)} + + latest := fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: optjson.SetInt(14), + } + + // 14.6.1 is a macOS version present in the GDMF fixture. + specific := fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString("14.6.1"), + Deadline: optjson.SetString("2026-09-01"), + } + + setup := func(t *testing.T, stored fleet.MDM) (fleet.Service, context.Context) { + // validateMDM checks minimum_version against GDMF unconditionally, so + // serve Apple's asset list from the local fixture. Without this the + // subtests reach out to Apple and start failing whenever a version stops + // being published. + mdmtest.StartNewAppleGDMFTestServer(t) + + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}}) + ctx = viewer.NewContext(ctx, viewer.Viewer{User: admin}) + + dsAppConfig := &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{OrgName: "Test"}, + ServerSettings: fleet.ServerSettings{ServerURL: "https://example.org"}, + MDM: stored, + } + 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.HasAppleUpdateConfigProfileConfiguredFunc = func(context.Context, uint) (bool, error) { + return false, nil + } + ds.ListABMTokensFunc = func(ctx context.Context) ([]*fleet.ABMToken, error) { + return []*fleet.ABMToken{}, nil + } + ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) { + return []*fleet.VPPTokenDB{}, nil + } + // changing OS updates reconciles the reserved software-update + // declaration, so the write path has to be stubbed for the success cases + // to get past validation. + ds.LabelIDsByNameFunc = func(ctx context.Context, names []string, tmFilter fleet.TeamFilter) (map[string]uint, error) { + ids := make(map[string]uint, len(names)) + for i, name := range names { + ids[name] = uint(i + 1) //nolint:gosec // G115: small test values + } + return ids, nil + } + ds.SetOrUpdateMDMAppleDeclarationFunc = func(ctx context.Context, d *fleet.MDMAppleDeclaration, usesFleetVars []fleet.FleetVarName) (*fleet.MDMAppleDeclaration, error) { + return d, nil + } + ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error { + return nil + } + return svc, ctx + } + + t.Run("macOS switching to a specific version", func(t *testing.T) { + svc, ctx := setup(t, fleet.MDM{MacOSUpdates: latest}) + + modified, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"macos_updates":{"minimum_version":"14.6.1","deadline":"2026-09-01"}}}`), + fleet.ApplySpecOptions{}) + require.NoError(t, err) + + require.Equal(t, "14.6.1", modified.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, "2026-09-01", modified.MDM.MacOSUpdates.Deadline.Value) + require.False(t, modified.MDM.MacOSUpdates.DeadlineDays.Valid) + }) + + t.Run("switching into latest mode drops the stored deadline", func(t *testing.T) { + // the mirror case: "latest" derives its deadline from deadline_days, so a + // stored deadline is what's stale here. + svc, ctx := setup(t, fleet.MDM{MacOSUpdates: specific}) + + modified, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"macos_updates":{"minimum_version":"latest","deadline_days":14}}}`), + fleet.ApplySpecOptions{}) + require.NoError(t, err) + + require.Equal(t, fleet.AppleOSUpdateLatestVersion, modified.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, 14, modified.MDM.MacOSUpdates.DeadlineDays.Value) + require.Empty(t, modified.MDM.MacOSUpdates.Deadline.Value) + + // deadline has always serialized as a string, so the cleared value has to + // stay "" rather than becoming null. + raw, err := json.Marshal(modified.MDM.MacOSUpdates) + require.NoError(t, err) + require.Contains(t, string(raw), `"deadline":""`) + }) + + t.Run("an explicitly supplied deadline is still rejected in latest mode", func(t *testing.T) { + svc, ctx := setup(t, fleet.MDM{MacOSUpdates: specific}) + + _, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"macos_updates":{"minimum_version":"latest","deadline":"2026-09-01","deadline_days":14}}}`), + fleet.ApplySpecOptions{}) + require.Error(t, err) + require.ErrorContains(t, err, `deadline cannot be set when minimum_version is set to "latest"`) + }) + + t.Run("clearing enforcement entirely", func(t *testing.T) { + // turning enforcement off also leaves "latest" mode, so the stored + // deadline_days must not block it either. + svc, ctx := setup(t, fleet.MDM{MacOSUpdates: latest}) + + modified, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"macos_updates":{"minimum_version":"","deadline":""}}}`), + fleet.ApplySpecOptions{}) + require.NoError(t, err) + + require.Empty(t, modified.MDM.MacOSUpdates.MinimumVersion.Value) + require.False(t, modified.MDM.MacOSUpdates.DeadlineDays.Valid) + }) + + // the clearing is wired up per platform, so cover the other two. They clear + // enforcement rather than set a version to keep Apple's supported-version + // list out of it. + t.Run("iOS clearing enforcement", func(t *testing.T) { + svc, ctx := setup(t, fleet.MDM{IOSUpdates: latest}) + + modified, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"ios_updates":{"minimum_version":"","deadline":""}}}`), + fleet.ApplySpecOptions{}) + require.NoError(t, err) + + require.False(t, modified.MDM.IOSUpdates.DeadlineDays.Valid) + }) + + t.Run("iPadOS clearing enforcement", func(t *testing.T) { + svc, ctx := setup(t, fleet.MDM{IPadOSUpdates: latest}) + + modified, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"ipados_updates":{"minimum_version":"","deadline":""}}}`), + fleet.ApplySpecOptions{}) + require.NoError(t, err) + + require.False(t, modified.MDM.IPadOSUpdates.DeadlineDays.Valid) + }) + + t.Run("an explicitly supplied deadline_days is still rejected", func(t *testing.T) { + // the caller sent it, so this is a real mistake and has to keep failing + // with the error that explains the constraint. + svc, ctx := setup(t, fleet.MDM{MacOSUpdates: latest}) + + _, err := svc.ModifyAppConfig(ctx, + []byte(`{"mdm":{"macos_updates":{"minimum_version":"14.6.1","deadline":"2026-09-01","deadline_days":14}}}`), + fleet.ApplySpecOptions{}) + require.Error(t, err) + require.ErrorContains(t, err, `deadline_days can only be set when minimum_version is set to "latest"`) + }) + + t.Run("latest mode is untouched when the payload omits the platform", func(t *testing.T) { + svc, ctx := setup(t, fleet.MDM{MacOSUpdates: latest}) + + modified, err := svc.ModifyAppConfig(ctx, + []byte(`{"org_info":{"org_name":"Renamed"}}`), + fleet.ApplySpecOptions{}) + require.NoError(t, err) + + require.Equal(t, fleet.AppleOSUpdateLatestVersion, modified.MDM.MacOSUpdates.MinimumVersion.Value) + require.Equal(t, 14, modified.MDM.MacOSUpdates.DeadlineDays.Value) + }) +} + func TestModifyAppConfigWindowsEntraClientIDNormalization(t *testing.T) { ds := new(mock.Store) admin := &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)} @@ -3195,6 +3424,90 @@ func TestModifyAppConfigManagedLocalAccount(t *testing.T) { } } +func TestProcessAppleOSUpdateSettingsDeadlineDays(t *testing.T) { + ctx := context.Background() + lic := &fleet.LicenseInfo{Tier: fleet.TierPremium} + + // sentinel is returned by the override so the change is observable without + // standing up the activity service: reaching the override means the settings + // were considered changed. + sentinel := errors.New("override invoked") + + newSvc := func(called *bool) *Service { + svc := &Service{ds: new(mock.Store)} + svc.SetEnterpriseOverrides(fleet.EnterpriseOverrides{ + MDMAppleEditedAppleOSUpdates: func(ctx context.Context, teamID *uint, appleDevice fleet.AppleDevice, + updates fleet.AppleOSUpdateSettings, + ) error { + *called = true + return sentinel + }, + }) + return svc + } + + latest := func(days optjson.Int) fleet.AppleOSUpdateSettings { + return fleet.AppleOSUpdateSettings{ + MinimumVersion: optjson.SetString(fleet.AppleOSUpdateLatestVersion), + DeadlineDays: days, + } + } + + cases := []struct { + name string + old fleet.AppleOSUpdateSettings + new fleet.AppleOSUpdateSettings + wantUpdated bool + }{ + { + name: "deadline_days changed", + old: latest(optjson.SetInt(14)), + new: latest(optjson.SetInt(7)), + wantUpdated: true, + }, + { + name: "deadline_days set from unset", + old: latest(optjson.Int{}), + new: latest(optjson.SetInt(14)), + wantUpdated: true, + }, + { + name: "deadline_days cleared to null", + old: latest(optjson.SetInt(14)), + new: latest(optjson.Int{Set: true, Valid: false}), + wantUpdated: true, + }, + { + name: "nothing changed", + old: latest(optjson.SetInt(14)), + new: latest(optjson.SetInt(14)), + wantUpdated: false, + }, + { + name: "minimum_version changed", + old: latest(optjson.SetInt(14)), + new: fleet.AppleOSUpdateSettings{MinimumVersion: optjson.SetString("15.7.8"), Deadline: optjson.SetString("2026-09-01")}, + wantUpdated: true, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var called bool + svc := newSvc(&called) + + err := svc.processAppleOSUpdateSettings(ctx, lic, fleet.MacOS, tc.old, tc.new) + if tc.wantUpdated { + require.ErrorIs(t, err, sentinel, "expected the OS updates change to be detected") + require.True(t, called) + } else { + require.NoError(t, err) + require.False(t, called, "expected no update for unchanged settings") + } + }) + } +} + func TestModifyAppConfigWindowsEnrollment(t *testing.T) { admin := &fleet.User{GlobalRole: new(fleet.RoleAdmin)} teamID := uint(7) diff --git a/server/service/apple_mdm_test.go b/server/service/apple_mdm_test.go index e116eb23ef..1ef22dede8 100644 --- a/server/service/apple_mdm_test.go +++ b/server/service/apple_mdm_test.go @@ -8666,6 +8666,31 @@ func TestValidateDeclarationFleetVariables(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, "Fleet variable $FLEET_VAR_DIGICERT_DATA_myCA is not supported in DDM profiles") }) + + // The OS update target variables are Fleet-internal: they are placed only in + // Fleet's own OS update declaration and resolved per host. An admin must not + // be able to reference them in a declaration of their own, so they are + // deliberately absent from fleetVarsSupportedInDDMDeclarations. Adding them + // there would silently break that. + t.Run("Fleet-internal OS update variables are rejected", func(t *testing.T) { + for _, v := range []fleet.FleetVarName{ + fleet.FleetVarHostTargetOSVersion, + fleet.FleetVarHostTargetOSDeadline, + } { + // Both reference forms, since Fleet's own declaration uses each of them. + for form, value := range map[string]string{ + "bare": fmt.Sprintf("$FLEET_VAR_%s", v), + "braces": fmt.Sprintf("${FLEET_VAR_%s}", v), + } { + t.Run(string(v)+"/"+form, func(t *testing.T) { + _, err := validateDeclarationFleetVariables(makeDecl(value), premiumLic) + require.Error(t, err) + require.ErrorContains(t, err, + fmt.Sprintf("Fleet variable $FLEET_VAR_%s is not supported in DDM profiles", v)) + }) + } + } + }) } func TestJSONEscapeString(t *testing.T) { diff --git a/server/service/client.go b/server/service/client.go index b3efbf4664..963aee1219 100644 --- a/server/service/client.go +++ b/server/service/client.go @@ -2666,6 +2666,12 @@ func (c *Client) DoGitOps( if deadline, ok := macOSUpdates["deadline"]; !ok || deadline == nil { macOSUpdates["deadline"] = "" } + // Send an explicit null when the file omits deadline_days, otherwise the + // PATCH would leave a previously stored value in place and the YAML would + // stop being the source of truth. + if _, ok := macOSUpdates["deadline_days"]; !ok { + macOSUpdates["deadline_days"] = nil + } // When update_new_hosts isn't explicitly set, derive it from whether OS updates // are configured: default to true when both minimum_version and deadline are set @@ -2673,7 +2679,13 @@ func (c *Client) DoGitOps( // updates aren't configured prevents a previously stored "true" from sticking // around once minimum_version/deadline are cleared. if macOSUpdates["update_new_hosts"] == nil { - macOSUpdates["update_new_hosts"] = macOSUpdates["minimum_version"] != "" && macOSUpdates["deadline"] != "" + // "latest" mode has no deadline — deadline_days replaces it — so the + // deadline check alone would read as "not configured" and silently + // leave new hosts unenforced. + enforcingLatest := macOSUpdates["minimum_version"] == fleet.AppleOSUpdateLatestVersion && + macOSUpdates["deadline_days"] != nil + macOSUpdates["update_new_hosts"] = enforcingLatest || + (macOSUpdates["minimum_version"] != "" && macOSUpdates["deadline"] != "") } // Put in default values for ios_updates @@ -2689,6 +2701,9 @@ func (c *Client) DoGitOps( if deadline, ok := iOSUpdates["deadline"]; !ok || deadline == nil { iOSUpdates["deadline"] = "" } + if _, ok := iOSUpdates["deadline_days"]; !ok { + iOSUpdates["deadline_days"] = nil + } // update_new_hosts is only used for macOS so ignore any values posted for iOS iOSUpdates["update_new_hosts"] = nil @@ -2705,6 +2720,9 @@ func (c *Client) DoGitOps( if deadline, ok := iPadOSUpdates["deadline"]; !ok || deadline == nil { iPadOSUpdates["deadline"] = "" } + if _, ok := iPadOSUpdates["deadline_days"]; !ok { + iPadOSUpdates["deadline_days"] = nil + } // update_new_hosts is only used for macOS so ignore any values posted for iPadOS iPadOSUpdates["update_new_hosts"] = nil diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 76359ffb5b..ddf42e0186 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -317,16 +317,19 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("14.6.1"), Deadline: optjson.SetString("2021-01-01"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.SetBool(true), }, IOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-07-23"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-08-24"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, WindowsUpdates: fleet.WindowsUpdates{ @@ -450,16 +453,19 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("14.6.1"), Deadline: optjson.SetString("2021-01-01"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.SetBool(true), }, IOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-07-23"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-08-24"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, WindowsUpdates: fleet.WindowsUpdates{ @@ -491,16 +497,19 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("14.6.1"), Deadline: optjson.SetString("2021-01-01"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.SetBool(true), }, IOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-07-23"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-08-24"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, WindowsUpdates: fleet.WindowsUpdates{ @@ -534,16 +543,19 @@ func (s *integrationEnterpriseTestSuite) TestTeamSpecs() { MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("14.6.1"), Deadline: optjson.SetString("2021-01-01"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.SetBool(true), }, IOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-07-23"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.SetString("17.6.1"), Deadline: optjson.SetString("2024-08-24"), + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, WindowsUpdates: fleet.WindowsUpdates{ @@ -3509,16 +3521,19 @@ func (s *integrationEnterpriseTestSuite) TestWindowsUpdatesTeamConfig() { MacOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true}, }, IPadOSUpdates: fleet.AppleOSUpdateSettings{ MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, + DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.Bool{Set: true, Valid: false, Value: false}, }, WindowsUpdates: fleet.WindowsUpdates{ @@ -5006,7 +5021,7 @@ func (s *integrationEnterpriseTestSuite) TestMDMAppleOSUpdates() { // get the appconfig, nothing changed acResp = appConfigResponse{} s.DoJSON("GET", "/api/latest/fleet/config", nil, http.StatusOK, &acResp) - require.Equal(t, fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, UpdateNewHosts: optjson.SetBool(false)}, acResp.MDM.MacOSUpdates) + require.Equal(t, fleet.AppleOSUpdateSettings{MinimumVersion: optjson.String{Set: true}, Deadline: optjson.String{Set: true}, DeadlineDays: optjson.Int{Set: true}, UpdateNewHosts: optjson.SetBool(false)}, acResp.MDM.MacOSUpdates) // no activity got created activitiesResp = listActivitiesResponse{} diff --git a/tools/cloner-check/generated_files/appconfig.txt b/tools/cloner-check/generated_files/appconfig.txt index 03f7a20875..a36f0f68b1 100644 --- a/tools/cloner-check/generated_files/appconfig.txt +++ b/tools/cloner-check/generated_files/appconfig.txt @@ -143,13 +143,14 @@ github.com/fleetdm/fleet/v4/pkg/optjson/String Set bool github.com/fleetdm/fleet/v4/pkg/optjson/String Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/String Value string github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings DeadlineDays optjson.Int +github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/server/fleet/MDM IOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/MDM IPadOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/MDM WindowsUpdates fleet.WindowsUpdates github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates DeadlineDays optjson.Int -github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool -github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool -github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int github.com/fleetdm/fleet/v4/server/fleet/MDM MacOSSettings fleet.MacOSSettings github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec diff --git a/tools/cloner-check/generated_files/teamconfig.txt b/tools/cloner-check/generated_files/teamconfig.txt index 44f28ff7fb..c09f1fc49b 100644 --- a/tools/cloner-check/generated_files/teamconfig.txt +++ b/tools/cloner-check/generated_files/teamconfig.txt @@ -40,13 +40,14 @@ github.com/fleetdm/fleet/v4/pkg/optjson/String Set bool github.com/fleetdm/fleet/v4/pkg/optjson/String Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/String Value string github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings DeadlineDays optjson.Int +github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/server/fleet/TeamMDM IOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM IPadOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsUpdates fleet.WindowsUpdates github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates DeadlineDays optjson.Int -github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool -github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool -github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettings github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec diff --git a/tools/cloner-check/generated_files/teammdm.txt b/tools/cloner-check/generated_files/teammdm.txt index e655b6cfbb..4b793ac32c 100644 --- a/tools/cloner-check/generated_files/teammdm.txt +++ b/tools/cloner-check/generated_files/teammdm.txt @@ -11,13 +11,14 @@ github.com/fleetdm/fleet/v4/pkg/optjson/String Set bool github.com/fleetdm/fleet/v4/pkg/optjson/String Valid bool github.com/fleetdm/fleet/v4/pkg/optjson/String Value string github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings Deadline optjson.String +github.com/fleetdm/fleet/v4/server/fleet/AppleOSUpdateSettings DeadlineDays optjson.Int +github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool +github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool +github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/server/fleet/TeamMDM IOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM IPadOSUpdates fleet.AppleOSUpdateSettings github.com/fleetdm/fleet/v4/server/fleet/TeamMDM WindowsUpdates fleet.WindowsUpdates github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates DeadlineDays optjson.Int -github.com/fleetdm/fleet/v4/pkg/optjson/Int Set bool -github.com/fleetdm/fleet/v4/pkg/optjson/Int Valid bool -github.com/fleetdm/fleet/v4/pkg/optjson/Int Value int github.com/fleetdm/fleet/v4/server/fleet/WindowsUpdates GracePeriodDays optjson.Int github.com/fleetdm/fleet/v4/server/fleet/TeamMDM MacOSSettings fleet.MacOSSettings github.com/fleetdm/fleet/v4/server/fleet/MacOSSettings CustomSettings []fleet.MDMProfileSpec