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
12 changed files with 243 additions and 205 deletions
+75 -44
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]
}
+74 -78
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)
}