add support for in house apps and vpp apps (#35671)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #35534

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

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually

For unreleased bug fixes in a release candidate, one of:

- [x] Confirmed that the fix is not expected to adversely impact load
test results
This commit is contained in:
Jahziel Villasana-Espinoza 2025-11-17 11:23:35 -05:00 committed by GitHub
parent 04e7a308d4
commit 67a954661c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 219 additions and 22 deletions

View file

@ -17,9 +17,7 @@ func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *flee
return nil, ctxerr.Wrap(ctx, err, "getting existing installer")
}
if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil &&
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil &&
payload.LabelsIncludeAny == nil && payload.LabelsExcludeAny == nil {
if payload.IsNoopPayload(software) {
return existingInstaller, nil // no payload, noop
}
@ -41,13 +39,14 @@ func (svc *Service) updateInHouseAppInstaller(ctx context.Context, payload *flee
selfService = *payload.SelfService
}
activity := fleet.ActivityTypeEditedSoftware{
SoftwareTitle: existingInstaller.SoftwareTitle,
TeamName: teamName,
TeamID: actTeamID,
SoftwarePackage: &existingInstaller.Name,
SoftwareTitleID: payload.TitleID,
SoftwareIconURL: existingInstaller.IconUrl,
SelfService: selfService,
SoftwareTitle: existingInstaller.SoftwareTitle,
TeamName: teamName,
TeamID: actTeamID,
SoftwarePackage: &existingInstaller.Name,
SoftwareTitleID: payload.TitleID,
SoftwareIconURL: existingInstaller.IconUrl,
SelfService: selfService,
SoftwareDisplayName: payload.DisplayName,
}
var payloadForNewInstallerFile *fleet.UploadSoftwareInstallerPayload

View file

@ -360,9 +360,7 @@ func (svc *Service) UpdateSoftwareInstaller(ctx context.Context, payload *fleet.
return nil, ctxerr.Wrap(ctx, err, "getting existing installer")
}
if payload.SelfService == nil && payload.InstallerFile == nil && payload.PreInstallQuery == nil &&
payload.InstallScript == nil && payload.PostInstallScript == nil && payload.UninstallScript == nil &&
payload.LabelsIncludeAny == nil && payload.LabelsExcludeAny == nil && software.DisplayName == payload.DisplayName {
if payload.IsNoopPayload(software) {
return existingInstaller, nil // no payload, noop
}

View file

@ -599,7 +599,7 @@ func getVPPAppsMetadata(ctx context.Context, ids []fleet.VPPAppTeam) ([]*fleet.V
return apps, nil
}
func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string) (*fleet.VPPAppStoreApp, error) {
func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string, displayName string) (*fleet.VPPAppStoreApp, error) {
if err := svc.authz.Authorize(ctx, &fleet.VPPApp{TeamID: teamID}, fleet.ActionWrite); err != nil {
return nil, err
}
@ -634,6 +634,7 @@ func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID
},
SelfService: selfService,
ValidatedLabels: validatedLabels,
DisplayName: displayName,
},
TeamID: teamID,
TitleID: titleID,

View file

@ -248,6 +248,13 @@ WHERE
dest.Categories = categories
}
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, titleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get in house app display name")
}
dest.DisplayName = displayName
if teamID != nil {
icon, err := ds.GetSoftwareTitleIcon(ctx, *teamID, titleID)
if err != nil && !fleet.IsNotFound(err) {
@ -295,6 +302,10 @@ func (ds *Datastore) SaveInHouseAppUpdates(ctx context.Context, payload *fleet.U
}
}
if err := updateSoftwareTitleDisplayName(ctx, tx, payload.TeamID, payload.TitleID, payload.DisplayName); err != nil {
return ctxerr.Wrap(ctx, err, "update in house app display name")
}
return nil
})
if err != nil {

View file

@ -930,6 +930,13 @@ WHERE
dest.Categories = categories
}
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, titleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get software title display name")
}
dest.DisplayName = displayName
if teamID != nil {
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, *teamID)
if err != nil {

View file

@ -84,6 +84,18 @@ WHERE
}
app.Categories = categories
var tmID uint
if teamID != nil {
tmID = *teamID
}
displayName, err := ds.getSoftwareTitleDisplayName(ctx, tmID, titleID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get display name for app store app")
}
app.DisplayName = displayName
if teamID != nil {
policies, err := ds.getPoliciesBySoftwareTitleIDs(ctx, []uint{titleID}, *teamID)
if err != nil {
@ -98,6 +110,7 @@ WHERE
if icon != nil {
app.IconURL = ptr.String(icon.IconUrl())
}
}
return &app, nil

View file

@ -717,7 +717,7 @@ type Service interface {
// AddAppStoreApp persists a VPP app onto a team and returns the resulting title ID
AddAppStoreApp(ctx context.Context, teamID *uint, appTeam VPPAppTeam) (uint, error)
UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string) (*VPPAppStoreApp, error)
UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string, displayName string) (*VPPAppStoreApp, error)
// GetInHouseAppManifest returns a manifest XML file that points at the download URL for the given in-house app.
GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error)

