diff --git a/changes/48685-mdm-enrolled-host-usage-stats b/changes/48685-mdm-enrolled-host-usage-stats new file mode 100644 index 0000000000..2dce02a193 --- /dev/null +++ b/changes/48685-mdm-enrolled-host-usage-stats @@ -0,0 +1 @@ +- Added anonymous usage statistics reporting the number of macOS and Windows hosts enrolled in Fleet's MDM. diff --git a/cmd/fleet/serve_test.go b/cmd/fleet/serve_test.go index cc6794be35..d8cb3a5a77 100644 --- a/cmd/fleet/serve_test.go +++ b/cmd/fleet/serve_test.go @@ -119,18 +119,20 @@ func TestMaybeSendStatistics(t *testing.T) { fleet.HostsCountByOSVersion{Version: "1.2.3", NumEnrolled: 22}, }, }, - HostsEnrolledByOrbitVersion: []fleet.HostsCountByOrbitVersion{}, - HostsEnrolledByOsqueryVersion: []fleet.HostsCountByOsqueryVersion{}, - StoredErrors: []byte(`[]`), - Organization: "Fleet", - AIFeaturesDisabled: true, - MaintenanceWindowsEnabled: true, - MaintenanceWindowsConfigured: true, - NumHostsFleetDesktopEnabled: 1984, - FleetMaintainedAppsMacOS: []string{"1password/darwin"}, - FleetMaintainedAppsWindows: []string{"google-chrome/windows"}, - GitOpsModeEnabled: true, - GitOpsModeExceptions: []string{"labels", "software", "secrets"}, + HostsEnrolledByOrbitVersion: []fleet.HostsCountByOrbitVersion{}, + HostsEnrolledByOsqueryVersion: []fleet.HostsCountByOsqueryVersion{}, + StoredErrors: []byte(`[]`), + Organization: "Fleet", + AIFeaturesDisabled: true, + MaintenanceWindowsEnabled: true, + MaintenanceWindowsConfigured: true, + NumHostsFleetDesktopEnabled: 1984, + FleetMaintainedAppsMacOS: []string{"1password/darwin"}, + FleetMaintainedAppsWindows: []string{"google-chrome/windows"}, + GitOpsModeEnabled: true, + GitOpsModeExceptions: []string{"labels", "software", "secrets"}, + NumHostsFleetMDMEnrolledMacOS: 12, + NumHostsFleetMDMEnrolledWindows: 34, }, true, nil } recorded := false @@ -149,7 +151,7 @@ func TestMaybeSendStatistics(t *testing.T) { require.NoError(t, err) assert.True(t, recorded) require.True(t, cleanedup) - assert.JSONEq(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"googleWorkspaceConfigured":false,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false,"gitOpsModeEnabled":true,"gitOpsModeExceptions":["labels","software","secrets"]}`, requestBody) + assert.JSONEq(t, `{"anonymousIdentifier":"ident","fleetVersion":"1.2.3","licenseTier":"premium","organization":"Fleet","numHostsEnrolled":999,"numHostsABMPending":888,"numUsers":99,"numSoftwareVersions":100,"numHostSoftwares":101,"numSoftwareTitles":102,"numHostSoftwareInstalledPaths":103,"numSoftwareCPEs":104,"numSoftwareCVEs":105,"numTeams":9,"numPolicies":0,"numQueries":200,"numLabels":3,"softwareInventoryEnabled":true,"vulnDetectionEnabled":true,"systemUsersEnabled":true,"hostsStatusWebHookEnabled":true,"mdmMacOsEnabled":false,"hostExpiryEnabled":false,"mdmWindowsEnabled":false,"mdmRecoveryLockPasswordEnabled":false,"liveQueryDisabled":false,"numWeeklyActiveUsers":111,"numWeeklyPolicyViolationDaysActual":0,"numWeeklyPolicyViolationDaysPossible":0,"hostsEnrolledByOperatingSystem":{"linux":[{"version":"1.2.3","numEnrolled":22}]},"hostsEnrolledByOrbitVersion":[],"hostsEnrolledByOsqueryVersion":[],"storedErrors":[],"numHostsNotResponding":0,"aiFeaturesDisabled":true,"maintenanceWindowsEnabled":true,"maintenanceWindowsConfigured":true,"googleWorkspaceConfigured":false,"numHostsFleetDesktopEnabled":1984,"fleetMaintainedAppsMacOS":["1password/darwin"],"fleetMaintainedAppsWindows":["google-chrome/windows"],"conditionalAccessEnabled":false,"oktaConditionalAccessConfigured":false,"conditionalAccessBypassDisabled":false,"entraConditionalAccessConfigured":false,"gitOpsModeEnabled":true,"gitOpsModeExceptions":["labels","software","secrets"],"numHostsFleetMDMEnrolledMacOS":12,"numHostsFleetMDMEnrolledWindows":34}`, requestBody) } func TestMaybeSendStatisticsSkipsSendingIfNotNeeded(t *testing.T) { diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 67d8507525..3e42ae670a 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -6428,6 +6428,33 @@ SELECT COUNT(*) FROM hosts h LEFT JOIN host_mdm hmdm ON h.id=hmdm.host_id WHERE return count, nil } +// numHostsFleetMDMEnrolledDB returns the number of macOS and Windows hosts that are currently enrolled in Fleet's own +// MDM. It excludes hosts enrolled in a third-party MDM, server hosts (host_mdm.is_server = 1), and hosts that are not +// currently enrolled (host_mdm.enrolled = 0, which includes ABM-pending and unenrolled hosts). +func numHostsFleetMDMEnrolledDB(ctx context.Context, db sqlx.QueryerContext) (macOS int, windows int, err error) { + var counts struct { + MacOS int `db:"macos"` + Windows int `db:"windows"` + } + const stmt = ` +SELECT + COALESCE(SUM(CASE WHEN h.platform = 'darwin' THEN 1 ELSE 0 END), 0) AS macos, + COALESCE(SUM(CASE WHEN h.platform = 'windows' THEN 1 ELSE 0 END), 0) AS windows +FROM host_mdm hm + JOIN hosts h ON h.id = hm.host_id + JOIN mobile_device_management_solutions mdms ON hm.mdm_id = mdms.id +WHERE hm.enrolled = 1 + AND NOT COALESCE(hm.is_server, false) + AND mdms.name = ? + AND h.platform IN ('darwin', 'windows') + ` + if err := sqlx.GetContext(ctx, db, &counts, stmt, fleet.WellKnownMDMFleet); err != nil { + return 0, 0, err + } + + return counts.MacOS, counts.Windows, nil +} + func (ds *Datastore) GetMatchingHostSerials(ctx context.Context, serials []string) (map[string]*fleet.Host, error) { result := map[string]*fleet.Host{} if len(serials) == 0 { diff --git a/server/datastore/mysql/statistics.go b/server/datastore/mysql/statistics.go index 4de961d991..48ed9d2fc1 100644 --- a/server/datastore/mysql/statistics.go +++ b/server/datastore/mysql/statistics.go @@ -114,6 +114,10 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du if err != nil { return ctxerr.Wrap(ctx, err, "fleet maintained apps") } + numHostsFleetMDMEnrolledMacOS, numHostsFleetMDMEnrolledWindows, err := numHostsFleetMDMEnrolledDB(ctx, ds.reader(ctx)) + if err != nil { + return ctxerr.Wrap(ctx, err, "number of hosts enrolled in Fleet MDM") + } stats.NumHostsEnrolled = amountEnrolledHosts stats.NumHostsABMPending = numHostsABMPending @@ -169,6 +173,8 @@ func (ds *Datastore) ShouldSendStatistics(ctx context.Context, frequency time.Du stats.NumQueries = numQueries stats.FleetMaintainedAppsMacOS = fleetMaintainedAppsMacOS stats.FleetMaintainedAppsWindows = fleetMaintainedAppsWindows + stats.NumHostsFleetMDMEnrolledMacOS = numHostsFleetMDMEnrolledMacOS + stats.NumHostsFleetMDMEnrolledWindows = numHostsFleetMDMEnrolledWindows stats.ConditionalAccessEnabled, err = ds.conditionalAccessEnabledOnATeam(ctx, teams) if err != nil { diff --git a/server/datastore/mysql/statistics_test.go b/server/datastore/mysql/statistics_test.go index 262e4bda1f..90e6b747ef 100644 --- a/server/datastore/mysql/statistics_test.go +++ b/server/datastore/mysql/statistics_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "testing" "time" @@ -13,6 +14,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/license" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/ptr" + "github.com/fleetdm/fleet/v4/server/test" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,6 +31,7 @@ func TestStatistics(t *testing.T) { {"ConditionalAccessStatistics", testConditionalAccessStatistics}, {"FleetMaintainedAppsInUse", testFleetMaintainedAppsInUse}, {"GitOpsModeStatistics", testGitOpsModeStatistics}, + {"FleetMDMEnrolled", testStatisticsFleetMDMEnrolled}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -841,3 +844,51 @@ func testGitOpsModeStatistics(t *testing.T, ds *Datastore) { assert.False(t, stats.GitOpsModeEnabled) assert.Equal(t, []string{}, stats.GitOpsModeExceptions) } + +func testStatisticsFleetMDMEnrolled(t *testing.T, ds *Datastore) { + ctx := t.Context() + + // With no hosts, both counts are zero (not null/missing). + macOS, windows, err := numHostsFleetMDMEnrolledDB(ctx, ds.reader(ctx)) + require.NoError(t, err) + assert.Zero(t, macOS) + assert.Zero(t, windows) + + // Each host exercises one branch of the query; only enrolled, non-server, Fleet-MDM darwin/windows hosts are counted. + cases := []struct { + name string + platform string + isServer bool + enrolled bool + installedFromDep bool + mdmName string // "" means no MDM data at all + }{ + {"macOS Fleet MDM", "darwin", false, true, false, fleet.WellKnownMDMFleet}, // counted (macOS) + {"windows Fleet MDM", "windows", false, true, false, fleet.WellKnownMDMFleet}, // counted (Windows) + {"macOS third-party MDM", "darwin", false, true, false, fleet.WellKnownMDMIntune}, // excluded: not Fleet + // ABM pending: DEP-assigned (installed_from_dep=1) but not yet enrolled (enrolled=0). + {"macOS ABM pending", "darwin", false, false, true, fleet.WellKnownMDMFleet}, // excluded: not enrolled + {"windows server host", "windows", true, true, false, fleet.WellKnownMDMFleet}, // excluded: is_server + {"iOS Fleet MDM", "ios", false, true, false, fleet.WellKnownMDMFleet}, // excluded: not macOS/Windows + {"macOS no MDM", "darwin", false, false, false, ""}, // excluded: no MDM data + } + for i, c := range cases { + key := fmt.Sprintf("mdm-stats-%d", i) + h := test.NewHost(t, ds, key, "", key, key, time.Now(), test.WithPlatform(c.platform)) + if c.mdmName != "" { + require.NoError(t, ds.SetOrUpdateMDMData(ctx, h.ID, c.isServer, c.enrolled, "https://fleet.example.com", c.installedFromDep, c.mdmName, "", false), c.name) + } + } + + // The counts flow through the full ShouldSendStatistics payload: 1 macOS and 1 Windows Fleet-MDM host. + eh := ctxerr.MockHandler{} + eh.RetrieveImpl = func(flush bool) ([]*ctxerr.StoredError, error) { return nil, nil } + statsCtx := ctxerr.NewContext(ctx, eh) + premiumLicense := &fleet.LicenseInfo{Tier: fleet.TierPremium, Organization: "Fleet"} + fleetConfig := config.FleetConfig{Osquery: config.OsqueryConfig{DetailUpdateInterval: 1 * time.Hour}} + stats, shouldSend, err := ds.ShouldSendStatistics(license.NewContext(statsCtx, premiumLicense), time.Millisecond, fleetConfig) + require.NoError(t, err) + assert.True(t, shouldSend) + assert.Equal(t, 1, stats.NumHostsFleetMDMEnrolledMacOS) + assert.Equal(t, 1, stats.NumHostsFleetMDMEnrolledWindows) +} diff --git a/server/fleet/statistics.go b/server/fleet/statistics.go index d7df597f82..832510a439 100644 --- a/server/fleet/statistics.go +++ b/server/fleet/statistics.go @@ -82,6 +82,11 @@ type StatisticsPayload struct { // GitOpsModeExceptions lists the configured GitOps mode exceptions (e.g. "labels", "software", "secrets"). // Exceptions are persisted independently of GitOpsModeEnabled. GitOpsModeExceptions []string `json:"gitOpsModeExceptions"` + + // NumHostsFleetMDMEnrolledMacOS is the number of macOS hosts actually enrolled in Fleet's own MDM + NumHostsFleetMDMEnrolledMacOS int `json:"numHostsFleetMDMEnrolledMacOS"` + // NumHostsFleetMDMEnrolledWindows is the number of Windows hosts actually enrolled in Fleet's own MDM + NumHostsFleetMDMEnrolledWindows int `json:"numHostsFleetMDMEnrolledWindows"` } type HostsCountByOrbitVersion struct {