diff --git a/ee/server/service/maintained_apps.go b/ee/server/service/maintained_apps.go index 3c61607d1d..4564c2164e 100644 --- a/ee/server/service/maintained_apps.go +++ b/ee/server/service/maintained_apps.go @@ -57,7 +57,7 @@ func (svc *Service) AddFleetMaintainedApp( return 0, ctxerr.Wrap(ctx, err, "transient server issue validating embedded secrets") } - app, err := svc.ds.GetMaintainedAppByID(ctx, appID) + app, err := svc.ds.GetMaintainedAppByID(ctx, appID, teamID) if err != nil { return 0, ctxerr.Wrap(ctx, err, "getting maintained app by id") } @@ -125,7 +125,7 @@ func (svc *Service) AddFleetMaintainedApp( TeamID: teamID, Version: app.Version, Filename: filename, - Platform: string(app.Platform), + Platform: app.Platform, Source: "apps", Extension: extension, BundleIdentifier: app.BundleIdentifier, @@ -191,6 +191,7 @@ func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID *uint, o 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 managed apps") @@ -199,14 +200,20 @@ func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID *uint, o return avail, meta, nil } -func (svc *Service) GetFleetMaintainedApp(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) { - // Special case auth for maintained apps (vs. normal installers) as maintained apps are not scoped to a team; - // use SoftwareInstaller for authorization elsewhere. - if err := svc.authz.Authorize(ctx, &fleet.MaintainedApp{}, fleet.ActionRead); err != nil { - return nil, err +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) } - app, err := svc.ds.GetMaintainedAppByID(ctx, appID) + if authErr != nil { + return nil, authErr + } + + app, err := svc.ds.GetMaintainedAppByID(ctx, appID, teamID) if err != nil { return nil, ctxerr.Wrap(ctx, err, "get fleet maintained app") } diff --git a/ee/server/service/maintained_apps_test.go b/ee/server/service/maintained_apps_test.go index 332bf555ff..d714601258 100644 --- a/ee/server/service/maintained_apps_test.go +++ b/ee/server/service/maintained_apps_test.go @@ -114,7 +114,7 @@ func TestGetMaintainedAppAuth(t *testing.T) { ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) { return &fleet.AppConfig{}, nil } - ds.GetMaintainedAppByIDFunc = func(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) { + ds.GetMaintainedAppByIDFunc = func(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) { return &fleet.MaintainedApp{}, nil } authorizer, err := authz.NewAuthorizer() @@ -122,39 +122,53 @@ func TestGetMaintainedAppAuth(t *testing.T) { svc := &Service{authz: authorizer, ds: ds} testCases := []struct { - name string - user *fleet.User - shouldFail bool + name string + user *fleet.User + shouldFailWithNoTeam bool + shouldFailWithMatchingTeam bool + shouldFailWithDifferentTeam bool }{ { "global admin", &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}, false, + false, + false, }, { "global maintainer", &fleet.User{GlobalRole: ptr.String(fleet.RoleMaintainer)}, false, + false, + false, }, { "global observer", &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, true, + true, + true, }, { "team admin", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleAdmin}}}, false, + false, + true, }, { "team maintainer", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleMaintainer}}}, false, + false, + true, }, { "team observer", &fleet.User{Teams: []fleet.UserTeam{{Team: fleet.Team{ID: 1}, Role: fleet.RoleObserver}}}, true, + true, + true, }, } @@ -162,9 +176,25 @@ func TestGetMaintainedAppAuth(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { ctx := viewer.NewContext(context.Background(), viewer.Viewer{User: tt.user}) - _, err := svc.GetFleetMaintainedApp(ctx, 123) + _, err := svc.GetFleetMaintainedApp(ctx, 123, nil) - if tt.shouldFail { + if tt.shouldFailWithNoTeam { + require.Error(t, err) + require.ErrorAs(t, err, &forbiddenError) + } else { + require.NoError(t, err) + } + + _, err = svc.GetFleetMaintainedApp(ctx, 1, ptr.Uint(1)) + if tt.shouldFailWithMatchingTeam { + require.Error(t, err) + require.ErrorAs(t, err, &forbiddenError) + } else { + require.NoError(t, err) + } + + _, err = svc.GetFleetMaintainedApp(ctx, 1, ptr.Uint(2)) + if tt.shouldFailWithDifferentTeam { require.Error(t, err) require.ErrorAs(t, err, &forbiddenError) } else { diff --git a/server/datastore/mysql/maintained_apps.go b/server/datastore/mysql/maintained_apps.go index aeacf226f0..7befbd05d0 100644 --- a/server/datastore/mysql/maintained_apps.go +++ b/server/datastore/mysql/maintained_apps.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "net/http" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" @@ -68,28 +69,53 @@ ON DUPLICATE KEY UPDATE return app, nil } -func (ds *Datastore) GetMaintainedAppByID(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) { - const stmt = ` -SELECT - fla.id, - fla.name, - fla.token, - fla.version, - fla.platform, - fla.installer_url, - fla.sha256, - fla.bundle_identifier, - sc1.contents AS install_script, - sc2.contents AS uninstall_script -FROM fleet_library_apps fla +const teamFMATitlesJoin = ` + team_titles.id software_title_id FROM fleet_library_apps fla + LEFT JOIN ( + SELECT DISTINCT st.id, st.bundle_identifier, st.name + FROM software_titles st + LEFT JOIN + software_installers si + ON si.title_id = st.id AND si.global_or_team_id = ? + AND si.platform IN ('darwin','windows') + LEFT JOIN + vpp_apps va + ON va.title_id = st.id + AND va.platform = 'darwin' + LEFT JOIN + vpp_apps_teams vat + ON vat.adam_id = va.adam_id + AND vat.platform = va.platform + AND vat.global_or_team_id = ? + WHERE si.id IS NOT NULL OR vat.id IS NOT NULL + ) team_titles ON ( + team_titles.bundle_identifier != '' AND team_titles.bundle_identifier = fla.bundle_identifier + ) OR ( + team_titles.bundle_identifier = '' AND team_titles.name = fla.name + )` + +func (ds *Datastore) GetMaintainedAppByID(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) { + stmt := `SELECT fla.id, fla.name, fla.token, fla.version, fla.platform, fla.installer_url, fla.sha256, fla.bundle_identifier, + sc1.contents AS install_script, sc2.contents AS uninstall_script, ` + var args []any + + if teamID != nil { + stmt += teamFMATitlesJoin + args = []any{teamID, teamID} + } else { + stmt += `NULL software_title_id FROM fleet_library_apps fla` + } + + stmt += ` JOIN script_contents sc1 ON sc1.id = fla.install_script_content_id JOIN script_contents sc2 ON sc2.id = fla.uninstall_script_content_id WHERE fla.id = ? ` + args = append(args, appID) var app fleet.MaintainedApp - if err := sqlx.GetContext(ctx, ds.reader(ctx), &app, stmt, appID); err != nil { + if err := sqlx.GetContext(ctx, ds.reader(ctx), &app, stmt, args...); err != nil { if errors.Is(err, sql.ErrNoRows) { return nil, ctxerr.Wrap(ctx, notFound("MaintainedApp"), "no matching maintained app found") } @@ -100,36 +126,30 @@ WHERE return &app, nil } +// NoMaintainedAppsInDatabase is the error type for no Fleet Maintained Apps in the database +type NoMaintainedAppsInDatabase struct { + fleet.ErrorWithUUID +} + +// Error implements the error interface. +func (e *NoMaintainedAppsInDatabase) Error() string { + return `Fleet was unable to ingest the maintained apps list. Run fleetctl trigger name=maintained_apps to try repopulating the apps list.` +} + +// StatusCode implements the go-kit http StatusCoder interface. +func (e *NoMaintainedAppsInDatabase) StatusCode() int { + return http.StatusNotFound +} + func (ds *Datastore) ListAvailableFleetMaintainedApps(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) { - stmt := `SELECT fla.id, fla.name, fla.version, fla.platform, fla.updated_at FROM fleet_library_apps fla ` + stmt := `SELECT fla.id, fla.name, fla.platform, ` var args []any if teamID != nil { - stmt += `WHERE NOT EXISTS ( - SELECT - 1 - FROM - software_titles st - LEFT JOIN - software_installers si - ON si.title_id = st.id - LEFT JOIN - vpp_apps va - ON va.title_id = st.id - LEFT JOIN - vpp_apps_teams vat - ON vat.adam_id = va.adam_id - WHERE - st.bundle_identifier = fla.bundle_identifier - AND ( - (si.platform = fla.platform AND si.global_or_team_id = ?) - OR - (va.platform = fla.platform AND vat.global_or_team_id = ?) - ) - )` + stmt += teamFMATitlesJoin + ` WHERE TRUE` args = []any{teamID, teamID} } else { - stmt += `WHERE TRUE` + stmt += `NULL software_title_id FROM fleet_library_apps fla` } if match := opt.MatchQuery; match != "" { @@ -138,16 +158,27 @@ func (ds *Datastore) ListAvailableFleetMaintainedApps(ctx context.Context, teamI args = append(args, match) } - // perform a second query to grab the counts. Build the count statement before + // perform a second query to grab the filtered count. Build the count statement before // adding the pagination constraints to the stmt but after including the // MatchQuery option sql. dbReader := ds.reader(ctx) getAppsCountStmt := fmt.Sprintf(`SELECT COUNT(DISTINCT s.id) FROM (%s) AS s`, stmt) - var counts int - if err := sqlx.GetContext(ctx, dbReader, &counts, getAppsCountStmt, args...); err != nil { + var filteredCount int + if err := sqlx.GetContext(ctx, dbReader, &filteredCount, getAppsCountStmt, args...); err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "get fleet maintained apps count") } + if filteredCount == 0 { // check if we have nothing in the full apps list, in which case provide an error back + var totalCount int + if err := sqlx.GetContext(ctx, dbReader, &totalCount, `SELECT COUNT(id) FROM fleet_library_apps`); err != nil { + return nil, nil, ctxerr.Wrap(ctx, err, "get fleet maintained apps total count") + } + + if totalCount == 0 { + return nil, nil, &NoMaintainedAppsInDatabase{} + } + } + stmtPaged, args := appendListOptionsWithCursorToSQL(stmt, args, &opt) var avail []fleet.MaintainedApp @@ -155,8 +186,8 @@ func (ds *Datastore) ListAvailableFleetMaintainedApps(ctx context.Context, teamI return nil, nil, ctxerr.Wrap(ctx, err, "selecting available fleet managed apps") } - meta := &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0, TotalResults: uint(counts)} //nolint:gosec // dismiss G115 - if len(avail) > int(opt.PerPage) { //nolint:gosec // dismiss G115 + meta := &fleet.PaginationMetadata{HasPreviousResults: opt.Page > 0, TotalResults: uint(filteredCount)} //nolint:gosec // dismiss G115 + if len(avail) > int(opt.PerPage) { //nolint:gosec // dismiss G115 meta.HasNextResults = true avail = avail[:len(avail)-1] } diff --git a/server/datastore/mysql/maintained_apps_test.go b/server/datastore/mysql/maintained_apps_test.go index 21a19665b8..b613c0e7f7 100644 --- a/server/datastore/mysql/maintained_apps_test.go +++ b/server/datastore/mysql/maintained_apps_test.go @@ -7,6 +7,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps" + "github.com/fleetdm/fleet/v4/server/ptr" "github.com/fleetdm/fleet/v4/server/test" "github.com/go-kit/kit/log" "github.com/jmoiron/sqlx" @@ -22,8 +23,7 @@ func TestMaintainedApps(t *testing.T) { }{ {"UpsertMaintainedApps", testUpsertMaintainedApps}, {"IngestWithBrew", testIngestWithBrew}, - {"ListAvailableApps", testListAvailableApps}, - {"GetMaintainedAppByID", testGetMaintainedAppByID}, + {"ListAndGetAvailableApps", testListAndGetAvailableApps}, } for _, c := range cases { @@ -58,7 +58,7 @@ func testUpsertMaintainedApps(t *testing.T, ds *Datastore) { Token: "figma", InstallerURL: "https://desktop.figma.com/mac-arm/Figma-999.9.9.zip", Version: "999.9.9", - Platform: fleet.MacOSPlatform, + Platform: "darwin", }) require.NoError(t, err) @@ -89,7 +89,7 @@ func testIngestWithBrew(t *testing.T, ds *Datastore) { require.ElementsMatch(t, expectedTokens, actualTokens) } -func testListAvailableApps(t *testing.T, ds *Datastore) { +func testListAndGetAvailableApps(t *testing.T, ds *Datastore) { ctx := context.Background() user := test.NewUser(t, ds, "Zaphod Beeblebrox", "zaphod@example.com", true) @@ -102,7 +102,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { Name: "Maintained1", Token: "maintained1", Version: "1.0.0", - Platform: fleet.MacOSPlatform, + Platform: "darwin", InstallerURL: "http://example.com/main1", SHA256: "DEADBEEF", BundleIdentifier: "fleet.maintained1", @@ -115,7 +115,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { Name: "Maintained2", Token: "maintained2", Version: "1.0.0", - Platform: fleet.MacOSPlatform, + Platform: "darwin", InstallerURL: "http://example.com/main1", SHA256: "DEADBEEF", BundleIdentifier: "fleet.maintained2", @@ -127,7 +127,7 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { Name: "Maintained3", Token: "maintained3", Version: "1.0.0", - Platform: fleet.MacOSPlatform, + Platform: "darwin", InstallerURL: "http://example.com/main1", SHA256: "DEADBEEF", BundleIdentifier: "fleet.maintained3", @@ -136,43 +136,37 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { }) require.NoError(t, err) + gotApp, err := ds.GetMaintainedAppByID(ctx, maintained1.ID, nil) + require.NoError(t, err) + require.Equal(t, maintained1, gotApp) + + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained1.ID, &team1.ID) + require.NoError(t, err) + require.Equal(t, maintained1, gotApp) + expectedApps := []fleet.MaintainedApp{ { ID: maintained1.ID, Name: maintained1.Name, - Version: maintained1.Version, Platform: maintained1.Platform, }, { ID: maintained2.ID, Name: maintained2.Name, - Version: maintained2.Version, Platform: maintained2.Platform, }, { ID: maintained3.ID, Name: maintained3.Name, - Version: maintained3.Version, Platform: maintained3.Platform, }, } - // We use this assertion for UpdatedAt because we only concerned with - // its presence, not its value. We will set it to nil after asserting - // to make the expected vs actual comparison easier. - assertUpdatedAt := func(apps []fleet.MaintainedApp) { - for i, app := range apps { - require.NotNil(t, app.UpdatedAt) - apps[i].UpdatedAt = nil - } - } - // Testing pagination apps, meta, err := ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) require.NoError(t, err) require.Len(t, apps, 3) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps, apps) require.False(t, meta.HasNextResults) @@ -180,7 +174,6 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, apps, 1) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps[:1], apps) require.True(t, meta.HasNextResults) @@ -188,7 +181,6 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, apps, 1) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps[1:2], apps) require.True(t, meta.HasNextResults) require.True(t, meta.HasPreviousResults) @@ -197,13 +189,12 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, apps, 1) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps[2:3], apps) require.False(t, meta.HasNextResults) require.True(t, meta.HasPreviousResults) // - // Test excluding results for existing apps (installers) + // Test including software title ID for existing apps (installers) /// Irrelevant package _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ @@ -222,7 +213,6 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, apps, 3) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps, apps) /// Correct package on a different team @@ -242,11 +232,10 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, apps, 3) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps, apps) /// Correct package on the right team with the wrong platform - _, _, err = ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ + _, titleID, err := ds.MatchOrCreateSoftwareInstaller(ctx, &fleet.UploadSoftwareInstallerPayload{ Title: "Maintained1", TeamID: &team1.ID, InstallScript: "nothing", @@ -262,9 +251,12 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Len(t, apps, 3) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) require.Equal(t, expectedApps, apps) + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained1.ID, &team1.ID) + require.NoError(t, err) + require.Equal(t, maintained1, gotApp) + /// Correct team and platform ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error { _, err := q.ExecContext(ctx, "UPDATE software_installers SET platform = ? WHERE platform = ?", fleet.MacOSPlatform, fleet.IOSPlatform) @@ -273,13 +265,22 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) require.NoError(t, err) - require.Len(t, apps, 2) - require.EqualValues(t, meta.TotalResults, 2) - assertUpdatedAt(apps) - require.Equal(t, expectedApps[1:], apps) + require.Len(t, apps, 3) + require.EqualValues(t, meta.TotalResults, 3) + expectedApps[0].TitleID = ptr.Uint(titleID) + require.Equal(t, expectedApps, apps) + + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained1.ID, ptr.Uint(0)) + require.NoError(t, err) + require.Equal(t, maintained1, gotApp) + + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained1.ID, &team1.ID) + require.NoError(t, err) + maintained1.TitleID = ptr.Uint(titleID) + require.Equal(t, maintained1, gotApp) // - // Test excluding results for existing apps (VPP) + // Test including software title ID for existing apps (VPP) test.CreateInsertGlobalVPPToken(t, ds) @@ -299,10 +300,9 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) require.NoError(t, err) - require.Len(t, apps, 2) - require.EqualValues(t, meta.TotalResults, 2) - assertUpdatedAt(apps) - require.Equal(t, expectedApps[1:], apps) + require.Len(t, apps, 3) + require.EqualValues(t, meta.TotalResults, 3) + require.Equal(t, expectedApps, apps) // right vpp app, wrong team vppMaintained2 := &fleet.VPPApp{ @@ -315,26 +315,14 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { }, BundleIdentifier: "fleet.maintained2", } - _, err = ds.InsertVPPAppWithTeam(ctx, vppMaintained2, &team2.ID) + vppApp, err := ds.InsertVPPAppWithTeam(ctx, vppMaintained2, &team2.ID) require.NoError(t, err) apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) require.NoError(t, err) - require.Len(t, apps, 2) - require.EqualValues(t, meta.TotalResults, 2) - assertUpdatedAt(apps) - require.Equal(t, expectedApps[1:], apps) - - // right vpp app, right team - _, err = ds.InsertVPPAppWithTeam(ctx, vppMaintained2, &team1.ID) - require.NoError(t, err) - - apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) - require.NoError(t, err) - require.Len(t, apps, 1) - require.EqualValues(t, meta.TotalResults, 1) - assertUpdatedAt(apps) - require.Equal(t, expectedApps[2:], apps) + require.Len(t, apps, 3) + require.EqualValues(t, meta.TotalResults, 3) + require.Equal(t, expectedApps, apps) // right app, right team, wrong platform vppMaintained3 := &fleet.VPPApp{ @@ -353,38 +341,46 @@ func testListAvailableApps(t *testing.T, ds *Datastore) { apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) require.NoError(t, err) - require.Len(t, apps, 1) - require.EqualValues(t, meta.TotalResults, 1) - assertUpdatedAt(apps) - require.Equal(t, expectedApps[2:], apps) + require.Len(t, apps, 3) + require.EqualValues(t, meta.TotalResults, 3) + require.Equal(t, expectedApps, apps) - // viewing with no team selected shouldn't exclude any results + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained3.ID, &team1.ID) + require.NoError(t, err) + require.Equal(t, maintained3, gotApp) + + // right vpp app, right team + _, err = ds.InsertVPPAppWithTeam(ctx, vppMaintained2, &team1.ID) + require.NoError(t, err) + + apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, &team1.ID, fleet.ListOptions{IncludeMetadata: true}) + require.NoError(t, err) + require.Len(t, apps, 3) + require.EqualValues(t, meta.TotalResults, 3) + expectedApps[1].TitleID = ptr.Uint(vppApp.TitleID) + require.Equal(t, expectedApps, apps) + + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained2.ID, &team1.ID) + require.NoError(t, err) + maintained2.TitleID = ptr.Uint(vppApp.TitleID) + require.Equal(t, maintained2, gotApp) + + // viewing with no team selected shouldn't include any title IDs apps, meta, err = ds.ListAvailableFleetMaintainedApps(ctx, nil, fleet.ListOptions{IncludeMetadata: true}) require.NoError(t, err) require.Len(t, apps, 3) require.EqualValues(t, meta.TotalResults, 3) - assertUpdatedAt(apps) + expectedApps[0].TitleID = nil + expectedApps[1].TitleID = nil require.Equal(t, expectedApps, apps) -} -func testGetMaintainedAppByID(t *testing.T, ds *Datastore) { - ctx := context.Background() - - expApp, err := ds.UpsertMaintainedApp(ctx, &fleet.MaintainedApp{ - Name: "foo", - Token: "token", - Version: "1.0.0", - Platform: "darwin", - InstallerURL: "https://example.com/foo.zip", - SHA256: "sha", - BundleIdentifier: "bundle", - InstallScript: "install", - UninstallScript: "uninstall", - }) + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained1.ID, nil) require.NoError(t, err) + maintained1.TitleID = nil + require.Equal(t, maintained1, gotApp) - gotApp, err := ds.GetMaintainedAppByID(ctx, expApp.ID) + gotApp, err = ds.GetMaintainedAppByID(ctx, maintained3.ID, nil) require.NoError(t, err) - - require.Equal(t, expApp, gotApp) + maintained3.TitleID = nil + require.Equal(t, maintained3, gotApp) } diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 66acc4e5e6..595e454c65 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -1944,12 +1944,15 @@ type Datastore interface { // Fleet-maintained apps // - // ListAvailableFleetMaintainedApps returns a list of - // Fleet-maintained apps available to a specific team (or the full list of apps if no team is specified) + // ListAvailableFleetMaintainedApps returns a list of Fleet-maintained apps, including software title ID if + // either the maintained app or a custom package/VPP app for the same app is installed on the specified team, + // if a team is specified. ListAvailableFleetMaintainedApps(ctx context.Context, teamID *uint, opt ListOptions) ([]MaintainedApp, *PaginationMetadata, error) - // GetMaintainedAppByID gets a Fleet-maintained app by its ID. - GetMaintainedAppByID(ctx context.Context, appID uint) (*MaintainedApp, error) + // GetMaintainedAppByID gets a Fleet-maintained app by its ID, including software title ID if + // either the maintained app or a custom package/VPP app for the same app is installed on the specified team, + // if a team is specified. + GetMaintainedAppByID(ctx context.Context, appID uint, teamID *uint) (*MaintainedApp, error) // UpsertMaintainedApp inserts or updates a maintained app using the updated // metadata provided via app. diff --git a/server/fleet/maintained_apps.go b/server/fleet/maintained_apps.go index c8dce4ac0d..d4b3f6b94a 100644 --- a/server/fleet/maintained_apps.go +++ b/server/fleet/maintained_apps.go @@ -1,26 +1,23 @@ package fleet -import "time" - -// MaintainedApp represets an app in the Fleet library of maintained apps, +// MaintainedApp represents an app in the Fleet library of maintained apps, // as stored in the fleet_library_apps table. type MaintainedApp struct { - ID uint `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Token string `json:"-" db:"token"` - Version string `json:"version" db:"version"` - Platform AppleDevicePlatform `json:"platform" db:"platform"` - InstallerURL string `json:"url" db:"installer_url"` - SHA256 string `json:"-" db:"sha256"` - BundleIdentifier string `json:"-" db:"bundle_identifier"` + ID uint `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Token string `json:"-" db:"token"` + Version string `json:"version,omitempty" db:"version"` + Platform string `json:"platform" db:"platform"` + TitleID *uint `json:"software_title_id" db:"software_title_id"` + InstallerURL string `json:"url,omitempty" db:"installer_url"` + SHA256 string `json:"-" db:"sha256"` + BundleIdentifier string `json:"-" db:"bundle_identifier"` // InstallScript and UninstallScript are not stored directly in the table, they // must be filled via a JOIN on script_contents. On insert/update/upsert, these // fields are used to provide the content of those scripts. - InstallScript string `json:"install_script" db:"install_script"` - UninstallScript string `json:"uninstall_script" db:"uninstall_script"` - // UpdatedAt is the timestamp when the fleet maintained app data was last updated. - UpdatedAt *time.Time `json:"-" db:"updated_at"` + InstallScript string `json:"install_script,omitempty" db:"install_script"` + UninstallScript string `json:"uninstall_script,omitempty" db:"uninstall_script"` } // AuthzType implements authz.AuthzTyper. diff --git a/server/fleet/service.go b/server/fleet/service.go index 5f959b08dc..a4bd83c1f9 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -1187,10 +1187,10 @@ type Service interface { // AddFleetMaintainedApp adds a Fleet-maintained app to the given team. AddFleetMaintainedApp(ctx context.Context, teamID *uint, appID uint, installScript, preInstallQuery, postInstallScript, uninstallScript string, selfService bool, automaticInstall bool, labelsIncludeAny, labelsExcludeAny []string) (uint, error) - // ListFleetMaintainedApps lists Fleet-maintained apps available to a specific team + // ListFleetMaintainedApps lists Fleet-maintained apps, including associated software title for supplied team ID (if any) ListFleetMaintainedApps(ctx context.Context, teamID *uint, opts ListOptions) ([]MaintainedApp, *PaginationMetadata, error) - // GetFleetMaintainedApp returns a Fleet-maintained app by ID - GetFleetMaintainedApp(ctx context.Context, appID uint) (*MaintainedApp, error) + // GetFleetMaintainedApp returns a Fleet-maintained app by ID, including associated software title for supplied team ID (if any) + GetFleetMaintainedApp(ctx context.Context, appID uint, teamID *uint) (*MaintainedApp, error) // ///////////////////////////////////////////////////////////////////////////// // Maintenance windows diff --git a/server/mdm/maintainedapps/ingest.go b/server/mdm/maintainedapps/ingest.go index 2c2c86942e..657849843a 100644 --- a/server/mdm/maintainedapps/ingest.go +++ b/server/mdm/maintainedapps/ingest.go @@ -176,7 +176,7 @@ func (i ingester) ingestOne(ctx context.Context, app maintainedApp, client *http Token: cask.Token, Version: cask.Version, // for now, maintained apps are always macOS (darwin) - Platform: fleet.MacOSPlatform, + Platform: "darwin", InstallerURL: cask.URL, SHA256: cask.SHA256, BundleIdentifier: app.BundleIdentifier, diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index e8d2e7c93c..c0f7c18f23 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -1228,7 +1228,7 @@ type MaybeUpdateSetupExperienceVPPStatusFunc func(ctx context.Context, hostUUID type ListAvailableFleetMaintainedAppsFunc func(ctx context.Context, teamID *uint, opt fleet.ListOptions) ([]fleet.MaintainedApp, *fleet.PaginationMetadata, error) -type GetMaintainedAppByIDFunc func(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) +type GetMaintainedAppByIDFunc func(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) type UpsertMaintainedAppFunc func(ctx context.Context, app *fleet.MaintainedApp) (*fleet.MaintainedApp, error) @@ -7382,11 +7382,11 @@ func (s *DataStore) ListAvailableFleetMaintainedApps(ctx context.Context, teamID return s.ListAvailableFleetMaintainedAppsFunc(ctx, teamID, opt) } -func (s *DataStore) GetMaintainedAppByID(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) { +func (s *DataStore) GetMaintainedAppByID(ctx context.Context, appID uint, teamID *uint) (*fleet.MaintainedApp, error) { s.mu.Lock() s.GetMaintainedAppByIDFuncInvoked = true s.mu.Unlock() - return s.GetMaintainedAppByIDFunc(ctx, appID) + return s.GetMaintainedAppByIDFunc(ctx, appID, teamID) } func (s *DataStore) UpsertMaintainedApp(ctx context.Context, app *fleet.MaintainedApp) (*fleet.MaintainedApp, error) { diff --git a/server/service/handler.go b/server/service/handler.go index 5483e288bf..b95e953ec5 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -367,7 +367,6 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC // Fleet-maintained apps ue.POST("/api/_version_/fleet/software/fleet_maintained_apps", addFleetMaintainedAppEndpoint, addFleetMaintainedAppRequest{}) - ue.PATCH("/api/_version_/fleet/software/fleet_maintained_apps", editFleetMaintainedAppEndpoint, editFleetMaintainedAppRequest{}) ue.GET("/api/_version_/fleet/software/fleet_maintained_apps", listFleetMaintainedAppsEndpoint, listFleetMaintainedAppsRequest{}) ue.GET("/api/_version_/fleet/software/fleet_maintained_apps/{app_id}", getFleetMaintainedApp, getFleetMaintainedAppRequest{}) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 254719beae..54388952ec 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -16165,7 +16165,12 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { s.Do("POST", "/api/latest/fleet/software/fleet_maintained_apps", &addFleetMaintainedAppRequest{AppID: 1}, http.StatusNotFound) // Insert the list of maintained apps - expectedApps := maintainedapps.IngestMaintainedApps(t, s.ds) + insertedApps := maintainedapps.IngestMaintainedApps(t, s.ds) + var expectedApps []fleet.MaintainedApp + for _, app := range insertedApps { + app.Version = "" // we don't expect version to be returned in list view anymore + expectedApps = append(expectedApps, app) + } // Edit DB to spoof URLs and SHA256 values so we don't have to actually download the installers mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error { @@ -16199,6 +16204,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { app.ID = 0 listAppsNoID = append(listAppsNoID, app) } + slices.SortFunc(listAppsNoID, func(a, b fleet.MaintainedApp) int { return cmp.Compare(a.Name, b.Name) }) @@ -16228,7 +16234,7 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { var getMAResp getFleetMaintainedAppResponse s.DoJSON(http.MethodGet, fmt.Sprintf("/api/latest/fleet/software/fleet_maintained_apps/%d", listMAResp.FleetMaintainedApps[0].ID), getFleetMaintainedAppRequest{}, http.StatusOK, &getMAResp) // TODO this will change when actual install scripts are created. - dbAppRecord, err := s.ds.GetMaintainedAppByID(ctx, listMAResp.FleetMaintainedApps[0].ID) + dbAppRecord, err := s.ds.GetMaintainedAppByID(ctx, listMAResp.FleetMaintainedApps[0].ID, nil) require.NoError(t, err) dbAppResponse := fleet.MaintainedApp{ ID: dbAppRecord.ID, @@ -16309,13 +16315,14 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { &listMAResp, "team_id", fmt.Sprint(team.ID)) require.Nil(t, listMAResp.Err) require.False(t, listMAResp.Meta.HasPreviousResults) - require.Len(t, listMAResp.FleetMaintainedApps, len(expectedApps)-1) + require.Len(t, listMAResp.FleetMaintainedApps, len(expectedApps)) // Validate software installer fields - mapp, err := s.ds.GetMaintainedAppByID(ctx, 1) + mapp, err := s.ds.GetMaintainedAppByID(ctx, 1, &team.ID) require.NoError(t, err) i, err := s.ds.GetSoftwareInstallerMetadataByID(context.Background(), getSoftwareInstallerIDByMAppID(1)) require.NoError(t, err) + require.Equal(t, mapp.TitleID, i.TitleID) require.Equal(t, ptr.Uint(1), i.FleetLibraryAppID) require.Equal(t, mapp.SHA256, i.StorageID) require.Equal(t, "darwin", i.Platform) @@ -16393,12 +16400,13 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() { "team_id", "0", ) - mapp, err = s.ds.GetMaintainedAppByID(ctx, 4) + mapp, err = s.ds.GetMaintainedAppByID(ctx, 4, ptr.Uint(0)) require.NoError(t, err) require.Equal(t, 1, resp.Count) title = resp.SoftwareTitles[0] require.NotNil(t, title.BundleIdentifier) require.Equal(t, ptr.String(mapp.BundleIdentifier), title.BundleIdentifier) + require.Equal(t, title.ID, *mapp.TitleID) require.Equal(t, mapp.Version, title.SoftwarePackage.Version) require.Equal(t, "installer.zip", title.SoftwarePackage.Name) diff --git a/server/service/maintained_apps.go b/server/service/maintained_apps.go index 0ac0826909..701ad4c54e 100644 --- a/server/service/maintained_apps.go +++ b/server/service/maintained_apps.go @@ -3,7 +3,6 @@ package service import ( "context" "errors" - "time" "github.com/fleetdm/fleet/v4/server/fleet" "github.com/fleetdm/fleet/v4/server/mdm/maintainedapps" @@ -64,32 +63,12 @@ func (svc *Service) AddFleetMaintainedApp(ctx context.Context, _ *uint, _ uint, return 0, fleet.ErrMissingLicense } -type editFleetMaintainedAppRequest struct { - TeamID *uint `json:"team_id"` - AppID uint `json:"fleet_maintained_app_id"` - InstallScript string `json:"install_script"` - PreInstallQuery string `json:"pre_install_query"` - 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"` -} - -func editFleetMaintainedAppEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { - // TODO: implement this - - return nil, errors.New("not implemented") -} - type listFleetMaintainedAppsRequest struct { fleet.ListOptions TeamID *uint `query:"team_id,optional"` } type listFleetMaintainedAppsResponse struct { - Count int `json:"count"` - AppsUpdatedAt *time.Time `json:"apps_updated_at"` FleetMaintainedApps []fleet.MaintainedApp `json:"fleet_maintained_apps"` Meta *fleet.PaginationMetadata `json:"meta"` Err error `json:"error,omitempty"` @@ -100,28 +79,15 @@ func (r listFleetMaintainedAppsResponse) Error() error { return r.Err } func listFleetMaintainedAppsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) { req := request.(*listFleetMaintainedAppsRequest) - req.IncludeMetadata = true - apps, meta, err := svc.ListFleetMaintainedApps(ctx, req.TeamID, req.ListOptions) if err != nil { return listFleetMaintainedAppsResponse{Err: err}, nil } - var latest time.Time - for _, app := range apps { - if app.UpdatedAt != nil && !app.UpdatedAt.IsZero() && app.UpdatedAt.After(latest) { - latest = *app.UpdatedAt - } - } - listResp := listFleetMaintainedAppsResponse{ FleetMaintainedApps: apps, - Count: int(meta.TotalResults), //nolint:gosec // dismiss G115 Meta: meta, } - if !latest.IsZero() { - listResp.AppsUpdatedAt = &latest - } return listResp, nil } @@ -135,7 +101,8 @@ func (svc *Service) ListFleetMaintainedApps(ctx context.Context, teamID *uint, o } type getFleetMaintainedAppRequest struct { - AppID uint `url:"app_id"` + AppID uint `url:"app_id"` + TeamID *uint `query:"team_id,optional"` } type getFleetMaintainedAppResponse struct { @@ -148,7 +115,7 @@ 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) + app, err := svc.GetFleetMaintainedApp(ctx, req.AppID, req.TeamID) if err != nil { return getFleetMaintainedAppResponse{Err: err}, nil } @@ -156,7 +123,7 @@ func getFleetMaintainedApp(ctx context.Context, request any, svc fleet.Service) return getFleetMaintainedAppResponse{FleetMaintainedApp: app}, nil } -func (svc *Service) GetFleetMaintainedApp(ctx context.Context, appID uint) (*fleet.MaintainedApp, error) { +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)