diff --git a/changes/45711-android-os-version-filter b/changes/45711-android-os-version-filter new file mode 100644 index 0000000000..46284543c0 --- /dev/null +++ b/changes/45711-android-os-version-filter @@ -0,0 +1 @@ +- Fixed the `GET /api/v1/fleet/hosts` endpoint so that filtering Android hosts by `os_name=Android` and `os_version=` returns the matching hosts. Android hosts now populate the `operating_systems` table on enrollment and on every status report, and also appear in the `GET /api/v1/fleet/os_versions` aggregation and OS list in the UI with the Android logo. diff --git a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx index 145adacc77..0643ac2d09 100644 --- a/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx +++ b/frontend/pages/SoftwarePage/SoftwareOS/SoftwareOSTable/SoftwareOSTable.tsx @@ -82,6 +82,11 @@ const PLATFORM_FILTER_OPTIONS = [ label: "iPadOS", value: "ipados", }, + { + disabled: false, + label: "Android", + value: "android", + }, ]; const SoftwareOSTable = ({ diff --git a/frontend/pages/SoftwarePage/components/icons/AndroidOS.tsx b/frontend/pages/SoftwarePage/components/icons/AndroidOS.tsx new file mode 100644 index 0000000000..43fec72df7 --- /dev/null +++ b/frontend/pages/SoftwarePage/components/icons/AndroidOS.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import type { SVGProps } from "react"; + +const AndroidOS = (props: SVGProps) => ( + + + + + + +); +export default AndroidOS; diff --git a/frontend/pages/SoftwarePage/components/icons/index.ts b/frontend/pages/SoftwarePage/components/icons/index.ts index c8382d5808..a34b0c716c 100644 --- a/frontend/pages/SoftwarePage/components/icons/index.ts +++ b/frontend/pages/SoftwarePage/components/icons/index.ts @@ -40,6 +40,7 @@ import Airtame from "./Airtame"; import AmazonChime from "./AmazonChime"; import AmazonDCV from "./AmazonDCV"; import AndroidApp from "./AndroidApp"; +import AndroidOS from "./AndroidOS"; import AndroidPlayStore from "./AndroidPlayStore"; import AndroidStudio from "./AndroidStudio"; import Anka from "./Anka"; @@ -543,6 +544,7 @@ export const PLATFORM_NAME_TO_ICON_MAP = { chrome: ChromeOS, ios: iOS, ipados: iPadOS, + android: AndroidOS, } as const; // SOFTWARE_SOURCE_TO_ICON_MAP maps different software sources to a defined diff --git a/frontend/services/entities/operating_systems.ts b/frontend/services/entities/operating_systems.ts index 8a6ae68a98..41af164f95 100644 --- a/frontend/services/entities/operating_systems.ts +++ b/frontend/services/entities/operating_systems.ts @@ -12,6 +12,7 @@ export const OS_VERSIONS_API_SUPPORTED_PLATFORMS = [ "chrome", "ios", "ipados", + "android", ]; export interface IGetOSVersionsQueryParams { diff --git a/server/mdm/android/service/enterprises_test.go b/server/mdm/android/service/enterprises_test.go index 55667d8b5e..1ef134aba7 100644 --- a/server/mdm/android/service/enterprises_test.go +++ b/server/mdm/android/service/enterprises_test.go @@ -219,6 +219,9 @@ func InitCommonDSMocks() *AndroidMockDS { ds.Store.CreatePendingCertificateTemplatesForNewHostFunc = func(ctx context.Context, hostUUID string, teamID uint) (int64, error) { return 0, nil } + ds.Store.UpdateHostOperatingSystemFunc = func(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error { + return nil + } return &ds } diff --git a/server/mdm/android/service/pubsub.go b/server/mdm/android/service/pubsub.go index 4092a645c3..f1293a067a 100644 --- a/server/mdm/android/service/pubsub.go +++ b/server/mdm/android/service/pubsub.go @@ -497,6 +497,13 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De return ctxerr.Wrap(ctx, err, "enrolling Android host") } + // Populate the operating_systems table so the host can be filtered via + // `GET /api/v1/fleet/hosts?os_name=Android&os_version=` and show + // up in the /os_versions aggregation alongside other platforms. + if err := svc.updateHostOperatingSystem(ctx, host.Host.ID, device); err != nil { + return err + } + if fromEnroll { // Delete any existing certificate template records for this host. The device has // lost all certificates on re-enrollment (work profile removed and re-installed, or @@ -533,6 +540,24 @@ func (svc *Service) updateHost(ctx context.Context, device *androidmanagement.De return nil } +// updateHostOperatingSystem upserts the host's OS into the operating_systems +// and host_operating_system tables. Without this, Android hosts cannot be +// filtered via the os_name/os_version host list parameters and do not appear +// in the /os_versions aggregation. +func (svc *Service) updateHostOperatingSystem(ctx context.Context, hostID uint, device *androidmanagement.Device) error { + if device.SoftwareInfo == nil || device.SoftwareInfo.AndroidVersion == "" { + return nil + } + if err := svc.fleetDS.UpdateHostOperatingSystem(ctx, hostID, fleet.OperatingSystem{ + Name: "Android", + Version: device.SoftwareInfo.AndroidVersion, + Platform: "android", + }); err != nil { + return ctxerr.Wrap(ctx, err, "update Android host operating system") + } + return nil +} + func getAndroidHostKey(device *androidmanagement.Device) string { if device.HardwareInfo.EnterpriseSpecificId != "" { return device.HardwareInfo.EnterpriseSpecificId @@ -620,6 +645,13 @@ func (svc *Service) addNewHost(ctx context.Context, device *androidmanagement.De return ctxerr.Wrap(ctx, err, "enrolling Android host") } + // Populate the operating_systems table so the host can be filtered via + // `GET /api/v1/fleet/hosts?os_name=Android&os_version=` and show + // up in the /os_versions aggregation alongside other platforms. + if err := svc.updateHostOperatingSystem(ctx, fleetHost.Host.ID, device); err != nil { + return err + } + if enrollmentTokenRequest.IdpUUID != "" { svc.logger.InfoContext(ctx, "associating android host with idp account", "host_uuid", host.UUID, "idp_uuid", enrollmentTokenRequest.IdpUUID) err := svc.ds.AssociateHostMDMIdPAccount(ctx, host.UUID, enrollmentTokenRequest.IdpUUID) diff --git a/server/mdm/android/service/pubsub_test.go b/server/mdm/android/service/pubsub_test.go index 5e4b53fe4e..9297d3e8f6 100644 --- a/server/mdm/android/service/pubsub_test.go +++ b/server/mdm/android/service/pubsub_test.go @@ -19,14 +19,13 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/android" android_mock "github.com/fleetdm/fleet/v4/server/mdm/android/mock" common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql" - "github.com/fleetdm/fleet/v4/server/ptr" "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/api/androidmanagement/v1" ) // sha256 of "TestBrand:test-serial". Will need to be updated if our test enrollment message changes -const testBrandTestSerialHashed = "9c311e05af14f958bd65188796e41fcc8a7b0ff913bfea4f11f31c96c6f052b0" +var testBrandTestSerialHashed = "9c311e05af14f958bd65188796e41fcc8a7b0ff913bfea4f11f31c96c6f052b0" func createAndroidService(t *testing.T) (android.Service, *AndroidMockDS) { androidAPIClient := android_mock.Client{} @@ -257,6 +256,58 @@ func TestPubSubEnrollment(t *testing.T) { require.Equal(t, expectedHostID, capturedScimHostID) }) + t.Run("populates operating_systems with Android name and version on enrollment", func(t *testing.T) { + // Regression test for https://github.com/fleetdm/fleet/issues/45711. + // Without this, filtering hosts with `os_name=Android&os_version=` + // returns nothing because the operating_systems table is empty for + // Android hosts. + mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{AndroidEnabledAndConfigured: true}, + }, nil + } + + expectedHostID := uint(99) + mockDS.NewAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, companyOwned bool) (*fleet.AndroidHost, error) { + return &fleet.AndroidHost{Host: &fleet.Host{ID: expectedHostID}}, nil + } + var capturedHostID uint + var capturedOS fleet.OperatingSystem + mockDS.UpdateHostOperatingSystemFunc = func(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error { + capturedHostID = hostID + 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"), + EnrollmentTokenData: string(enrollTokenData), + } + enrollmentMessage := createEnrollmentMessage(t, deviceInfo) + // createEnrollmentMessage sets AndroidVersion="1"; override to a more + // realistic version so we verify it's passed through unchanged. + 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, expectedHostID, capturedHostID) + require.Equal(t, "Android", capturedOS.Name) + require.Equal(t, "16", capturedOS.Version) + require.Equal(t, "android", capturedOS.Platform) + }) + t.Run("creates device as company-owned if specified in enrollment message", func(t *testing.T) { mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{ @@ -321,7 +372,7 @@ func TestStatusReportPolicyValidation(t *testing.T) { } t.Run("single install pending profile with empty compliance details", func(t *testing.T) { - policyVersion := ptr.Int(1) + policyVersion := new(1) installPendingProfile := &fleet.MDMAndroidProfilePayload{ ProfileUUID: uuid.NewString(), @@ -364,7 +415,7 @@ func TestStatusReportPolicyValidation(t *testing.T) { }) t.Run("compliance details has failure", func(t *testing.T) { - policyVersion := ptr.Int(1) + policyVersion := new(1) policyRequestUUID := uuid.NewString() installPendingProfile1 := &fleet.MDMAndroidProfilePayload{ @@ -461,7 +512,7 @@ func TestStatusReportPolicyValidation(t *testing.T) { }) t.Run("profile failed due to non-compliance but is reverified", func(t *testing.T) { - policyVersion := ptr.Int(1) + policyVersion := new(1) policyRequestUUID := uuid.NewString() installPendingProfile1 := &fleet.MDMAndroidProfilePayload{ @@ -582,7 +633,7 @@ func TestStatusReportPolicyValidation(t *testing.T) { func TestUpdateHostEmptyUUIDGetsPopulated(t *testing.T) { svc, mockDS := createAndroidService(t) - const enterpriseSpecificID = "SHOULD-BE-THIS-UUID" + enterpriseSpecificID := "SHOULD-BE-THIS-UUID" const deviceName = "test-empty-uuid-bug" // Mock AppConfig @@ -605,7 +656,7 @@ func TestUpdateHostEmptyUUIDGetsPopulated(t *testing.T) { Device: &android.Device{ HostID: 200, DeviceID: "buggy-device", - EnterpriseSpecificID: ptr.String(enterpriseSpecificID), + EnterpriseSpecificID: &enterpriseSpecificID, }, } existingHostWithEmptyUUID.SetNodeKey(enterpriseSpecificID) @@ -672,9 +723,80 @@ func TestUpdateHostEmptyUUIDGetsPopulated(t *testing.T) { "Host UUID is properly populated from device.EnterpriseSpecificId") } +// TestStatusReportPopulatesOperatingSystem is a regression test for +// https://github.com/fleetdm/fleet/issues/45711. Status reports for existing +// Android hosts must populate the operating_systems table so the hosts can be +// filtered via GET /api/v1/fleet/hosts?os_name=Android&os_version=. +func TestStatusReportPopulatesOperatingSystem(t *testing.T) { + svc, mockDS := createAndroidService(t) + const enterpriseSpecificID = "ESI-OS-TEST" + + mockDS.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { + return &fleet.AppConfig{ + MDM: fleet.MDM{AndroidEnabledAndConfigured: true}, + }, nil + } + expectedHostID := uint(321) + mockDS.AndroidHostLiteFunc = func(ctx context.Context, esID string) (*fleet.AndroidHost, error) { + return &fleet.AndroidHost{ + Host: &fleet.Host{ + ID: expectedHostID, + UUID: enterpriseSpecificID, + }, + Device: &android.Device{ + HostID: expectedHostID, + DeviceID: "device", + EnterpriseSpecificID: new(enterpriseSpecificID), + }, + }, nil + } + mockDS.UpdateAndroidHostFunc = func(ctx context.Context, host *fleet.AndroidHost, fromEnroll, companyOwned bool) error { + return nil + } + + var capturedHostID uint + var capturedOS fleet.OperatingSystem + mockDS.UpdateHostOperatingSystemFunc = func(ctx context.Context, hostID uint, hostOS fleet.OperatingSystem) error { + capturedHostID = hostID + capturedOS = hostOS + return nil + } + + device := androidmanagement.Device{ + Name: createAndroidDeviceId("test-android-status-os"), + HardwareInfo: &androidmanagement.HardwareInfo{ + EnterpriseSpecificId: enterpriseSpecificID, + Brand: "Google", + Model: "Pixel 8a", + }, + SoftwareInfo: &androidmanagement.SoftwareInfo{ + AndroidVersion: "16", + }, + MemoryInfo: &androidmanagement.MemoryInfo{ + TotalRam: int64(8 * 1024 * 1024 * 1024), + }, + LastStatusReportTime: "2024-01-01T12:00:00Z", + } + deviceBytes, err := json.Marshal(device) + require.NoError(t, err) + message := &android.PubSubMessage{ + Attributes: map[string]string{"notificationType": string(android.PubSubStatusReport)}, + Data: base64.StdEncoding.EncodeToString(deviceBytes), + } + + err = svc.ProcessPubSubPush(t.Context(), "value", message) + require.NoError(t, err) + + 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, "android", capturedOS.Platform) +} + func TestHostPayloadUUIDForFrontend(t *testing.T) { svc, mockDS := createAndroidService(t) - const enterpriseSpecificID = "ANDROID-DEVICE-UUID-123" + enterpriseSpecificID := "ANDROID-DEVICE-UUID-123" const deviceName = "test-frontend-payload" // Mock AppConfig @@ -721,7 +843,7 @@ func TestHostPayloadUUIDForFrontend(t *testing.T) { Device: &android.Device{ HostID: 100, DeviceID: "test-device", - EnterpriseSpecificID: ptr.String(enterpriseSpecificID), + EnterpriseSpecificID: &enterpriseSpecificID, }, }, nil } @@ -775,7 +897,7 @@ func TestHostPayloadUUIDForFrontend(t *testing.T) { func TestUpdateHost(t *testing.T) { svc, mockDS := createAndroidService(t) - const enterpriseSpecificID = "TEST-UUID-12345" + enterpriseSpecificID := "TEST-UUID-12345" const deviceName = "test-update-host" // Mock AppConfig @@ -798,7 +920,7 @@ func TestUpdateHost(t *testing.T) { Device: &android.Device{ HostID: 1, DeviceID: "old-device-id", - EnterpriseSpecificID: ptr.String(enterpriseSpecificID), + EnterpriseSpecificID: &enterpriseSpecificID, }, } existingHost.SetNodeKey(enterpriseSpecificID) @@ -942,7 +1064,7 @@ func TestUpdateHost(t *testing.T) { Device: &android.Device{ HostID: 2, DeviceID: "device-2", - EnterpriseSpecificID: ptr.String(testBrandTestSerialHashed), + EnterpriseSpecificID: &testBrandTestSerialHashed, }, }, nil } @@ -1393,7 +1515,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { mockDS.BulkSetVPPInstallsAsFailedFuncInvoked = false }) - policyVersion := ptr.Int(1) + policyVersion := new(1) mockDS.ListHostMDMAndroidVPPAppsPendingInstallWithVersionFunc = func(ctx context.Context, hostUUID string, version int64) ([]*fleet.HostAndroidVPPSoftwareInstall, error) { return nil, nil @@ -1440,7 +1562,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { return nil } - policyVersion := ptr.Int(1) + policyVersion := new(1) enrollmentMessage := createStatusReportMessage(t, androidDevice.UUID, "test", createAndroidDeviceId("test"), policyVersion, nil) err := svc.ProcessPubSubPush(context.Background(), "value", &enrollmentMessage) require.NoError(t, err) @@ -1478,7 +1600,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { return nil } - policyVersion := ptr.Int(2) + policyVersion := new(2) enrollmentMessage := createStatusAppReportMessage(t, androidDevice.UUID, "test", createAndroidDeviceId("test"), policyVersion, []*androidmanagement.ApplicationReport{ {PackageName: pendingApp.AdamID, State: "INSTALLED"}, }, nil) @@ -1518,7 +1640,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { return nil } - policyVersion := ptr.Int(2) + policyVersion := new(2) enrollmentMessage := createStatusAppReportMessage(t, androidDevice.UUID, "test", createAndroidDeviceId("test"), policyVersion, []*androidmanagement.ApplicationReport{ {PackageName: pendingApp.AdamID, State: "INSTALLED"}, }, []*androidmanagement.NonComplianceDetail{ @@ -1560,7 +1682,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { return nil } - policyVersion := ptr.Int(2) + policyVersion := new(2) enrollmentMessage := createStatusAppReportMessage(t, androidDevice.UUID, "test", createAndroidDeviceId("test"), policyVersion, []*androidmanagement.ApplicationReport{ {PackageName: pendingApp.AdamID, State: "APPLICATION_STATE_UNSPECIFIED"}, }, []*androidmanagement.NonComplianceDetail{ @@ -1602,7 +1724,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { return nil } - policyVersion := ptr.Int(2) + policyVersion := new(2) enrollmentMessage := createStatusAppReportMessage(t, androidDevice.UUID, "test", createAndroidDeviceId("test"), policyVersion, nil, []*androidmanagement.NonComplianceDetail{ {PackageName: pendingApp.AdamID, NonComplianceReason: "PENDING", InstallationFailureReason: "IN_PROGRESS"}, }) @@ -1683,7 +1805,7 @@ func TestStatusReportAppInstallVerification(t *testing.T) { return &fleet.User{}, &fleet.ActivityInstalledAppStoreApp{CommandUUID: cmdUUID, Status: string(status)}, nil } - policyVersion := ptr.Int(2) + policyVersion := new(2) // app1 and app2 verified, app3 not reported at all so failed, app4 failed with compliance report enrollmentMessage := createStatusAppReportMessage(t, androidDevice.UUID, "test", createAndroidDeviceId("test"), policyVersion, []*androidmanagement.ApplicationReport{ {PackageName: pendingApps[0].AdamID, State: "INSTALLED"},