From f053a9fd491618ef471e6d2dddcf42716ea776b0 Mon Sep 17 00:00:00 2001 From: Lucas Manuel Rodriguez Date: Tue, 4 Aug 2026 08:48:32 -0300 Subject: [PATCH] Allow enabling/disabling software inventory per-fleet via the API (#50481) Resolves #45735. - [X] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. ## Testing - [X] Added/updated automated tests - [x] QA'd all new/changed functionality manually. ## Summary by CodeRabbit * **New Features** * Team settings can now enable or disable Software Inventory through API updates. * Partial updates preserve existing settings when the Software Inventory option is omitted. * Software Inventory configuration can be re-enabled after being disabled. * **Bug Fixes** * Invalid or null Software Inventory values are handled correctly without affecting global or Unassigned settings. --- ...enable-software-inventory-per-fleet-api.md | 1 + ee/server/service/teams.go | 4 + server/fleet/teams.go | 10 ++- server/service/integration_enterprise_test.go | 74 +++++++++++++++++++ 4 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 changes/45735-enable-software-inventory-per-fleet-api.md diff --git a/changes/45735-enable-software-inventory-per-fleet-api.md b/changes/45735-enable-software-inventory-per-fleet-api.md new file mode 100644 index 0000000000..1ead45c631 --- /dev/null +++ b/changes/45735-enable-software-inventory-per-fleet-api.md @@ -0,0 +1 @@ +- Added support for enabling/disabling software inventory per-fleet via `PATCH /api/v1/fleet/fleets/{id}` with `{"features": {"enable_software_inventory": }}`. The key follows PATCH-merge semantics: when omitted, the stored value is unchanged. diff --git a/ee/server/service/teams.go b/ee/server/service/teams.go index c5e1fedf31..9ed705c5ea 100644 --- a/ee/server/service/teams.go +++ b/ee/server/service/teams.go @@ -503,6 +503,10 @@ func (svc *Service) ModifyTeam(ctx context.Context, teamID uint, payload fleet.T } } + if payload.Features != nil && payload.Features.EnableSoftwareInventory.Valid { + team.Config.Features.EnableSoftwareInventory = payload.Features.EnableSoftwareInventory.Value + } + team, err = svc.ds.SaveTeam(ctx, team) if err != nil { return nil, err diff --git a/server/fleet/teams.go b/server/fleet/teams.go index 13982eeba3..a3797e6200 100644 --- a/server/fleet/teams.go +++ b/server/fleet/teams.go @@ -65,11 +65,13 @@ type TeamPayload struct { // `features` shape so admins can use the same JSON path on both endpoints. // // Only the sub-fields defined here take effect; the broader Features -// fields (enable_host_users, enable_software_inventory, additional_queries, -// detail_query_overrides) remain settable per-fleet only via the -// `/spec/fleets` GitOps path. +// fields (enable_host_users, additional_queries, detail_query_overrides) +// remain settable per-fleet only via the `/spec/fleets` GitOps path. type TeamPayloadFeatures struct { - HistoricalData *HistoricalDataPayload `json:"historical_data"` + // EnableSoftwareInventory uses optjson.Bool so a key omitted from a + // PATCH body retains its current stored value (PATCH-merge semantics). + EnableSoftwareInventory optjson.Bool `json:"enable_software_inventory"` + HistoricalData *HistoricalDataPayload `json:"historical_data"` } // HistoricalDataPayload is the per-sub-key partial-PATCH form of diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index d9e7765dff..76359ffb5b 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -1831,6 +1831,80 @@ func (s *integrationEnterpriseTestSuite) TestModifyTeamHistoricalData() { ), "no disable historical data activity for ignored patch") } +func (s *integrationEnterpriseTestSuite) TestModifyTeamEnableSoftwareInventory() { + t := s.T() + ctx := context.Background() + + // Create a fleet — features.enable_software_inventory snapshots the global + // config. `features` in the POST payload is ignored (pre-existing + // behavior, consistent with historical_data), so send the OPPOSITE of the + // global value to make the ignore observable. + globalCfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + globalEnabled := globalCfg.Features.EnableSoftwareInventory + teamName := t.Name() + "softwareInventoryFleet" + var createResp teamResponse + s.DoJSON("POST", "/api/latest/fleet/teams", json.RawMessage(fmt.Sprintf( + `{"name": %q, "features": {"enable_software_inventory": %t}}`, teamName, !globalEnabled, + )), http.StatusOK, &createResp) + require.Equal(t, globalEnabled, createResp.Team.Config.Features.EnableSoftwareInventory, + "features is ignored on POST; new fleet snapshots the global config value") + teamID := createResp.Team.ID + t.Cleanup(func() { + require.NoError(t, s.ds.DeleteTeam(ctx, teamID)) + }) + + // PATCH enable_software_inventory=false. + var modResp teamResponse + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/fleets/%d", teamID), + json.RawMessage(`{"features": {"enable_software_inventory": false}}`), + http.StatusOK, &modResp) + require.False(t, modResp.Team.Config.Features.EnableSoftwareInventory) + + // PATCH other fields without the key — value retained (PATCH-merge semantics). + newName := teamName + "renamed" + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/fleets/%d", teamID), + json.RawMessage(fmt.Sprintf(`{"name": %q, "features": {"historical_data": {"uptime": false}}}`, newName)), + http.StatusOK, &modResp) + require.Equal(t, newName, modResp.Team.Name) + require.False(t, modResp.Team.Config.Features.EnableSoftwareInventory, + "enable_software_inventory preserved when omitted from PATCH") + require.False(t, modResp.Team.Config.Features.HistoricalData.Uptime) + + // Invalid value returns a 4xx error and does not change the setting. + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/fleets/%d", teamID), + json.RawMessage(`{"features": {"enable_software_inventory": "yes"}}`), + http.StatusBadRequest, &modResp) + teamFeatures, err := s.ds.TeamFeatures(ctx, teamID) + require.NoError(t, err) + require.False(t, teamFeatures.EnableSoftwareInventory, + "setting unchanged after invalid value") + + // `null` is treated as omitted — value retained. + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/fleets/%d", teamID), + json.RawMessage(`{"features": {"enable_software_inventory": null}}`), + http.StatusOK, &modResp) + require.False(t, modResp.Team.Config.Features.EnableSoftwareInventory) + + // Re-enable. + s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/fleets/%d", teamID), + json.RawMessage(`{"features": {"enable_software_inventory": true}}`), + http.StatusOK, &modResp) + require.True(t, modResp.Team.Config.Features.EnableSoftwareInventory) + + // PATCH /fleets/0 (Unassigned) ignores `features` (pre-existing behavior, + // consistent with historical_data); Unassigned hosts follow the global + // config setting, so send the OPPOSITE of the global value and verify it + // is unchanged. + s.DoJSON("PATCH", "/api/latest/fleet/fleets/0", + json.RawMessage(fmt.Sprintf(`{"features": {"enable_software_inventory": %t}}`, !globalEnabled)), + http.StatusOK, &modResp) + appCfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + require.Equal(t, globalEnabled, appCfg.Features.EnableSoftwareInventory, + "global setting unchanged by Unassigned fleet PATCH") +} + func (s *integrationEnterpriseTestSuite) TestAvailableTeams() { t := s.T()