48093 auld api gitops latest os version (#50213)
**Related issue:** Resolves #48093 - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. ## Testing - [x] Added/updated automated tests - [x] QA'd all new/changed functionality manually ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [x] Verified that the setting is exported via `fleetctl generate-gitops` - [x] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added “latest” version enforcement for macOS, iOS, and iPadOS updates using required `deadline_days`. * Updates dynamically target each device’s available OS version and deadline. * Configuration and GitOps outputs now include `deadline_days`. * **Bug Fixes** * Improved validation when switching update modes or omitting deadline settings. * GitOps updates now clear previously stored deadline values when omitted. * Changes to `deadline_days` are detected and applied consistently. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Magnus Jensen <magnus@fleetdm.com>
This commit is contained in:
co-authored by
Magnus Jensen
parent
5e95589554
commit
192ac4eb51
File diff suppressed because one or more lines are too long
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
+129
-13
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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{}
|
||||
|
||||
Reference in New Issue
Block a user