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.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Lucas Manuel Rodriguez
2026-08-04 08:48:32 -03:00
committed by GitHub
parent e529d97897
commit f053a9fd49
4 changed files with 85 additions and 4 deletions
@@ -0,0 +1 @@
- Added support for enabling/disabling software inventory per-fleet via `PATCH /api/v1/fleet/fleets/{id}` with `{"features": {"enable_software_inventory": <bool>}}`. The key follows PATCH-merge semantics: when omitted, the stored value is unchanged.
+4
View File
@@ -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
+6 -4
View File
@@ -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
@@ -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()