View file

@ -130,6 +130,9 @@ type SoftwareInstaller struct {
Categories []string `json:"categories"`
BundleIdentifier string `json:"-" db:"bundle_identifier"`
// DisplayName is an end-user friendly name.
DisplayName string `json:"display_name"`
}
// SoftwarePackageResponse is the response type used when applying software by batch.
@ -567,6 +570,12 @@ type UpdateSoftwareInstallerPayload struct {
DisplayName string
}
func (u *UpdateSoftwareInstallerPayload) IsNoopPayload(existing *SoftwareTitle) bool {
return u.SelfService == nil && u.InstallerFile == nil && u.PreInstallQuery == nil &&
u.InstallScript == nil && u.PostInstallScript == nil && u.UninstallScript == nil &&
u.LabelsIncludeAny == nil && u.LabelsExcludeAny == nil && u.DisplayName == existing.DisplayName
}
// DownloadSoftwareInstallerPayload is the payload for downloading a software installer.
type DownloadSoftwareInstallerPayload struct {
Filename string

View file

@ -46,7 +46,7 @@ type VPPAppTeam struct {
// automatically created when a VPP app is added to Fleet. This field should be set after VPP
// app creation if AddAutoInstallPolicy is true.
AddedAutomaticInstallPolicy *Policy `json:"-"`
DisplayName string `json:"-"`
DisplayName string `json:"display_name"`
}
// VPPApp represents a VPP (Volume Purchase Program) application,
@ -102,7 +102,8 @@ type VPPAppStoreApp struct {
AddedAt time.Time `db:"added_at" json:"created_at"`
// Categories is the list of categories to which this software belongs: e.g. "Productivity",
// "Browsers", etc.
Categories []string `json:"categories"`
Categories []string `json:"categories"`
DisplayName string `json:"display_name"`
}
// VPPAppStatusSummary represents aggregated status metrics for a VPP app.

View file

@ -456,7 +456,7 @@ type GetAppStoreAppsFunc func(ctx context.Context, teamID *uint) ([]*fleet.VPPAp
type AddAppStoreAppFunc func(ctx context.Context, teamID *uint, appTeam fleet.VPPAppTeam) (uint, error)
type UpdateAppStoreAppFunc func(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string) (*fleet.VPPAppStoreApp, error)
type UpdateAppStoreAppFunc func(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string, displayName string) (*fleet.VPPAppStoreApp, error)
type GetInHouseAppManifestFunc func(ctx context.Context, titleID uint, teamID *uint) ([]byte, error)
@ -3640,11 +3640,11 @@ func (s *Service) AddAppStoreApp(ctx context.Context, teamID *uint, appTeam flee
return s.AddAppStoreAppFunc(ctx, teamID, appTeam)
}
func (s *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string) (*fleet.VPPAppStoreApp, error) {
func (s *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny []string, labelsExcludeAny []string, categories []string, displayName string) (*fleet.VPPAppStoreApp, error) {
s.mu.Lock()
s.UpdateAppStoreAppFuncInvoked = true
s.mu.Unlock()
return s.UpdateAppStoreAppFunc(ctx, titleID, teamID, selfService, labelsIncludeAny, labelsExcludeAny, categories)
return s.UpdateAppStoreAppFunc(ctx, titleID, teamID, selfService, labelsIncludeAny, labelsExcludeAny, categories, displayName)
}
func (s *Service) GetInHouseAppManifest(ctx context.Context, titleID uint, teamID *uint) ([]byte, error) {

View file

@ -1,18 +1,22 @@
package service
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func (s *integrationMDMTestSuite) TestSoftwareTitleDisplayNames() {
t := s.T()
ctx := context.Background()
// Create a team
var newTeamResp teamResponse
@ -155,4 +159,148 @@ func (s *integrationMDMTestSuite) TestSoftwareTitleDisplayNames() {
require.Equal(t, getDeviceSw.Software[0].Name, "ruby")
s.Assert().Empty(getDeviceSw.Software[0].DisplayName)
// Test display names with app store apps
includeAnyApp := fleet.VPPApp{
VPPAppTeam: fleet.VPPAppTeam{
VPPAppID: fleet.VPPAppID{
AdamID: "1",
Platform: fleet.MacOSPlatform,
},
},
Name: "App 1",
BundleIdentifier: "a-1",
IconURL: "https://example.com/images/1",
LatestVersion: "1.0.0",
}
var addAppResp addAppStoreAppResponse
addAppReq := &addAppStoreAppRequest{
TeamID: &team.ID,
AppStoreID: includeAnyApp.AdamID,
SelfService: true,
}
// Now add it for real
s.DoJSON("POST", "/api/latest/fleet/software/app_store_apps", addAppReq, http.StatusOK, &addAppResp)
macOSTitleID := addAppResp.TitleID
updateAppReq := &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: false, DisplayName: "MacOSAppStoreAppUpdated1"}
var updateAppResp updateAppStoreAppResponse
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", macOSTitleID), updateAppReq, http.StatusOK, &updateAppResp)
s.Assert().Equal(updateAppReq.DisplayName, updateAppResp.AppStoreApp.DisplayName)
stResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", macOSTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team.ID))
s.Assert().Equal(updateAppReq.DisplayName, stResp.SoftwareTitle.DisplayName)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID), "query", includeAnyApp.Name)
for _, a := range resp.SoftwareTitles {
if a.ID == macOSTitleID {
s.Assert().Equal(updateAppReq.DisplayName, a.DisplayName)
}
}
updateAppReq = &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: false, DisplayName: "MacOSAppStoreAppUpdated2"}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", macOSTitleID), updateAppReq, http.StatusOK, &updateAppResp)
s.Assert().Equal(updateAppReq.DisplayName, updateAppResp.AppStoreApp.DisplayName)
stResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", macOSTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team.ID))
s.Assert().Equal(updateAppReq.DisplayName, stResp.SoftwareTitle.DisplayName)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID), "query", includeAnyApp.Name)
for _, a := range resp.SoftwareTitles {
if a.ID == macOSTitleID {
s.Assert().Equal(updateAppReq.DisplayName, a.DisplayName)
}
}
updateAppReq = &updateAppStoreAppRequest{TeamID: &team.ID, SelfService: false, DisplayName: ""}
s.DoJSON("PATCH", fmt.Sprintf("/api/latest/fleet/software/titles/%d/app_store_app", macOSTitleID), updateAppReq, http.StatusOK, &updateAppResp)
s.Assert().Equal(updateAppReq.DisplayName, updateAppResp.AppStoreApp.DisplayName)
stResp = getSoftwareTitleResponse{}
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", macOSTitleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team.ID))
s.Assert().Empty(stResp.SoftwareTitle.DisplayName)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID), "query", includeAnyApp.Name)
for _, a := range resp.SoftwareTitles {
if a.ID == macOSTitleID {
s.Assert().Empty(a.DisplayName)
}
}
// Test display names with in-house apps
// Upload in-house app for iOS, with the label as "exclude any"
s.uploadSoftwareInstaller(t, &fleet.UploadSoftwareInstallerPayload{Filename: "ipa_test.ipa", TeamID: &team.ID}, http.StatusOK, "")
// Get title ID
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &titleID, "SELECT title_id FROM in_house_apps WHERE filename = 'ipa_test.ipa'")
})
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID,
TeamID: &team.ID,
DisplayName: "InHouseAppUpdate",
}, http.StatusOK, "")
// Entity has display name
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team.ID))
s.Assert().Equal("InHouseAppUpdate", stResp.SoftwareTitle.DisplayName)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID))
for _, t := range resp.SoftwareTitles {
if t.ID == titleID {
s.Assert().Equal("InHouseAppUpdate", t.DisplayName)
}
}
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID,
TeamID: &team.ID,
DisplayName: "InHouseAppUpdate2",
}, http.StatusOK, "")
// Entity has display name
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team.ID))
s.Assert().Equal("InHouseAppUpdate2", stResp.SoftwareTitle.DisplayName)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID))
for _, t := range resp.SoftwareTitles {
if t.ID == titleID {
s.Assert().Equal("InHouseAppUpdate2", t.DisplayName)
}
}
s.updateSoftwareInstaller(t, &fleet.UpdateSoftwareInstallerPayload{
TitleID: titleID,
TeamID: &team.ID,
DisplayName: "",
}, http.StatusOK, "")
// Entity has display name
s.DoJSON("GET", fmt.Sprintf("/api/latest/fleet/software/titles/%d", titleID), getSoftwareTitleRequest{}, http.StatusOK, &stResp, "team_id", fmt.Sprint(team.ID))
s.Assert().Empty(stResp.SoftwareTitle.DisplayName)
// List software titles has display name
s.DoJSON("GET", "/api/latest/fleet/software/titles", listSoftwareTitlesRequest{}, http.StatusOK, &resp, "team_id", fmt.Sprint(team.ID))
for _, t := range resp.SoftwareTitles {
if t.ID == titleID {
s.Assert().Empty(t.DisplayName)
}
}
}

