From 88466bc8b235c76345e9b1f556575a514a28205f Mon Sep 17 00:00:00 2001 From: Tim Lee Date: Wed, 1 Jul 2026 15:11:56 -0600 Subject: [PATCH] Android: capture security patch level in OS version Fold SoftwareInfo.SecurityPatchLevel into the Android OS version so hosts report as "Android 16 (2026-05-01)" and get distinct operating_systems rows per patch level. Falls back to the bare major version when no patch level is reported. Resolves #47334 --- changes/47334-android-os-security-patch-level | 1 + server/mdm/android/service/pubsub.go | 19 +++++- server/mdm/android/service/pubsub_test.go | 67 +++++++++++++++++-- 3 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 changes/47334-android-os-security-patch-level diff --git a/changes/47334-android-os-security-patch-level b/changes/47334-android-os-security-patch-level new file mode 100644 index 00000000000..e709ebd8f06 --- /dev/null +++ b/changes/47334-android-os-security-patch-level @@ -0,0 +1 @@ +- Android host OS versions now include the security patch level (e.g. "Android 16 (2026-05-01)"), so hosts are grouped by patch level on the OS versions page. diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go index 9bcdb3311a9..d15da949ef6 100644 --- a/server/mdm/android/service/pubsub.go +++ b/server/mdm/android/service/pubsub.go @@ -736,7 +736,7 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De host.Host.ComputerName = computerName host.Host.Hostname = computerName host.Host.Platform = "android" - host.Host.OSVersion = "Android " + device.SoftwareInfo.AndroidVersion + host.Host.OSVersion = "Android " + androidOSVersion(device.SoftwareInfo) host.Host.Build = device.SoftwareInfo.AndroidBuildNumber host.Host.Memory = device.MemoryInfo.TotalRam @@ -833,7 +833,7 @@ func (svc *Service) updateHostOperatingSystem(ctx context.Context, hostID uint, } if err := svc.fleetDS.UpdateHostOperatingSystem(ctx, hostID, fleet.OperatingSystem{ Name: "Android", - Version: device.SoftwareInfo.AndroidVersion, + Version: androidOSVersion(device.SoftwareInfo), Platform: "android", }); err != nil { return ctxerr.Wrap(ctx, err, "update Android host operating system") @@ -841,6 +841,19 @@ func (svc *Service) updateHostOperatingSystem(ctx context.Context, hostID uint, return nil } +// androidOSVersion folds the security patch level into the Android version, +// e.g. "16 (2026-05-01)". Older devices that don't report a patch level fall +// back to the bare major version ("16"). +func androidOSVersion(si *androidmanagement.SoftwareInfo) string { + if si == nil { + return "" + } + if si.SecurityPatchLevel == "" { + return si.AndroidVersion + } + return si.AndroidVersion + " (" + si.SecurityPatchLevel + ")" +} + func getAndroidHostKey(device *androidmanagement.Device) string { if device.HardwareInfo.EnterpriseSpecificId != "" { return device.HardwareInfo.EnterpriseSpecificId @@ -903,7 +916,7 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De ComputerName: computerName, Hostname: computerName, Platform: "android", - OSVersion: "Android " + device.SoftwareInfo.AndroidVersion, + OSVersion: "Android " + androidOSVersion(device.SoftwareInfo), Build: device.SoftwareInfo.AndroidBuildNumber, Memory: device.MemoryInfo.TotalRam, GigsTotalDiskSpace: gigsTotalDiskSpace, diff --git a/server/mdm/android/service/pubsub_test.go b/server/mdm/android/service/pubsub_test.go index 81e49bc037f..18fb4b1b785 100644 --- a/server/mdm/android/service/pubsub_test.go +++ b/server/mdm/android/service/pubsub_test.go @@ -268,7 +268,9 @@ func TestPubSubEnrollment(t *testing.T) { } expectedHostID := uint(99) + var capturedHost *fleet.AndroidHost mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) { + capturedHost = host return &fleet.AndroidHost{Host: &fleet.Host{ID: expectedHostID}}, nil } var capturedHostID uint @@ -288,12 +290,14 @@ func TestPubSubEnrollment(t *testing.T) { } enrollmentMessage := createEnrollmentMessage(t, deviceInfo) // createEnrollmentMessage sets AndroidVersion="1"; override to a more - // realistic version so we verify it's passed through unchanged. + // realistic version + security patch level so we verify both are folded + // into the OS version as "16 (2026-05-01)". data, err := base64.StdEncoding.DecodeString(enrollmentMessage.Data) require.NoError(t, err) var decoded androidmanagement.Device require.NoError(t, json.Unmarshal(data, &decoded)) decoded.SoftwareInfo.AndroidVersion = "16" + decoded.SoftwareInfo.SecurityPatchLevel = "2026-05-01" reEncoded, err := json.Marshal(decoded) require.NoError(t, err) enrollmentMessage.Data = base64.StdEncoding.EncodeToString(reEncoded) @@ -304,8 +308,57 @@ func TestPubSubEnrollment(t *testing.T) { require.True(t, mockDS.UpdateHostOperatingSystemFuncInvoked) require.Equal(t, expectedHostID, capturedHostID) require.Equal(t, "Android", capturedOS.Name) - require.Equal(t, "16", capturedOS.Version) + require.Equal(t, "16 (2026-05-01)", capturedOS.Version) require.Equal(t, "android", capturedOS.Platform) + require.NotNil(t, capturedHost) + require.Equal(t, "Android 16 (2026-05-01)", capturedHost.Host.OSVersion) + }) + + t.Run("falls back to bare major version when no security patch level is reported", func(t *testing.T) { + mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{AndroidEnabledAndConfigured: true}, + }, nil + } + + expectedHostID := uint(100) + var capturedHost *fleet.AndroidHost + mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) { + capturedHost = host + return &fleet.AndroidHost{Host: &fleet.Host{ID: expectedHostID}}, nil + } + var capturedOS fleet.OperatingSystem + mockDS.UpdateHostOperatingSystemFunc = func(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error { + capturedOS = hostOS + return nil + } + + enrollmentToken := enrollmentTokenRequest{EnrollSecret: "global"} + enrollTokenData, err := json.Marshal(enrollmentToken) + require.NoError(t, err) + deviceInfo := androidmanagement.Device{ + Name: createAndroidDeviceId("test-android-os-no-spl"), + EnrollmentTokenData: string(enrollTokenData), + } + enrollmentMessage := createEnrollmentMessage(t, deviceInfo) + // createEnrollmentMessage leaves SecurityPatchLevel empty; override the + // version and confirm no trailing "(...)" is appended. + data, err := base64.StdEncoding.DecodeString(enrollmentMessage.Data) + require.NoError(t, err) + var decoded androidmanagement.Device + require.NoError(t, json.Unmarshal(data, &decoded)) + decoded.SoftwareInfo.AndroidVersion = "16" + reEncoded, err := json.Marshal(decoded) + require.NoError(t, err) + enrollmentMessage.Data = base64.StdEncoding.EncodeToString(reEncoded) + + err = svc.ProcessPubSubPush(t.Context(), "value", enrollmentMessage) + require.NoError(t, err) + + require.True(t, mockDS.UpdateHostOperatingSystemFuncInvoked) + require.Equal(t, "16", capturedOS.Version) + require.NotNil(t, capturedHost) + require.Equal(t, "Android 16", capturedHost.Host.OSVersion) }) t.Run("creates device as company-owned if specified in enrollment message", func(t *testing.T) { @@ -1000,7 +1053,8 @@ func TestStatusReportPopulatesOperatingSystem(t *testing.T) { Model: "Pixel 8a", }, SoftwareInfo: &androidmanagement.SoftwareInfo{ - AndroidVersion: "16", + AndroidVersion: "16", + SecurityPatchLevel: "2026-05-01", }, MemoryInfo: &androidmanagement.MemoryInfo{ TotalRam: int64(8 * 1024 * 1024 * 1024), @@ -1020,7 +1074,9 @@ func TestStatusReportPopulatesOperatingSystem(t *testing.T) { require.True(t, mockDS.UpdateHostOperatingSystemFuncInvoked) require.Equal(t, expectedHostID, capturedHostID) require.Equal(t, "Android", capturedOS.Name) - require.Equal(t, "16", capturedOS.Version) + // The security patch level is folded into the OS-version row so it is + // distinct per patch level (e.g. "16" vs "16 (2026-05-01)"). + require.Equal(t, "16 (2026-05-01)", capturedOS.Version) require.Equal(t, "android", capturedOS.Platform) } @@ -1206,6 +1262,7 @@ func TestUpdateHost(t *testing.T) { SoftwareInfo: &androidmanagement.SoftwareInfo{ AndroidBuildNumber: "updated-build", AndroidVersion: "15", + SecurityPatchLevel: "2026-05-01", }, MemoryInfo: &androidmanagement.MemoryInfo{ TotalRam: int64(16 * 1024 * 1024 * 1024), // 16GB RAM @@ -1240,7 +1297,7 @@ func TestUpdateHost(t *testing.T) { require.Equal(t, "Updatedbrand UpdatedModel", capturedHost.Host.ComputerName) require.Equal(t, "Updatedbrand UpdatedModel", capturedHost.Host.Hostname) require.Equal(t, "Updatedbrand UpdatedModel", capturedHost.Host.HardwareModel) - require.Equal(t, "Android 15", capturedHost.Host.OSVersion) + require.Equal(t, "Android 15 (2026-05-01)", capturedHost.Host.OSVersion) }) t.Run("UUID is set from EnterpriseSpecificId", func(t *testing.T) {