Add support for Android systemUpdate profile (#35791)

This commit is contained in:
Sarah Gillespie 2025-11-20 11:43:28 -06:00 committed by GitHub
parent 03e8a35854
commit ecac38f8ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 143 additions and 12 deletions

View file

@ -0,0 +1 @@
- Added support for Android `systemUpdate` profiles in Fleet Premium.

View file

@ -128,8 +128,7 @@ const OSUpdates = ({ router, teamIdForApi, queryParams }: IOSUpdates) => {
return (
<div className={baseClass}>
<p className={`${baseClass}__description`}>
Remotely encourage the installation of software updates on hosts
assigned to this team.
Remotely enforce software updates.
</p>
<>
<div className={`${baseClass}__current-version-container`}>

View file

@ -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 = ({
<TabPanel className={`${baseClass}__tab-panel`}>
<div className={`${baseClass}__coming-soon`}>
<p>
<b>Android updates are coming soon.</b>
</p>
<p>
Need to encourage installation of Android updates?{" "}
<CustomLink url={SUPPORT_LINK} text="Let us know" newTab />
Add a{" "}
<CustomLink
url={`/controls/os-settings/custom-settings?team_id=${currentTeamId}`}
text="custom setting"
/>{" "}
(configuration profile) to enforce Android OS updates.
</p>
</div>
</TabPanel>

View file

@ -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)
}

View file

@ -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))

View file

@ -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)
})
}
}