View file

@ -832,5 +832,14 @@ func (ts *withServer) updateSoftwareInstaller(
if expectedError != "" {
errMsg := extractServerErrorText(r.Body)
require.Contains(t, errMsg, expectedError)
return
}
bodyBytes, err := io.ReadAll(r.Body)
require.NoError(t, err)
var resp getSoftwareInstallerResponse
require.NoError(t, json.Unmarshal(bodyBytes, &resp))
assert.Equal(t, payload.DisplayName, resp.SoftwareInstaller.DisplayName)
}

View file

@ -103,6 +103,7 @@ type updateAppStoreAppRequest struct {
LabelsIncludeAny []string `json:"labels_include_any"`
LabelsExcludeAny []string `json:"labels_exclude_any"`
Categories []string `json:"categories"`
DisplayName string `json:"display_name"`
}
type updateAppStoreAppResponse struct {
@ -115,7 +116,7 @@ func (r updateAppStoreAppResponse) Error() error { return r.Err }
func updateAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*updateAppStoreAppRequest)
updatedApp, err := svc.UpdateAppStoreApp(ctx, req.TitleID, req.TeamID, req.SelfService, req.LabelsIncludeAny, req.LabelsExcludeAny, req.Categories)
updatedApp, err := svc.UpdateAppStoreApp(ctx, req.TitleID, req.TeamID, req.SelfService, req.LabelsIncludeAny, req.LabelsExcludeAny, req.Categories, req.DisplayName)
if err != nil {
return updateAppStoreAppResponse{Err: err}, nil
}
@ -123,7 +124,7 @@ func updateAppStoreAppEndpoint(ctx context.Context, request interface{}, svc fle
return updateAppStoreAppResponse{AppStoreApp: updatedApp}, nil
}
func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string) (*fleet.VPPAppStoreApp, error) {
func (svc *Service) UpdateAppStoreApp(ctx context.Context, titleID uint, teamID *uint, selfService bool, labelsIncludeAny, labelsExcludeAny, categories []string, displayName string) (*fleet.VPPAppStoreApp, error) {
// skipauth: No authorization check needed due to implementation returning
// only license error.
svc.authz.SkipAuthorization(ctx)