Revise FMA list endpoint to match Windows FMA spec (#27180)

For #26652. No changes file as that'll come in another PR. Will stack
additional PRs on top of this one (for ingestion changes etc.) to get
this merged more quickly.

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements)
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes
- [x] Added/updated automated tests
- [x] A detailed QA plan exists on the associated ticket (if it isn't
there, work with the product group's QA engineer to add it)
- [x] Manual QA for all new/changed functionality
This commit is contained in:
Ian Littman 2025-03-17 10:09:39 -05:00 committed by GitHub
parent 0c77e61cfa
commit fdff6e16ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 243 additions and 205 deletions

View file

@ -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")
}

View file

@ -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 {

View file

@ -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]
}

View file

@ -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)
}

View file

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

View file

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

View file

@ -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

View file

@ -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,

View file

@ -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) {

View file

@ -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{})

View file

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

View file

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