diff --git a/changes/25896-android-os-updates b/changes/25896-android-os-updates new file mode 100644 index 0000000000..e7d3418499 --- /dev/null +++ b/changes/25896-android-os-updates @@ -0,0 +1 @@ +- Added support for Android `systemUpdate` profiles in Fleet Premium. diff --git a/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx b/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx index 95ca045777..24adc35f18 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/OSUpdates.tsx @@ -128,8 +128,7 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => { return (

- Remotely encourage the installation of software updates on hosts - assigned to this team. + Remotely enforce software updates.

<>
diff --git a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx index 7381dd9868..6f9955606a 100644 --- a/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx +++ b/frontend/pages/ManageControlsPage/OSUpdates/components/PlatformTabs/PlatformTabs.tsx @@ -3,7 +3,6 @@ import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; import TabNav from "components/TabNav"; import TabText from "components/TabText"; import CustomLink from "components/CustomLink"; -import { SUPPORT_LINK } from "utilities/constants"; import EndUserOSRequirementPreview from "../EndUserOSRequirementPreview"; import WindowsTargetForm from "../WindowsTargetForm"; @@ -162,11 +161,12 @@ const PlatformTabs = ({

- Android updates are coming soon. -

-

- Need to encourage installation of Android updates?{" "} - + Add a{" "} + {" "} + (configuration profile) to enforce Android OS updates.

diff --git a/server/fleet/android.go b/server/fleet/android.go index b949a1aa2a..4f779b1573 100644 --- a/server/fleet/android.go +++ b/server/fleet/android.go @@ -44,7 +44,6 @@ var AndroidForbiddenJSONKeys = map[string]string{ "uninstallAppsDisabled": `Android configuration profile can't include "uninstallAppsDisabled" setting. Software management is coming soon.`, "blockApplicationsEnabled": `Android configuration profile can't include "blockApplicationsEnabled" setting. Software management is coming soon.`, "appAutoUpdatePolicy": `Android configuration profile can't include "appAutoUpdatePolicy" setting. Software management is coming soon.`, - "systemUpdate": `Android configuration profile can't include "systemUpdate" setting. OS updates are coming soon.`, "kioskCustomLauncherEnabled": `Android configuration profile can't include "kioskCustomLauncherEnabled" setting. Currently, only personal hosts are supported.`, "kioskCustomization": `Android configuration profile can't include "kioskCustomization" setting. Currently, only personal hosts are supported.`, "persistentPreferredActivities": `Android configuration profile can't include "persistentPreferredActivities" setting. Currently, only personal hosts are supported.`, @@ -52,7 +51,13 @@ var AndroidForbiddenJSONKeys = map[string]string{ "encryptionPolicy": `Android configuration profile can't include "encryptionPolicy" setting. Currently, disk encryption isn't supported.`, } -func (m *MDMAndroidConfigProfile) ValidateUserProvided() error { +// AndroidPremiumOnlyJSONKeys are keys that may not be included in user-provided Android +// configuration profiles for non-Premium licenses and associated error messages when they are included +var AndroidPremiumOnlyJSONKeys = map[string]string{ + "systemUpdate": `Android OS updates ("systemUpdate") is Fleet Premium only.`, +} + +func (m *MDMAndroidConfigProfile) ValidateUserProvided(isPremium bool) error { if len(bytes.TrimSpace(m.RawJSON)) == 0 { return errors.New("The file should include valid JSON.") } @@ -75,6 +80,12 @@ func (m *MDMAndroidConfigProfile) ValidateUserProvided() error { return errors.New(errMsg) } + if !isPremium { + if errMsg, ok := AndroidPremiumOnlyJSONKeys[key]; ok { + return errors.New(errMsg) + } + } + if !IsAndroidPolicyFieldValid(key) { return fmt.Errorf("Invalid JSON payload. Unknown key %q", key) } diff --git a/server/service/mdm.go b/server/service/mdm.go index 772ea3ef76..661f4cbd4b 100644 --- a/server/service/mdm.go +++ b/server/service/mdm.go @@ -1630,7 +1630,7 @@ func (svc *Service) NewMDMAndroidConfigProfile(ctx context.Context, teamID uint, Name: profileName, RawJSON: data, } - if err := cp.ValidateUserProvided(); err != nil { + if err := cp.ValidateUserProvided(license.IsPremium(ctx)); err != nil { err := &fleet.BadRequestError{Message: "Couldn't add. " + err.Error()} return nil, ctxerr.Wrap(ctx, err, "validate profile") } @@ -2443,6 +2443,7 @@ func getAndroidProfiles(ctx context.Context, appCfg *fleet.AppConfig, profiles map[int]fleet.MDMProfileBatchPayload, labelMap map[string]fleet.ConfigurationProfileLabel, + // isPremium bool, ) (map[int]*fleet.MDMAndroidConfigProfile, error) { profs := make(map[int]*fleet.MDMAndroidConfigProfile, len(profiles)) for i, profile := range profiles { @@ -2484,7 +2485,7 @@ func getAndroidProfiles(ctx context.Context, } } - if err := mdmProf.ValidateUserProvided(); err != nil { + if err := mdmProf.ValidateUserProvided(license.IsPremium(ctx)); err != nil { msg := err.Error() return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError(fmt.Sprintf("profiles[%s]", profile.Name), msg)) diff --git a/server/service/mdm_test.go b/server/service/mdm_test.go index c5b1fe11eb..ffce1478a1 100644 --- a/server/service/mdm_test.go +++ b/server/service/mdm_test.go @@ -1796,6 +1796,28 @@ func TestMDMBatchSetProfiles(t *testing.T) { }, "duplicate json by name", }, + { + "premium-only android profile without premium license", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + nil, + nil, + []fleet.MDMProfileBatchPayload{ + {Name: "systemUpdate", Contents: json.RawMessage([]byte(`{"systemUpdate": {"type": "AUTOMATIC"}}`))}, + }, + `Android OS updates ("systemUpdate") is Fleet Premium only.`, + }, + { + "premium-only android profile with premium license", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + true, + nil, + nil, + []fleet.MDMProfileBatchPayload{ + {Name: "systemUpdate", Contents: json.RawMessage([]byte(`{"systemUpdate": {"type": "AUTOMATIC"}}`))}, + }, + "", + }, } for _, tt := range testCases { @@ -2589,3 +2611,100 @@ func TestUploadMDMAppleAPNSCertReplacesFileVaultProfile(t *testing.T) { require.EqualValues(t, 2, deleteCalls) require.EqualValues(t, 2, newActivityCalls) // Only enabled Disk encryption activities, we don't want to log disable right before enabling. } + +func TestNewMDMProfilePremiumOnlyAndroid(t *testing.T) { + require.Len(t, fleet.AndroidPremiumOnlyJSONKeys, 1, "update this test with any new premium-only key for android profiles") + require.Contains(t, fleet.AndroidPremiumOnlyJSONKeys, "systemUpdate", "update this test with any new premium-only key for android profiles") + + ds := new(mock.Store) + svc, ctx := newTestService(t, ds, nil, nil, &TestServerOpts{License: &fleet.LicenseInfo{Tier: fleet.TierPremium}, SkipCreateTestUsers: true}) + + ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + OrgInfo: fleet.OrgInfo{ + OrgName: "Foo Inc.", + }, + ServerSettings: fleet.ServerSettings{ + ServerURL: "https://foo.example.com", + }, + MDM: fleet.MDM{ + EnabledAndConfigured: true, + WindowsEnabledAndConfigured: true, + AndroidEnabledAndConfigured: true, + }, + }, nil + } + ds.TeamByNameFunc = func(ctx context.Context, name string) (*fleet.Team, error) { + return &fleet.Team{ID: 1, Name: name}, nil + } + ds.TeamWithExtrasFunc = func(ctx context.Context, id uint) (*fleet.Team, error) { + return &fleet.Team{ID: id, Name: "team"}, nil + } + ds.NewActivityFunc = func( + ctx context.Context, user *fleet.User, activity fleet.ActivityDetails, details []byte, createdAt time.Time, + ) error { + return nil + } + ds.ValidateEmbeddedSecretsFunc = func(ctx context.Context, documents []string) error { + return nil + } + ds.ExpandEmbeddedSecretsAndUpdatedAtFunc = func(ctx context.Context, document string) (string, *time.Time, error) { + return document, nil, nil + } + ds.GetGroupedCertificateAuthoritiesFunc = func(ctx context.Context, includeSecrets bool) (*fleet.GroupedCertificateAuthorities, error) { + return &fleet.GroupedCertificateAuthorities{}, nil + } + ds.NewMDMAndroidConfigProfileFunc = func(ctx context.Context, cp fleet.MDMAndroidConfigProfile) (*fleet.MDMAndroidConfigProfile, error) { + return &fleet.MDMAndroidConfigProfile{}, nil + } + + testCases := []struct { + name string + user *fleet.User + premium bool + teamID uint + profile string + wantErr string + }{ + { + "premium-only android profile without premium license", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + false, + 0, + `{"systemUpdate": {"type": "AUTOMATIC"}}`, + `Android OS updates ("systemUpdate") is Fleet Premium only.`, + }, + { + "premium-only android profile with premium license", + &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, + true, + 0, + `{"systemUpdate": {"type": "AUTOMATIC"}}`, + "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + defer func() { ds.NewMDMAndroidConfigProfileFuncInvoked = false }() + + // prepare the context with the user and license + ctx := viewer.NewContext(ctx, viewer.Viewer{User: tt.user}) + tier := fleet.TierFree + if tt.premium { + tier = fleet.TierPremium + } + ctx = license.NewContext(ctx, &fleet.LicenseInfo{Tier: tier}) + + _, err := svc.NewMDMAndroidConfigProfile(ctx, tt.teamID, tt.name, []byte(tt.profile), nil, fleet.LabelsIncludeAll) + if tt.wantErr == "" { + require.NoError(t, err) + require.True(t, ds.NewMDMAndroidConfigProfileFuncInvoked) + return + } + require.Error(t, err) + require.ErrorContains(t, err, tt.wantErr) + require.False(t, ds.NewMDMAndroidConfigProfileFuncInvoked) + }) + } +}