Fix the Fleet-maintained apps list being cut off by adding server-side pagination and applying platform / "hide added apps" filters across the full library. Introduces MaintainedAppListOptions (with Platform and AvailableOnly) and changes the ListAvailableFleetMaintainedApps / ListFleetMaintainedApps signatures. Datastore now paginates and counts by distinct app name, fetches all platform rows for apps on a page, and returns a count and pagination metadata; default client page size set to 500. Frontend no longer performs client-side filtering or local status/platform state; it relies on the API and uses data.count for totals. Docs, tests, mocks, and various call sites updated (including a new test that verifies pagination, platform and availability filters). <!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves # # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [ ] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [ ] Timeouts are implemented and retries are limited to avoid infinite loops - [ ] If paths of existing endpoints are modified without backwards compatibility, checked the frontend/CLI for any necessary changes ## Testing - [x] Added/updated automated tests - [ ] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually For unreleased bug fixes in a release candidate, one of: - [ ] Confirmed that the fix is not expected to adversely impact load test results - [ ] Alerted the release DRI if additional load testing is needed ## Database migrations - [ ] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [ ] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [ ] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`). ## New Fleet configuration settings - [ ] Setting(s) is/are explicitly excluded from GitOps If you didn't check the box above, follow this checklist for GitOps-enabled settings: - [ ] Verified that the setting is exported via `fleetctl generate-gitops` - [ ] Verified the setting is documented in a separate PR to [the GitOps documentation](https://github.com/fleetdm/fleet/blob/main/docs/Configuration/yaml-files.md#L485) - [ ] Verified that the setting is cleared on the server if it is not supplied in a YAML file (or that it is documented as being optional) - [ ] Verified that any relevant UI is disabled when GitOps mode is enabled ## fleetd/orbit/Fleet Desktop - [ ] Verified compatibility with the latest released version of Fleet (see [Must rule](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/workflows/fleetd-development-and-release-strategy.md)) - [ ] If the change applies to only one platform, confirmed that `runtime.GOOS` is used as needed to isolate changes - [ ] Verified that fleetd runs on macOS, Linux and Windows - [ ] Verified auto-update works from the released version of component to the new version (see [tools/tuf/test](../tools/tuf/test/README.md)) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Fleet-maintained apps listing now paginates server-side (100 per page) so entries near the end of the alphabet are reachable. * Platform and “Hide added apps” filters are applied across the entire library, not just the currently loaded subset. * The displayed count now matches results by counting macOS and Windows versions separately. * **New Features** * Listing now supports URL-driven platform and “available” filtering, and the UI consistently reflects the active filter state. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
212 lines
7.7 KiB
Go
212 lines
7.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
|
|
platform_logging "github.com/fleetdm/fleet/v4/server/platform/logging"
|
|
)
|
|
|
|
type addFleetMaintainedAppRequest struct {
|
|
TeamID *uint `json:"team_id"` //nolint:apiparamcheck // alias handled manually via sibling FleetID field (see DecodeRequest below)
|
|
// Note that we're adding an explicit FleetID field rather than using `renameto`.
|
|
// The POST /software/fleet_maintained_apps endpoint has a custom decoder
|
|
// and in this special case it's easier to handle the aliasing manually.
|
|
FleetID *uint `json:"fleet_id"`
|
|
AppID uint `json:"fleet_maintained_app_id"`
|
|
InstallScript string `json:"install_script"`
|
|
PreInstallQuery string `json:"pre_install_query"` //nolint:apiparamcheck
|
|
PostInstallScript string `json:"post_install_script"`
|
|
SelfService bool `json:"self_service"`
|
|
UninstallScript string `json:"uninstall_script"`
|
|
LabelsIncludeAny []string `json:"labels_include_any"`
|
|
LabelsExcludeAny []string `json:"labels_exclude_any"`
|
|
LabelsIncludeAll []string `json:"labels_include_all"`
|
|
AutomaticInstall bool `json:"automatic_install"`
|
|
Categories []string `json:"categories"`
|
|
}
|
|
|
|
// DecodeRequest implements the RequestDecoder interface to support base64-encoded
|
|
// script fields. This allows bypassing WAF rules that may block requests containing
|
|
// shell/PowerShell script patterns. When the X-Fleet-Scripts-Encoded header is set
|
|
// to "base64", the script fields are decoded from base64.
|
|
func (addFleetMaintainedAppRequest) DecodeRequest(ctx context.Context, r *http.Request) (any, error) {
|
|
var req addFleetMaintainedAppRequest
|
|
|
|
// Decode JSON body
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "failed to decode request body",
|
|
InternalErr: err,
|
|
}
|
|
}
|
|
|
|
// Resolve fleet_id → team_id aliasing. The struct has both fields so
|
|
// json.Decode populates whichever the caller sent; we normalize here.
|
|
if req.FleetID != nil {
|
|
if req.TeamID != nil {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: `Specify only one of "team_id" or "fleet_id"`,
|
|
}
|
|
}
|
|
req.TeamID = req.FleetID
|
|
req.FleetID = nil
|
|
} else if req.TeamID != nil && platform_logging.TopicEnabled(platform_logging.DeprecatedFieldTopic) {
|
|
// Add a deprecation warning.
|
|
logging.WithExtras(ctx,
|
|
"deprecated_fields", "[team_id]",
|
|
"deprecation_warning", "use the updated field names (fleet_id) instead",
|
|
)
|
|
}
|
|
// Check if scripts are base64 encoded
|
|
if isScriptsEncoded(r) {
|
|
var err error
|
|
if req.InstallScript, err = decodeBase64Script(req.InstallScript); err != nil {
|
|
return nil, fleet.NewInvalidArgumentError("install_script", "invalid base64 encoding")
|
|
}
|
|
if req.UninstallScript, err = decodeBase64Script(req.UninstallScript); err != nil {
|
|
return nil, fleet.NewInvalidArgumentError("uninstall_script", "invalid base64 encoding")
|
|
}
|
|
if req.PostInstallScript, err = decodeBase64Script(req.PostInstallScript); err != nil {
|
|
return nil, fleet.NewInvalidArgumentError("post_install_script", "invalid base64 encoding")
|
|
}
|
|
if req.PreInstallQuery, err = decodeBase64Script(req.PreInstallQuery); err != nil {
|
|
return nil, fleet.NewInvalidArgumentError("pre_install_query", "invalid base64 encoding")
|
|
}
|
|
}
|
|
|
|
return &req, nil
|
|
}
|
|
|
|
type addFleetMaintainedAppResponse struct {
|
|
SoftwareTitleID uint `json:"software_title_id,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r addFleetMaintainedAppResponse) Error() error { return r.Err }
|
|
|
|
func addFleetMaintainedAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*addFleetMaintainedAppRequest)
|
|
ctx, cancel := context.WithTimeout(ctx, maintained_apps.InstallerTimeout)
|
|
defer cancel()
|
|
titleId, err := svc.AddFleetMaintainedApp(
|
|
ctx,
|
|
req.TeamID,
|
|
req.AppID,
|
|
req.InstallScript,
|
|
req.PreInstallQuery,
|
|
req.PostInstallScript,
|
|
req.UninstallScript,
|
|
req.SelfService,
|
|
req.AutomaticInstall,
|
|
req.LabelsIncludeAny,
|
|
req.LabelsExcludeAny,
|
|
req.LabelsIncludeAll,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
err = fleet.NewGatewayTimeoutError("Couldn't add. Request timeout. Please make sure your server and load balancer timeout is long enough.", err)
|
|
}
|
|
|
|
return &addFleetMaintainedAppResponse{Err: err}, nil
|
|
}
|
|
return &addFleetMaintainedAppResponse{SoftwareTitleID: titleId}, nil
|
|
}
|
|
|
|
func (svc *Service) AddFleetMaintainedApp(ctx context.Context, _ *uint, _ uint, _, _, _, _ string, _ bool, _ bool, _, _, _ []string) (uint, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return 0, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type listFleetMaintainedAppsRequest struct {
|
|
fleet.ListOptions
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
// Platform optionally filters to apps available on the given platform
|
|
// ("darwin" or "windows").
|
|
Platform string `query:"platform,optional"`
|
|
// AvailableOnly, when true, returns only apps not yet added to the team
|
|
// (the "Hide added apps" filter).
|
|
AvailableOnly bool `query:"available,optional"`
|
|
}
|
|
|
|
type listFleetMaintainedAppsResponse struct {
|
|
FleetMaintainedApps []fleet.MaintainedApp `json:"fleet_maintained_apps"`
|
|
Count int `json:"count"`
|
|
Meta *fleet.PaginationMetadata `json:"meta"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listFleetMaintainedAppsResponse) Error() error { return r.Err }
|
|
|
|
func listFleetMaintainedAppsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listFleetMaintainedAppsRequest)
|
|
|
|
opts := fleet.MaintainedAppListOptions{
|
|
ListOptions: req.ListOptions,
|
|
Platform: req.Platform,
|
|
AvailableOnly: req.AvailableOnly,
|
|
}
|
|
|
|
apps, meta, err := svc.ListFleetMaintainedApps(ctx, req.TeamID, opts)
|
|
if err != nil {
|
|
return listFleetMaintainedAppsResponse{Err: err}, nil
|
|
}
|
|
|
|
listResp := listFleetMaintainedAppsResponse{
|
|
FleetMaintainedApps: apps,
|
|
Meta: meta,
|
|
}
|
|
if meta != nil {
|
|
listResp.Count = int(meta.TotalResults) //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
return listResp, nil
|
|
}
|
|
|
|
func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts fleet.MaintainedAppListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
type getFleetMaintainedAppRequest struct {
|
|
AppID uint `url:"app_id"`
|
|
TeamID *uint `query:"team_id,optional" renameto:"fleet_id"`
|
|
}
|
|
|
|
type getFleetMaintainedAppResponse struct {
|
|
FleetMaintainedApp *fleet.MaintainedApp `json:"fleet_maintained_app"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getFleetMaintainedAppResponse) Error() error { return r.Err }
|
|
|
|
func getFleetMaintainedApp(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getFleetMaintainedAppRequest)
|
|
|
|
app, err := svc.GetFleetMaintainedApp(ctx, req.AppID, req.TeamID)
|
|
if err != nil {
|
|
return getFleetMaintainedAppResponse{Err: err}, nil
|
|
}
|
|
|
|
return getFleetMaintainedAppResponse{FleetMaintainedApp: app}, nil
|
|
}
|
|
|
|
func (svc *Service) GetFleetMaintainedApp(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|