diff --git a/ee/server/service/in_house_apps.go b/ee/server/service/in_house_apps.go index 4cac7743fa..b8e06c7a69 100644 --- a/ee/server/service/in_house_apps.go +++ b/ee/server/service/in_house_apps.go @@ -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 diff --git a/ee/server/service/software_installers.go b/ee/server/service/software_installers.go index 8a22fd4032..9e34a823e9 100644 --- a/ee/server/service/software_installers.go +++ b/ee/server/service/software_installers.go @@ -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 } diff --git a/ee/server/service/vpp.go b/ee/server/service/vpp.go index c608bf8241..48c9502587 100644 --- a/ee/server/service/vpp.go +++ b/ee/server/service/vpp.go @@ -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, diff --git a/server/datastore/mysql/in_house_apps.go b/server/datastore/mysql/in_house_apps.go index 1b25065ba1..461447f4cb 100644 --- a/server/datastore/mysql/in_house_apps.go +++ b/server/datastore/mysql/in_house_apps.go @@ -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 { diff --git a/server/datastore/mysql/software_installers.go b/server/datastore/mysql/software_installers.go index d8a988cda4..92a51bac44 100644 --- a/server/datastore/mysql/software_installers.go +++ b/server/datastore/mysql/software_installers.go @@ -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 { diff --git a/server/datastore/mysql/vpp.go b/server/datastore/mysql/vpp.go index 4d99fcf770..4564bfb2c4 100644 --- a/server/datastore/mysql/vpp.go +++ b/server/datastore/mysql/vpp.go @@ -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 diff --git a/server/fleet/service.go b/server/fleet/service.go index b11f47c3f7..6a55e9925c 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -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) diff --git a/server/fleet/software_installer.go b/server/fleet/software_installer.go index 26d6639307..35e82b1e00 100644 --- a/server/fleet/software_installer.go +++ b/server/fleet/software_installer.go @@ -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 diff --git a/server/fleet/vpp.go b/server/fleet/vpp.go index c4bbb50f11..7d11fa53d8 100644 --- a/server/fleet/vpp.go +++ b/server/fleet/vpp.go @@ -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. diff --git a/server/mock/service/service_mock.go b/server/mock/service/service_mock.go index 75e9b2cdaf..10a3359e65 100644 --- a/server/mock/service/service_mock.go +++ b/server/mock/service/service_mock.go @@ -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) { diff --git a/server/service/integration_software_titles_test.go b/server/service/integration_software_titles_test.go index b1be581956..acad922d6f 100644 --- a/server/service/integration_software_titles_test.go +++ b/server/service/integration_software_titles_test.go @@ -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) + } + } + } diff --git a/server/service/testing_client.go b/server/service/testing_client.go index 7100f21e5a..ce2ea1c0f4 100644 --- a/server/service/testing_client.go +++ b/server/service/testing_client.go @@ -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) } diff --git a/server/service/vpp.go b/server/service/vpp.go index 4d7e788a85..21dd0934d9 100644 --- a/server/service/vpp.go +++ b/server/service/vpp.go @@ -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)