diff --git a/changes/15009-queries-observer b/changes/15009-queries-observer new file mode 100644 index 0000000000..d92ecc41c5 --- /dev/null +++ b/changes/15009-queries-observer @@ -0,0 +1 @@ +- Fixes bug where Global Observers were not able to list all queries through the API. \ No newline at end of file diff --git a/cmd/fleetctl/get_test.go b/cmd/fleetctl/get_test.go index 5f300cbcb4..ac7eadd9f8 100644 --- a/cmd/fleetctl/get_test.go +++ b/cmd/fleetctl/get_test.go @@ -774,9 +774,9 @@ func TestGetSoftwareVersions(t *testing.T) { var gotTeamID *uint - ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) { + ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { gotTeamID = opt.TeamID - return []fleet.Software{foo001, foo002, foo003, bar003}, nil + return []fleet.Software{foo001, foo002, foo003, bar003}, &fleet.PaginationMetadata{}, nil } ds.CountSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) { diff --git a/ee/server/service/software.go b/ee/server/service/software.go index 469fbfd27d..0e63e02bce 100644 --- a/ee/server/service/software.go +++ b/ee/server/service/software.go @@ -6,7 +6,7 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) -func (svc *Service) ListSoftware(ctx context.Context, opts fleet.SoftwareListOptions) ([]fleet.Software, error) { +func (svc *Service) ListSoftware(ctx context.Context, opts fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { // reuse ListSoftware, but include cve scores in premium version opts.IncludeCVEScores = true return svc.Service.ListSoftware(ctx, opts) diff --git a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx index c7318ab266..80f6caabb5 100644 --- a/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx +++ b/frontend/pages/queries/ManageQueriesPage/ManageQueriesPage.tsx @@ -90,6 +90,7 @@ const ManageQueriesPage = ({ filteredQueriesPath, isPremiumTier, isSandboxMode, + isGlobalObserver, config, } = useContext(AppContext); const { setLastEditedQueryBody, setSelectedQueryTargetsByType } = useContext( @@ -137,6 +138,12 @@ const ManageQueriesPage = ({ [{ scope: "queries", teamId: teamIdForApi }], ({ queryKey: [{ teamId }] }) => queriesAPI.loadAll(teamId).then(({ queries }) => { + if (isGlobalObserver) { + return queries + .filter((q: ISchedulableQuery) => q.observer_can_run) + .map(enhanceQuery); + } + return queries.map(enhanceQuery); }), { diff --git a/server/datastore/mysql/mysql.go b/server/datastore/mysql/mysql.go index 3769bee35e..956c6bcea6 100644 --- a/server/datastore/mysql/mysql.go +++ b/server/datastore/mysql/mysql.go @@ -787,12 +787,18 @@ func appendLimitOffsetToSelect(ds *goqu.SelectDataset, opts fleet.ListOptions) * if perPage == 0 { perPage = defaultSelectLimit } - ds = ds.Limit(perPage) offset := perPage * opts.Page if offset > 0 { ds = ds.Offset(offset) } + + if opts.IncludeMetadata { + perPage++ + } + + ds = ds.Limit(perPage) + return ds } diff --git a/server/datastore/mysql/software.go b/server/datastore/mysql/software.go index 0e7cea5af0..f9a99427c4 100644 --- a/server/datastore/mysql/software.go +++ b/server/datastore/mysql/software.go @@ -1036,8 +1036,26 @@ func (ds *Datastore) ListSoftwareCPEs(ctx context.Context) ([]fleet.SoftwareCPE, return result, nil } -func (ds *Datastore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) { - return listSoftwareDB(ctx, ds.reader(ctx), opt) +func (ds *Datastore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { + software, err := listSoftwareDB(ctx, ds.reader(ctx), opt) + if err != nil { + return nil, nil, err + } + + perPage := opt.ListOptions.PerPage + var metaData *fleet.PaginationMetadata + if opt.ListOptions.IncludeMetadata { + if perPage <= 0 { + perPage = defaultSelectLimit + } + metaData = &fleet.PaginationMetadata{HasPreviousResults: opt.ListOptions.Page > 0} + if len(software) > int(perPage) { + metaData.HasNextResults = true + software = software[:len(software)-1] + } + } + + return software, metaData, nil } func (ds *Datastore) CountSoftware(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) { diff --git a/server/datastore/mysql/software_test.go b/server/datastore/mysql/software_test.go index 4c5e3f2c31..80256c70b5 100644 --- a/server/datastore/mysql/software_test.go +++ b/server/datastore/mysql/software_test.go @@ -657,9 +657,10 @@ func testSoftwareList(t *testing.T, ds *Datastore) { t.Run("paginates", func(t *testing.T) { opts := fleet.SoftwareListOptions{ ListOptions: fleet.ListOptions{ - Page: 1, - PerPage: 1, - OrderKey: "version", + Page: 1, + PerPage: 1, + OrderKey: "version", + IncludeMetadata: true, }, IncludeCVEScores: true, } @@ -704,9 +705,10 @@ func testSoftwareList(t *testing.T, ds *Datastore) { opts := fleet.SoftwareListOptions{ ListOptions: fleet.ListOptions{ - PerPage: 1, - Page: 1, - OrderKey: "id", + PerPage: 1, + Page: 1, + OrderKey: "id", + IncludeMetadata: true, }, TeamID: &team1.ID, } @@ -882,12 +884,30 @@ func testSoftwareList(t *testing.T, ds *Datastore) { } func listSoftwareCheckCount(t *testing.T, ds *Datastore, expectedListCount int, expectedFullCount int, opts fleet.SoftwareListOptions, returnSorted bool) []fleet.Software { - software, err := ds.ListSoftware(context.Background(), opts) + software, meta, err := ds.ListSoftware(context.Background(), opts) require.NoError(t, err) require.Len(t, software, expectedListCount) count, err := ds.CountSoftware(context.Background(), opts) require.NoError(t, err) require.Equal(t, expectedFullCount, count) + + if opts.ListOptions.IncludeMetadata { + require.NotNil(t, meta) + if expectedListCount == expectedFullCount { + require.False(t, meta.HasPreviousResults) + require.True(t, meta.HasNextResults) + } + if expectedFullCount > expectedListCount { + shouldHavePrevious := opts.ListOptions.Page > 0 + require.Equal(t, shouldHavePrevious, meta.HasPreviousResults) + + shouldHaveNext := uint(expectedFullCount) > (opts.ListOptions.Page+1)*opts.ListOptions.PerPage // page is 0-indexed + require.Equal(t, shouldHaveNext, meta.HasNextResults) + } + } else { + require.Nil(t, meta) + } + for _, s := range software { sort.Slice(s.Vulnerabilities, func(i, j int) bool { return s.Vulnerabilities[i].CVE < s.Vulnerabilities[j].CVE @@ -1372,7 +1392,7 @@ func testHostVulnSummariesBySoftwareIDs(t *testing.T, ds *Datastore) { insertVulnSoftwareForTest(t, ds) - allSoftware, err := ds.ListSoftware(ctx, fleet.SoftwareListOptions{}) + allSoftware, _, err := ds.ListSoftware(ctx, fleet.SoftwareListOptions{}) require.NoError(t, err) var fooRpm fleet.Software diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index 78e7b8cff3..1fa8bedd77 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -559,7 +559,7 @@ type Datastore interface { // MigrationStatus returns nil if migrations are complete, and an error if migrations need to be run. MigrationStatus(ctx context.Context) (*MigrationStatus, error) - ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, error) + ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, *PaginationMetadata, error) CountSoftware(ctx context.Context, opt SoftwareListOptions) (int, error) // DeleteVulnerabilities deletes the given list of vulnerabilities identified by CPE+CVE. DeleteSoftwareVulnerabilities(ctx context.Context, vulnerabilities []SoftwareVulnerability) error diff --git a/server/fleet/service.go b/server/fleet/service.go index ea7eda07e4..8c7d94dd1e 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -570,7 +570,7 @@ type Service interface { // ///////////////////////////////////////////////////////////////////////////// // Software - ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, error) + ListSoftware(ctx context.Context, opt SoftwareListOptions) ([]Software, *PaginationMetadata, error) SoftwareByID(ctx context.Context, id uint, includeCVEScores bool) (*Software, error) CountSoftware(ctx context.Context, opt SoftwareListOptions) (int, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 5d7226708c..1868edf099 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -416,7 +416,7 @@ type MigrateDataFunc func(ctx context.Context) error type MigrationStatusFunc func(ctx context.Context) (*fleet.MigrationStatus, error) -type ListSoftwareFunc func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) +type ListSoftwareFunc func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) type CountSoftwareFunc func(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) @@ -3305,7 +3305,7 @@ func (s *DataStore) MigrationStatus(ctx context.Context) (*fleet.MigrationStatus return s.MigrationStatusFunc(ctx) } -func (s *DataStore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) { +func (s *DataStore) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { s.mu.Lock() s.ListSoftwareFuncInvoked = true s.mu.Unlock() diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 58c3b3a68e..66441c6a4d 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -5757,6 +5757,8 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { versResp = listSoftwareVersionsResponse{} s.DoJSON("GET", "/api/latest/fleet/software/versions", nil, http.StatusOK, &versResp, "per_page", "5", "page", "0", "order_key", "hosts_count", "order_direction", "desc") assertVersionsResp(versResp, []fleet.Software{sws[19], sws[18], sws[17], sws[16], sws[15]}, hostsCountTs, len(sws), 20, 19, 18, 17, 16) + require.False(t, versResp.Meta.HasPreviousResults) + require.True(t, versResp.Meta.HasNextResults) // second page (page=1) lsResp = listSoftwareResponse{} @@ -5765,6 +5767,8 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { versResp = listSoftwareVersionsResponse{} s.DoJSON("GET", "/api/latest/fleet/software/versions", nil, http.StatusOK, &versResp, "per_page", "5", "page", "1", "order_key", "hosts_count", "order_direction", "desc") assertVersionsResp(versResp, []fleet.Software{sws[14], sws[13], sws[12], sws[11], sws[10]}, hostsCountTs, len(sws), 15, 14, 13, 12, 11) + require.True(t, versResp.Meta.HasPreviousResults) + require.True(t, versResp.Meta.HasNextResults) // third page (page=2) lsResp = listSoftwareResponse{} @@ -5773,6 +5777,8 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { versResp = listSoftwareVersionsResponse{} s.DoJSON("GET", "/api/latest/fleet/software/versions", nil, http.StatusOK, &versResp, "per_page", "5", "page", "2", "order_key", "hosts_count", "order_direction", "desc") assertVersionsResp(versResp, []fleet.Software{sws[9], sws[8], sws[7], sws[6], sws[5]}, hostsCountTs, len(sws), 10, 9, 8, 7, 6) + require.True(t, versResp.Meta.HasPreviousResults) + require.True(t, versResp.Meta.HasNextResults) // last page (page=3) lsResp = listSoftwareResponse{} @@ -5781,6 +5787,8 @@ func (s *integrationTestSuite) TestListSoftwareAndSoftwareDetails() { versResp = listSoftwareVersionsResponse{} s.DoJSON("GET", "/api/latest/fleet/software/versions", nil, http.StatusOK, &versResp, "per_page", "5", "page", "3", "order_key", "hosts_count", "order_direction", "desc") assertVersionsResp(versResp, []fleet.Software{sws[4], sws[3], sws[2], sws[1], sws[0]}, hostsCountTs, len(sws), 5, 4, 3, 2, 1) + require.True(t, versResp.Meta.HasPreviousResults) + require.False(t, versResp.Meta.HasNextResults) // past the end lsResp = listSoftwareResponse{} diff --git a/server/service/queries.go b/server/service/queries.go index 5e32d2fc14..f59c4d46d2 100644 --- a/server/service/queries.go +++ b/server/service/queries.go @@ -114,7 +114,10 @@ func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, team func onlyShowObserverCanRunQueries(user *fleet.User, teamID *uint) bool { if user.GlobalRole != nil && *user.GlobalRole == fleet.RoleObserver { - return true + // Return false here because Global Observers should be able to access all queries via API. + // However, the UI will only show queries that have "observer can run" set to true. + // See the user permissions matrix: https://fleetdm.com/docs/using-fleet/manage-access#user-permissions + return false } return teamID != nil && user.TeamMembership(func(ut fleet.UserTeam) bool { diff --git a/server/service/queries_test.go b/server/service/queries_test.go index b0786e7a1e..e7580c57f1 100644 --- a/server/service/queries_test.go +++ b/server/service/queries_test.go @@ -14,7 +14,7 @@ import ( func TestFilterQueriesForObserver(t *testing.T) { t.Run("global role", func(t *testing.T) { - require.True(t, onlyShowObserverCanRunQueries(&fleet.User{ + require.False(t, onlyShowObserverCanRunQueries(&fleet.User{ GlobalRole: ptr.String(fleet.RoleObserver), }, nil)) @@ -89,7 +89,7 @@ func TestListQueries(t *testing.T) { { title: "global observer", user: &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}, - expectedOpts: fleet.ListQueryOptions{OnlyObserverCanRun: true}, + expectedOpts: fleet.ListQueryOptions{OnlyObserverCanRun: false}, }, { title: "team maintainer", diff --git a/server/service/software.go b/server/service/software.go index 06148dd222..46e1fa51e3 100644 --- a/server/service/software.go +++ b/server/service/software.go @@ -29,7 +29,7 @@ func (r listSoftwareResponse) error() error { return r.Err } // DEPRECATED: use listSoftwareVersionsEndpoint instead func listSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*listSoftwareRequest) - resp, err := svc.ListSoftware(ctx, req.SoftwareListOptions) + resp, _, err := svc.ListSoftware(ctx, req.SoftwareListOptions) if err != nil { return listSoftwareResponse{Err: err}, nil } @@ -50,17 +50,23 @@ func listSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Se } type listSoftwareVersionsResponse struct { - Count int `json:"count"` - CountsUpdatedAt *time.Time `json:"counts_updated_at"` - Software []fleet.Software `json:"software,omitempty"` - Err error `json:"error,omitempty"` + Count int `json:"count"` + CountsUpdatedAt *time.Time `json:"counts_updated_at"` + Software []fleet.Software `json:"software,omitempty"` + Meta *fleet.PaginationMetadata `json:"meta"` + Err error `json:"error,omitempty"` } func (r listSoftwareVersionsResponse) error() error { return r.Err } func listSoftwareVersionsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) { req := request.(*listSoftwareRequest) - resp, err := svc.ListSoftware(ctx, req.SoftwareListOptions) + + // always include pagination for new software versions endpoint (not included by default in + // legacy endpoint for backwards compatibility) + req.SoftwareListOptions.ListOptions.IncludeMetadata = true + + resp, meta, err := svc.ListSoftware(ctx, req.SoftwareListOptions) if err != nil { return listSoftwareVersionsResponse{Err: err}, nil } @@ -72,7 +78,7 @@ func listSoftwareVersionsEndpoint(ctx context.Context, request interface{}, svc latest = sw.CountsUpdatedAt } } - listResp := listSoftwareVersionsResponse{Software: resp} + listResp := listSoftwareVersionsResponse{Software: resp, Meta: meta} if !latest.IsZero() { listResp.CountsUpdatedAt = &latest } @@ -86,11 +92,11 @@ func listSoftwareVersionsEndpoint(ctx context.Context, request interface{}, svc return listResp, nil } -func (svc *Service) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) { +func (svc *Service) ListSoftware(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{ TeamID: opt.TeamID, }, fleet.ActionRead); err != nil { - return nil, err + return nil, nil, err } // default sort order to hosts_count descending @@ -100,12 +106,12 @@ func (svc *Service) ListSoftware(ctx context.Context, opt fleet.SoftwareListOpti } opt.WithHostCounts = true - softwares, err := svc.ds.ListSoftware(ctx, opt) + softwares, meta, err := svc.ds.ListSoftware(ctx, opt) if err != nil { - return nil, err + return nil, nil, err } - return softwares, nil + return softwares, meta, nil } ///////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/software_test.go b/server/service/software_test.go index 49478ffdda..6695267670 100644 --- a/server/service/software_test.go +++ b/server/service/software_test.go @@ -17,10 +17,10 @@ func TestService_ListSoftware(t *testing.T) { var calledWithTeamID *uint var calledWithOpt fleet.SoftwareListOptions - ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) { + ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { calledWithTeamID = opt.TeamID calledWithOpt = opt - return []fleet.Software{}, nil + return []fleet.Software{}, &fleet.PaginationMetadata{}, nil } user := &fleet.User{ @@ -32,7 +32,7 @@ func TestService_ListSoftware(t *testing.T) { svc, ctx := newTestService(t, ds, nil, nil) ctx = viewer.NewContext(ctx, viewer.Viewer{User: user}) - _, err := svc.ListSoftware(ctx, fleet.SoftwareListOptions{TeamID: ptr.Uint(42), ListOptions: fleet.ListOptions{PerPage: 77, Page: 4}}) + _, _, err := svc.ListSoftware(ctx, fleet.SoftwareListOptions{TeamID: ptr.Uint(42), ListOptions: fleet.ListOptions{PerPage: 77, Page: 4}}) require.NoError(t, err) assert.True(t, ds.ListSoftwareFuncInvoked) @@ -43,7 +43,7 @@ func TestService_ListSoftware(t *testing.T) { // call again, this time with an explicit sort ds.ListSoftwareFuncInvoked = false - _, err = svc.ListSoftware(ctx, fleet.SoftwareListOptions{TeamID: nil, ListOptions: fleet.ListOptions{PerPage: 11, Page: 2, OrderKey: "id", OrderDirection: fleet.OrderAscending}}) + _, _, err = svc.ListSoftware(ctx, fleet.SoftwareListOptions{TeamID: nil, ListOptions: fleet.ListOptions{PerPage: 11, Page: 2, OrderKey: "id", OrderDirection: fleet.OrderAscending}}) require.NoError(t, err) assert.True(t, ds.ListSoftwareFuncInvoked) @@ -55,8 +55,8 @@ func TestService_ListSoftware(t *testing.T) { func TestServiceSoftwareInventoryAuth(t *testing.T) { ds := new(mock.Store) - ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, error) { - return []fleet.Software{}, nil + ds.ListSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) ([]fleet.Software, *fleet.PaginationMetadata, error) { + return []fleet.Software{}, &fleet.PaginationMetadata{}, nil } ds.CountSoftwareFunc = func(ctx context.Context, opt fleet.SoftwareListOptions) (int, error) { return 0, nil @@ -173,7 +173,7 @@ func TestServiceSoftwareInventoryAuth(t *testing.T) { ctx := viewer.NewContext(ctx, viewer.Viewer{User: tc.user}) // List all software. - _, err := svc.ListSoftware(ctx, fleet.SoftwareListOptions{}) + _, _, err := svc.ListSoftware(ctx, fleet.SoftwareListOptions{}) checkAuthErr(t, tc.shouldFailGlobalRead, err) // Count all software. @@ -181,7 +181,7 @@ func TestServiceSoftwareInventoryAuth(t *testing.T) { checkAuthErr(t, tc.shouldFailGlobalRead, err) // List software for a team. - _, err = svc.ListSoftware(ctx, fleet.SoftwareListOptions{ + _, _, err = svc.ListSoftware(ctx, fleet.SoftwareListOptions{ TeamID: ptr.Uint(1), }) checkAuthErr(t, tc.shouldFailTeamRead, err)