fleet/ee/server/service/maintained_apps.go
Martin Angers ba04887100
Backend: Support labels_include_all for installers/apps (#41324)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #40721 

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

- [x] 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

## Testing

- [x] Added/updated automated tests
- [x] 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)

- [ ] QA'd all new/changed functionality manually

I (Martin) did test `labels_include_all` for FMA, custom installer, IPA
and VPP apps, and it seemed to all work great for gitops apply and
gitops generate, **except for VPP apps** which seem to have 2 important
pre-existing bugs, see
https://github.com/fleetdm/fleet/issues/40723#issuecomment-4041780707

## New Fleet configuration 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

---------

Co-authored-by: Jahziel Villasana-Espinoza <jahziel@fleetdm.com>
2026-03-18 13:27:53 -04:00

278 lines
10 KiB
Go

package service
import (
"context"
"path/filepath"
"strings"
"time"
"github.com/fleetdm/fleet/v4/pkg/file"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
maintained_apps "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps"
)
// noCheckHash is used by homebrew to signal that a hash shouldn't be checked, and FMA carries this convention over
const noCheckHash = "no_check"
func (svc *Service) AddFleetMaintainedApp(
ctx context.Context,
teamID *uint,
appID uint,
installScript, preInstallQuery, postInstallScript, uninstallScript string,
selfService bool, automaticInstall bool,
labelsIncludeAny, labelsExcludeAny, labelsIncludeAll []string,
) (titleID uint, err error) {
if err := svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionWrite); err != nil {
return 0, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return 0, fleet.ErrNoContext
}
// validate labels before we do anything else
validatedLabels, err := ValidateSoftwareLabels(ctx, svc, teamID, labelsIncludeAny, labelsExcludeAny, labelsIncludeAll)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "validating software labels")
}
if err := svc.ds.ValidateEmbeddedSecrets(ctx, []string{installScript, postInstallScript, uninstallScript}); err != nil {
// We redo the validation on each script to find out which script has the missing secret.
// This is done to provide a more informative error message to the UI user.
var argErr *fleet.InvalidArgumentError
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "install script", &installScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "post-install script", &postInstallScript, argErr)
argErr = svc.validateEmbeddedSecretsOnScript(ctx, "uninstall script", &uninstallScript, argErr)
if argErr != nil {
return 0, argErr
}
// We should not get to this point. If we did, it means we have another issue, such as large read replica latency.
return 0, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets")
}
app, err := svc.ds.GetMaintainedAppByID(ctx, appID, teamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id")
}
app, err = maintained_apps.Hydrate(ctx, app, "", teamID, nil)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "hydrating app from manifest")
}
// Download installer from the URL
timeout := maintained_apps.InstallerTimeout
if v := dev_mode.Env("FLEET_DEV_MAINTAINED_APPS_INSTALLER_TIMEOUT"); v != "" {
timeout, _ = time.ParseDuration(v)
}
client := fleethttp.NewClient(fleethttp.WithTimeout(timeout))
installerTFR, filename, err := maintained_apps.DownloadInstaller(ctx, app.InstallerURL, client)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "downloading app installer")
}
defer installerTFR.Close()
gotHash, err := file.SHA256FromTempFileReader(installerTFR)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "calculating SHA256 hash")
}
// Validate the bytes we got are what we expected, if a valid SHA is supplied
if app.SHA256 != noCheckHash {
if gotHash != app.SHA256 {
return 0, ctxerr.New(ctx, "mismatch in maintained app SHA256 hash")
}
} else { // otherwise set the app hash to what we downloaded so storage writes correctly
app.SHA256 = gotHash
}
extension := strings.TrimLeft(filepath.Ext(filename), ".")
installScript = file.Dos2UnixNewlines(installScript)
if installScript == "" {
installScript = app.InstallScript
}
uninstallScript = file.Dos2UnixNewlines(uninstallScript)
if uninstallScript == "" {
uninstallScript = app.UninstallScript
}
maintainedAppID := &app.ID
if strings.TrimSpace(installScript) != strings.TrimSpace(app.InstallScript) ||
strings.TrimSpace(uninstallScript) != strings.TrimSpace(app.UninstallScript) {
maintainedAppID = nil // don't set app as maintained if scripts have been modified
}
// For platforms other than macOS, installer name has to match what we see in software inventory,
// so we have the UniqueIdentifier field to indicate what that should be (independent of the name we
// display when listing the FMA). For macOS, unique identifier is bundle name, and we use bundle
// identifier to link installers with inventory, so we set the name to the FMA's display name instead.
appName := app.UniqueIdentifier
if app.Platform == "darwin" || appName == "" {
appName = app.Name
}
version := app.Version
if version == "latest" { // download URL isn't version-pinned; extract version from installer
meta, err := file.ExtractInstallerMetadata(installerTFR)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "extracting installer metadata")
}
// reset the reader (it was consumed to extract metadata)
if err := installerTFR.Rewind(); err != nil {
return 0, ctxerr.Wrap(ctx, err, "resetting installer file reader")
}
version = meta.Version
}
payload := &fleet.UploadSoftwareInstallerPayload{
InstallerFile: installerTFR,
Title: appName,
UserID: vc.UserID(),
TeamID: teamID,
Version: version,
Filename: filename,
Platform: app.Platform,
Source: app.Source(),
Extension: extension,
BundleIdentifier: app.BundleIdentifier(),
UpgradeCode: app.UpgradeCode,
StorageID: app.SHA256,
FleetMaintainedAppID: maintainedAppID,
PreInstallQuery: preInstallQuery,
PostInstallScript: postInstallScript,
SelfService: selfService,
InstallScript: installScript,
UninstallScript: uninstallScript,
ValidatedLabels: validatedLabels,
AutomaticInstall: automaticInstall,
AutomaticInstallQuery: app.AutomaticInstallQuery,
Categories: app.Categories,
URL: app.InstallerURL,
}
payload.Categories = server.RemoveDuplicatesFromSlice(payload.Categories)
// Get the mapping of category names to IDs, filtering out categories that don't exist
// This allows apps to be added even if some categories (like "Security" or "Utilities")
// don't exist in older versions of Fleet.
categoryMap, err := svc.ds.GetSoftwareCategoryNameToIDMap(ctx, payload.Categories)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting software category name to id map")
}
// Filter payload.Categories to only include categories that exist in the database
var existingCategories []string
var existingCategoryIDs []uint
for _, catName := range payload.Categories {
if catID, exists := categoryMap[catName]; exists {
existingCategories = append(existingCategories, catName)
existingCategoryIDs = append(existingCategoryIDs, catID)
}
}
// Update payload with only the existing categories
payload.Categories = existingCategories
payload.CategoryIDs = existingCategoryIDs
// Create record in software installers table
_, titleID, err = svc.ds.MatchOrCreateSoftwareInstaller(ctx, payload)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "setting downloaded installer")
}
// Save in S3
if err := svc.storeSoftware(ctx, payload); err != nil {
return 0, ctxerr.Wrap(ctx, err, "upload maintained app installer to S3")
}
// Create activity
var teamName *string
if payload.TeamID != nil && *payload.TeamID != 0 {
t, err := svc.ds.TeamLite(ctx, *payload.TeamID)
if err != nil {
return 0, ctxerr.Wrap(ctx, err, "getting team")
}
teamName = &t.Name
}
actLabelsInclAny, actLabelsExclAny, actLabelsInclAll := activitySoftwareLabelsFromValidatedLabels(payload.ValidatedLabels)
if err := svc.NewActivity(ctx, vc.User, fleet.ActivityTypeAddedSoftware{
SoftwareTitle: payload.Title,
SoftwarePackage: payload.Filename,
TeamName: teamName,
TeamID: payload.TeamID,
SelfService: payload.SelfService,
SoftwareTitleID: titleID,
LabelsIncludeAny: actLabelsInclAny,
LabelsExcludeAny: actLabelsExclAny,
LabelsIncludeAll: actLabelsInclAll,
}); err != nil {
return 0, ctxerr.Wrap(ctx, err, "creating activity for added software")
}
if automaticInstall && payload.AddedAutomaticInstallPolicy != nil {
policyAct := fleet.ActivityTypeCreatedPolicy{
ID: payload.AddedAutomaticInstallPolicy.ID,
Name: payload.AddedAutomaticInstallPolicy.Name,
}
if err := svc.NewActivity(ctx, authz.UserFromContext(ctx), policyAct); err != nil {
svc.logger.WarnContext(ctx, "failed to create activity for create automatic install policy for FMA", "err", err)
}
}
return titleID, nil
}
func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) {
var authErr error
// viewing the maintained app list without showing team-specific info can be done by anyone who can view individual FMAs
if teamID == nil {
authErr = svc.authz.Authorize(ctx, &fleet.MaintainedApp{}, fleet.ActionRead)
} else { // viewing the maintained app list when showing team-specific info requires access to that team
authErr = svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead)
}
if authErr != nil {
return nil, nil, authErr
}
opts.IncludeMetadata = true
avail, meta, err := svc.ds.ListAvailableFleetMaintainedApps(ctx, teamID, opts)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "listing available fleet maintained apps")
}
return avail, meta, nil
}
func (svc *Service) GetFleetMaintainedApp(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) {
var authErr error
// viewing the maintained app without showing team-specific info can be done by anyone who can view individual FMAs
if teamID == nil {
authErr = svc.authz.Authorize(ctx, &fleet.MaintainedApp{}, fleet.ActionRead)
} else { // viewing the maintained app when showing team-specific info requires access to that team
authErr = svc.authz.Authorize(ctx, &fleet.SoftwareInstaller{TeamID: teamID}, fleet.ActionRead)
}
if authErr != nil {
return nil, authErr
}
app, err := svc.ds.GetMaintainedAppByID(ctx, appID, teamID)
if err != nil {
return nil, err
}
return maintained_apps.Hydrate(ctx, app, "", teamID, nil)
}