fleet/server/service/queries.go
Nico e5849ee720
Show Manage Automations disabled button with tooltip on Queries page (#39302)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #39303 (child of #25080).

- Added `inherited_query_count` to `ListQueriesResponse` (thought of
adding a brand new endpoint just for counting, but felt like extending
the current one was good enough). In the parent task, [it was
suggested](https://github.com/fleetdm/fleet/issues/25080#issuecomment-3326071574)
to `"Depend on team list entity endpoint's count field / team entity
count endpoint for whether or not to disable the manage automations
button"`, which Rachael approved, so I went for this approach.
- The `ManageQueryAutomationsModal` now fetches its own data with
`merge_inherited = false` (meaning it only fetches non-inherited queries
only). Previously, queries were passed down as props to it, which would
not show the queries available to automate if the first page of queries
were all inherited and the second page contained queries for that team
(the user would have to navigate to the second page for the button to be
enabled).


^ The fact that the modal fetches its own data is similar behavior to
what is currently done in `Policies`. For queries, I noticed that we
would need to add pagination within the `Manage Automations` modal, but
that can be a follow-up.

<img width="2480" height="1309" alt="Screenshot 2026-02-04 at 11 48
42 AM"
src="https://github.com/user-attachments/assets/ebac79a5-a793-4708-9313-d9a697dfd7de"
/>


# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

## Testing

- [x] QA'd all new/changed functionality manually



https://github.com/user-attachments/assets/119f03b9-dde1-4bb9-9fee-6204b1a58879
2026-02-09 15:16:28 -03:00

1065 lines
31 KiB
Go

package service
import (
"context"
"fmt"
"slices"
"strings"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log/level"
)
////////////////////////////////////////////////////////////////////////////////
// Get Query
////////////////////////////////////////////////////////////////////////////////
type getQueryRequest struct {
ID uint `url:"id"`
}
type getQueryResponse struct {
Query *fleet.Query `json:"query,omitempty"`
Err error `json:"error,omitempty"`
}
func (r getQueryResponse) Error() error { return r.Err }
func getQueryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getQueryRequest)
query, err := svc.GetQuery(ctx, req.ID)
if err != nil {
return getQueryResponse{Err: err}, nil
}
return getQueryResponse{query, nil}, nil
}
func (svc *Service) GetQuery(ctx context.Context, id uint) (*fleet.Query, error) {
// Load query first to get its teamID.
query, err := svc.ds.Query(ctx, id)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, ctxerr.Wrap(ctx, err, "get query from datastore")
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil {
return nil, err
}
return query, nil
}
////////////////////////////////////////////////////////////////////////////////
// List Queries
////////////////////////////////////////////////////////////////////////////////
type listQueriesRequest struct {
ListOptions fleet.ListOptions `url:"list_options"`
// TeamID url argument set to 0 means global.
TeamID uint `query:"team_id,optional"`
MergeInherited bool `query:"merge_inherited,optional"`
// only return queries targeted to run on this platform
Platform string `query:"platform,optional"`
}
type listQueriesResponse struct {
Queries []fleet.Query `json:"queries"`
Count int `json:"count"`
InheritedQueryCount int `json:"inherited_query_count"`
Meta *fleet.PaginationMetadata `json:"meta"`
Err error `json:"error,omitempty"`
}
func (r listQueriesResponse) Error() error { return r.Err }
func listQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listQueriesRequest)
var teamID *uint
if req.TeamID != 0 {
teamID = &req.TeamID
}
var urlPlatform *string
if req.Platform != "" {
urlPlatform = &req.Platform
}
queries, count, inheritedCount, meta, err := svc.ListQueries(ctx, req.ListOptions, teamID, nil, req.MergeInherited, urlPlatform)
if err != nil {
return listQueriesResponse{Err: err}, nil
}
respQueries := make([]fleet.Query, 0, len(queries))
for _, query := range queries {
respQueries = append(respQueries, *query)
}
return listQueriesResponse{
Queries: respQueries,
Count: count,
InheritedQueryCount: inheritedCount,
Meta: meta,
}, nil
}
func (svc *Service) ListQueries(ctx context.Context, opt fleet.ListOptions, teamID *uint, scheduled *bool, mergeInherited bool, urlPlatform *string) ([]*fleet.Query, int, int, *fleet.PaginationMetadata, error) {
// Check the user is allowed to list queries on the given team.
if err := svc.authz.Authorize(ctx, &fleet.Query{
TeamID: teamID,
}, fleet.ActionRead); err != nil {
return nil, 0, 0, nil, err
}
// always include metadata for queries
opt.IncludeMetadata = true
var dbPlatform *string
if urlPlatform != nil {
// validate platform filter
if *urlPlatform == "macos" {
// More user-friendly API param "macos" is called "darwin" in the datastore
dbPlatform = ptr.String("darwin")
} else {
dbPlatform = urlPlatform
}
if strings.Contains(*urlPlatform, ",") {
return nil, 0, 0, nil, &fleet.BadRequestError{Message: "queries can only be filtered by one platform at a time"}
}
targetableDBPlatforms := []string{"darwin", "windows", "linux"}
if !slices.Contains(targetableDBPlatforms, *dbPlatform) {
return nil, 0, 0, nil, &fleet.BadRequestError{Message: fmt.Sprintf("platform %q cannot be a scheduled query target, supported platforms are: %s", *dbPlatform, strings.Join(targetableDBPlatforms, ","))}
}
}
queries, count, inheritedCount, meta, err := svc.ds.ListQueries(ctx, fleet.ListQueryOptions{
ListOptions: opt,
TeamID: teamID,
IsScheduled: scheduled,
MergeInherited: mergeInherited,
Platform: dbPlatform,
})
if err != nil {
return nil, 0, 0, nil, err
}
return queries, count, inheritedCount, meta, nil
}
////////////////////////////////////////////////////////////////////////////////
// Query Reports
////////////////////////////////////////////////////////////////////////////////
type getQueryReportRequest struct {
ID uint `url:"id"`
TeamID *uint `query:"team_id,optional"`
}
type getQueryReportResponse struct {
QueryID uint `json:"query_id"`
Results []fleet.HostQueryResultRow `json:"results"`
ReportClipped bool `json:"report_clipped"`
Err error `json:"error,omitempty"`
}
func (r getQueryReportResponse) Error() error { return r.Err }
func getQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getQueryReportRequest)
queryReportResults, reportClipped, err := svc.GetQueryReportResults(ctx, req.ID, req.TeamID)
if err != nil {
return listQueriesResponse{Err: err}, nil
}
// Return an empty array if there are no results stored.
results := []fleet.HostQueryResultRow{}
if len(queryReportResults) > 0 {
results = queryReportResults
}
return getQueryReportResponse{
QueryID: req.ID,
Results: results,
ReportClipped: reportClipped,
}, nil
}
func (svc *Service) GetQueryReportResults(ctx context.Context, id uint, teamID *uint) ([]fleet.HostQueryResultRow, bool, error) {
// Load query first to get its teamID.
query, err := svc.ds.Query(ctx, id)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, false, ctxerr.Wrap(ctx, err, "get query from datastore")
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil {
return nil, false, err
}
if query.DiscardData {
return nil, false, nil
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, false, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID}
queryReportResultRows, err := svc.ds.QueryResultRows(ctx, id, filter)
if err != nil {
return nil, false, ctxerr.Wrap(ctx, err, "get query report results")
}
queryReportResults, err := fleet.MapQueryReportResultsToRows(queryReportResultRows)
if err != nil {
return nil, false, ctxerr.Wrap(ctx, err, "map db rows to results")
}
appConfig, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, false, ctxerr.Wrap(ctx, err, "get app config")
}
reportClipped, err := svc.QueryReportIsClipped(ctx, id, appConfig.ServerSettings.GetQueryReportCap())
if err != nil {
return nil, false, ctxerr.Wrap(ctx, err, "check query report is clipped")
}
return queryReportResults, reportClipped, nil
}
func (svc *Service) QueryReportIsClipped(ctx context.Context, queryID uint, maxQueryReportRows int) (bool, error) {
query, err := svc.ds.Query(ctx, queryID)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return false, ctxerr.Wrap(ctx, err, "get query from datastore")
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil {
return false, err
}
count, err := svc.ds.ResultCountForQuery(ctx, queryID)
if err != nil {
return false, err
}
return count >= maxQueryReportRows, nil
}
////////////////////////////////////////////////////////////////////////////////
// Create Query
////////////////////////////////////////////////////////////////////////////////
type createQueryRequest struct {
fleet.QueryPayload
}
type createQueryResponse struct {
Query *fleet.Query `json:"query,omitempty"`
Err error `json:"error,omitempty"`
}
func (r createQueryResponse) Error() error { return r.Err }
func createQueryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*createQueryRequest)
query, err := svc.NewQuery(ctx, req.QueryPayload)
if err != nil {
return createQueryResponse{Err: err}, nil
}
return createQueryResponse{query, nil}, nil
}
func (svc *Service) NewQuery(ctx context.Context, p fleet.QueryPayload) (*fleet.Query, error) {
// Check the user is allowed to create a new query on the team.
if err := svc.authz.Authorize(ctx, fleet.Query{
TeamID: p.TeamID,
}, fleet.ActionWrite); err != nil {
return nil, err
}
if p.Logging == nil || (p.Logging != nil && *p.Logging == "") {
p.Logging = ptr.String(fleet.LoggingSnapshot)
}
if err := p.Verify(); err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("query payload verification: %s", err),
})
}
query := &fleet.Query{Saved: true, TeamID: p.TeamID}
vc, ok := viewer.FromContext(ctx)
if ok {
query.AuthorID = ptr.Uint(vc.UserID())
query.AuthorName = vc.FullName()
query.AuthorEmail = vc.Email()
}
if err := verifyLabelsToAssociate(ctx, svc.ds, p.TeamID, p.LabelsIncludeAny, vc.User); err != nil {
return nil, ctxerr.Wrap(ctx, err, "verify labels to associate")
}
if p.Name != nil {
query.Name = *p.Name
}
if p.Description != nil {
query.Description = *p.Description
}
if p.Query != nil {
query.Query = *p.Query
}
if p.Interval != nil {
query.Interval = *p.Interval
}
if p.Platform != nil {
query.Platform = *p.Platform
}
if p.MinOsqueryVersion != nil {
query.MinOsqueryVersion = *p.MinOsqueryVersion
}
if p.AutomationsEnabled != nil {
query.AutomationsEnabled = *p.AutomationsEnabled
}
if p.Logging != nil {
query.Logging = *p.Logging
}
if p.ObserverCanRun != nil {
query.ObserverCanRun = *p.ObserverCanRun
}
if p.DiscardData != nil {
query.DiscardData = *p.DiscardData
}
if len(p.LabelsIncludeAny) > 0 {
labelIdents := make([]fleet.LabelIdent, 0, len(p.LabelsIncludeAny))
for _, label := range p.LabelsIncludeAny {
labelIdents = append(labelIdents, fleet.LabelIdent{LabelName: label})
}
query.LabelsIncludeAny = labelIdents
}
logging.WithExtras(ctx, "name", query.Name, "sql", query.Query)
query, err := svc.ds.NewQuery(ctx, query)
if err != nil {
return nil, err
}
var teamID int64
var teamName *string
if query.TeamID != nil {
teamID = int64(*query.TeamID) //nolint:gosec // dismiss G115
if svc.EnterpriseOverrides != nil && svc.EnterpriseOverrides.TeamByIDOrName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, query.TeamID, nil)
if err != nil {
return nil, err
}
teamName = &team.Name
}
} else {
teamID = -1 // Use -1 for global queries
teamName = nil
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeCreatedSavedQuery{
ID: query.ID,
Name: query.Name,
TeamID: teamID, // log teamID if available, else -1 for global
TeamName: teamName,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for query creation")
}
return query, nil
}
////////////////////////////////////////////////////////////////////////////////
// Modify Query
////////////////////////////////////////////////////////////////////////////////
type modifyQueryRequest struct {
ID uint `json:"-" url:"id"`
fleet.QueryPayload
}
type modifyQueryResponse struct {
Query *fleet.Query `json:"query,omitempty"`
Err error `json:"error,omitempty"`
}
func (r modifyQueryResponse) Error() error { return r.Err }
func modifyQueryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*modifyQueryRequest)
query, err := svc.ModifyQuery(ctx, req.ID, req.QueryPayload)
if err != nil {
return modifyQueryResponse{Err: err}, nil
}
return modifyQueryResponse{query, nil}, nil
}
func (svc *Service) ModifyQuery(ctx context.Context, id uint, p fleet.QueryPayload) (*fleet.Query, error) {
// Load query first to determine if the user can modify it.
query, err := svc.ds.Query(ctx, id)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, err
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionWrite); err != nil {
return nil, err
}
if p.Logging != nil && *p.Logging == "" {
p.Logging = ptr.String(fleet.LoggingSnapshot)
}
if err := p.Verify(); err != nil {
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("query payload verification: %s", err),
})
}
// We use query.TeamID because we do not allow changing the team
if err := verifyLabelsToAssociate(ctx, svc.ds, query.TeamID, p.LabelsIncludeAny, authz.UserFromContext(ctx)); err != nil {
return nil, ctxerr.Wrap(ctx, err, "verify labels to associate")
}
shouldDiscardQueryResults, shouldDeleteStats := false, false
if p.Name != nil {
query.Name = *p.Name
}
if p.Description != nil {
query.Description = *p.Description
}
if p.Query != nil {
if query.Query != *p.Query {
shouldDiscardQueryResults = true
shouldDeleteStats = true
}
query.Query = *p.Query
}
if p.Interval != nil {
query.Interval = *p.Interval
}
if p.Platform != nil {
if !comparePlatforms(query.Platform, *p.Platform) {
shouldDiscardQueryResults = true
}
query.Platform = *p.Platform
}
if p.MinOsqueryVersion != nil {
if query.MinOsqueryVersion != *p.MinOsqueryVersion {
shouldDiscardQueryResults = true
}
query.MinOsqueryVersion = *p.MinOsqueryVersion
}
if p.AutomationsEnabled != nil {
query.AutomationsEnabled = *p.AutomationsEnabled
}
if p.Logging != nil {
if query.Logging != *p.Logging && *p.Logging != fleet.LoggingSnapshot {
shouldDiscardQueryResults = true
}
query.Logging = *p.Logging
}
if p.ObserverCanRun != nil {
query.ObserverCanRun = *p.ObserverCanRun
}
if p.DiscardData != nil {
if *p.DiscardData && *p.DiscardData != query.DiscardData {
shouldDiscardQueryResults = true
}
query.DiscardData = *p.DiscardData
}
if p.LabelsIncludeAny != nil {
// Users submitting an empty array of labels will still
// initiate LabelsIncludeAny. It will only be nil if it was
// not included in the request (not modified)
labelIdents := make([]fleet.LabelIdent, 0, len(p.LabelsIncludeAny))
for _, label := range p.LabelsIncludeAny {
labelIdents = append(labelIdents, fleet.LabelIdent{LabelName: label})
}
query.LabelsIncludeAny = labelIdents
}
logging.WithExtras(ctx, "name", query.Name, "sql", query.Query)
if err := svc.ds.SaveQuery(ctx, query, shouldDiscardQueryResults, shouldDeleteStats); err != nil {
return nil, err
}
// If the query was modified in a way that requires discarding results,
// reset the Redis count as well.
if shouldDiscardQueryResults && svc.liveQueryStore != nil {
err = svc.liveQueryStore.SetQueryResultsCount(query.ID, 0)
if err != nil {
// Log the error but don't fail the request; this will get cleaned up
// in the "query_results_cleanup" job.
level.Error(svc.logger).Log("msg", "failed to set query results count", "err", err, "query_id", query.ID)
}
}
var teamID int64
var teamName *string
if query.TeamID != nil {
teamID = int64(*query.TeamID) //nolint:gosec // dismiss G115
if svc.EnterpriseOverrides != nil && svc.EnterpriseOverrides.TeamByIDOrName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, query.TeamID, nil)
if err != nil {
return nil, err
}
teamName = &team.Name
}
} else {
teamID = -1
teamName = nil
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeEditedSavedQuery{
ID: query.ID,
Name: query.Name,
TeamID: teamID,
TeamName: teamName,
},
); err != nil {
return nil, ctxerr.Wrap(ctx, err, "create activity for query modification")
}
return query, nil
}
func comparePlatforms(platform1, platform2 string) bool {
if platform1 == platform2 {
return true
}
p1s := strings.Split(platform1, ",")
slices.Sort(p1s)
p2s := strings.Split(platform2, ",")
slices.Sort(p2s)
return slices.Compare(p1s, p2s) == 0
}
////////////////////////////////////////////////////////////////////////////////
// Delete Query
////////////////////////////////////////////////////////////////////////////////
type deleteQueryRequest struct {
Name string `url:"name"`
// TeamID if not set is assumed to be 0 (global).
TeamID uint `url:"team_id,optional"`
}
type deleteQueryResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteQueryResponse) Error() error { return r.Err }
func deleteQueryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteQueryRequest)
var teamID *uint
if req.TeamID != 0 {
teamID = &req.TeamID
}
err := svc.DeleteQuery(ctx, teamID, req.Name)
if err != nil {
return deleteQueryResponse{Err: err}, nil
}
return deleteQueryResponse{}, nil
}
func (svc *Service) DeleteQuery(ctx context.Context, teamID *uint, name string) error {
// Load query first to determine if the user can delete it.
query, err := svc.ds.QueryByName(ctx, teamID, name)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return err
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionWrite); err != nil {
return err
}
if err := svc.ds.DeleteQuery(ctx, teamID, name); err != nil {
return err
}
// Delete the Redis counter for query results
if svc.liveQueryStore != nil {
if err = svc.liveQueryStore.DeleteQueryResultsCount(query.ID); err != nil {
// Log the error but don't fail the request; this will get cleaned up
// in the "query_results_cleanup" job.
level.Error(svc.logger).Log("msg", "failed to delete query results count", "err", err, "query_id", query.ID)
}
}
var logTeamID int64
var teamName *string
if query.TeamID != nil {
logTeamID = int64(*query.TeamID) //nolint:gosec // dismiss G115
if svc.EnterpriseOverrides != nil && svc.EnterpriseOverrides.TeamByIDOrName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, query.TeamID, nil)
if err != nil {
return err
}
teamName = &team.Name
}
} else {
logTeamID = -1
teamName = nil
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDeletedSavedQuery{
Name: name,
TeamID: logTeamID,
TeamName: teamName,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for query deletion")
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Delete Query By ID
////////////////////////////////////////////////////////////////////////////////
type deleteQueryByIDRequest struct {
ID uint `url:"id"`
}
type deleteQueryByIDResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteQueryByIDResponse) Error() error { return r.Err }
func deleteQueryByIDEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteQueryByIDRequest)
err := svc.DeleteQueryByID(ctx, req.ID)
if err != nil {
return deleteQueryByIDResponse{Err: err}, nil
}
return deleteQueryByIDResponse{}, nil
}
func (svc *Service) DeleteQueryByID(ctx context.Context, id uint) error {
// Load query first to determine if the user can delete it.
query, err := svc.ds.Query(ctx, id)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return ctxerr.Wrap(ctx, err, "lookup query by ID")
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionWrite); err != nil {
return err
}
if err := svc.ds.DeleteQuery(ctx, query.TeamID, query.Name); err != nil {
return ctxerr.Wrap(ctx, err, "delete query")
}
// Delete the Redis counter for query results
if svc.liveQueryStore != nil {
if err = svc.liveQueryStore.DeleteQueryResultsCount(query.ID); err != nil {
// Log the error but don't fail the request; this will get cleaned up
// in the "query_results_cleanup" job.
level.Error(svc.logger).Log("msg", "failed to delete query results count", "err", err, "query_id", query.ID)
}
}
var logTeamID int64
var teamName *string
if query.TeamID != nil {
logTeamID = int64(*query.TeamID) //nolint:gosec // dismiss G115
if svc.EnterpriseOverrides != nil && svc.EnterpriseOverrides.TeamByIDOrName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, query.TeamID, nil)
if err != nil {
return err
}
teamName = &team.Name
}
} else {
logTeamID = -1
teamName = nil
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDeletedSavedQuery{
Name: query.Name,
TeamID: logTeamID,
TeamName: teamName,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for query deletion by id")
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Delete Queries
////////////////////////////////////////////////////////////////////////////////
type deleteQueriesRequest struct {
IDs []uint `json:"ids"`
}
type deleteQueriesResponse struct {
Deleted uint `json:"deleted"`
Err error `json:"error,omitempty"`
}
func (r deleteQueriesResponse) Error() error { return r.Err }
func deleteQueriesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteQueriesRequest)
deleted, err := svc.DeleteQueries(ctx, req.IDs)
if err != nil {
return deleteQueriesResponse{Err: err}, nil
}
return deleteQueriesResponse{Deleted: deleted}, nil
}
func (svc *Service) DeleteQueries(ctx context.Context, ids []uint) (uint, error) {
// Verify that the user is allowed to delete all the requested queries.
var logTeamID int64 = -1
var teamName *string
for _, id := range ids {
query, err := svc.ds.Query(ctx, id)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return 0, ctxerr.Wrap(ctx, err, "lookup query by ID")
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionWrite); err != nil {
return 0, err
}
// Capture team information for activity logging.
if query.TeamID != nil {
logTeamID = int64(*query.TeamID) //nolint:gosec // dismiss G115
if svc.EnterpriseOverrides != nil && svc.EnterpriseOverrides.TeamByIDOrName != nil {
team, err := svc.EnterpriseOverrides.TeamByIDOrName(ctx, query.TeamID, nil)
if err != nil {
return 0, err
}
teamName = &team.Name
}
}
}
n, err := svc.ds.DeleteQueries(ctx, ids)
if err != nil {
return n, err
}
// Delete the Redis counters for query results
if svc.liveQueryStore != nil {
for _, id := range ids {
if err = svc.liveQueryStore.DeleteQueryResultsCount(id); err != nil {
// Log the error but don't fail the request; this will get cleaned up
// in the "query_results_cleanup" job.
level.Error(svc.logger).Log("msg", "failed to delete query results count", "err", err, "query_id", id)
}
}
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeDeletedMultipleSavedQuery{
IDs: ids,
Teamid: logTeamID,
TeamName: teamName,
},
); err != nil {
return 0, ctxerr.Wrap(ctx, err, "create activity for query deletions")
}
return n, nil
}
////////////////////////////////////////////////////////////////////////////////
// Apply Query Specs
////////////////////////////////////////////////////////////////////////////////
type applyQuerySpecsRequest struct {
Specs []*fleet.QuerySpec `json:"specs"`
}
type applyQuerySpecsResponse struct {
Err error `json:"error,omitempty"`
}
func (r applyQuerySpecsResponse) Error() error { return r.Err }
func applyQuerySpecsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*applyQuerySpecsRequest)
err := svc.ApplyQuerySpecs(ctx, req.Specs)
if err != nil {
return applyQuerySpecsResponse{Err: err}, nil
}
return applyQuerySpecsResponse{}, nil
}
func (svc *Service) ApplyQuerySpecs(ctx context.Context, specs []*fleet.QuerySpec) error {
// 1. Turn specs into queries.
queries := []*fleet.Query{}
for _, spec := range specs {
query, err := svc.queryFromSpec(ctx, spec)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return ctxerr.Wrap(ctx, err, "creating query from spec")
}
queries = append(queries, query)
}
// 2. Run authorization checks and verify their fields.
for _, query := range queries {
if err := svc.authz.Authorize(ctx, query, fleet.ActionWrite); err != nil {
return err
}
if err := query.Verify(); err != nil {
return ctxerr.Wrap(ctx, &fleet.BadRequestError{
Message: fmt.Sprintf("query payload verification: %s", err),
})
}
}
// 3. Apply the queries.
// first, find out if we should delete query results
queriesToDiscardResults := make(map[uint]struct{})
for _, query := range queries {
dbQuery, err := svc.ds.QueryByName(ctx, query.TeamID, query.Name)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "fetching saved query")
}
if dbQuery == nil {
// then we're creating a new query, so move on.
continue
}
if (query.DiscardData && query.DiscardData != dbQuery.DiscardData) ||
(query.Logging != dbQuery.Logging && query.Logging != fleet.LoggingSnapshot) ||
query.Query != dbQuery.Query ||
query.MinOsqueryVersion != dbQuery.MinOsqueryVersion ||
!comparePlatforms(query.Platform, dbQuery.Platform) {
queriesToDiscardResults[dbQuery.ID] = struct{}{}
}
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return ctxerr.New(ctx, "user must be authenticated to apply queries")
}
err := svc.ds.ApplyQueries(ctx, vc.UserID(), queries, queriesToDiscardResults)
if err != nil {
return ctxerr.Wrap(ctx, err, "applying queries")
}
// Reset the Redis counters for queries whose results were discarded
if svc.liveQueryStore != nil {
for queryID := range queriesToDiscardResults {
if err = svc.liveQueryStore.SetQueryResultsCount(queryID, 0); err != nil {
// Log the error but don't fail the request; this will get cleaned up
// in the "query_results_cleanup" job.
level.Error(svc.logger).Log("msg", "failed to set query results count", "err", err, "query_id", queryID)
}
}
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeAppliedSpecSavedQuery{
Specs: specs,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create activity for query spec")
}
return nil
}
func (svc *Service) queryFromSpec(ctx context.Context, spec *fleet.QuerySpec) (*fleet.Query, error) {
var teamID *uint
if spec.TeamName != "" {
team, err := svc.ds.TeamByName(ctx, spec.TeamName)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get team by name")
}
teamID = &team.ID
}
logging := spec.Logging
if logging == "" {
logging = fleet.LoggingSnapshot
}
// Find labels by name
var queryLabels []fleet.LabelIdent
if len(spec.LabelsIncludeAny) > 0 {
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
labelsMap, err := svc.ds.LabelsByName(ctx, spec.LabelsIncludeAny, fleet.TeamFilter{User: vc.User, TeamID: teamID})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get labels by name")
}
for labelName := range labelsMap {
queryLabels = append(queryLabels, fleet.LabelIdent{LabelName: labelName, LabelID: labelsMap[labelName].ID})
}
// Make sure that all labels were found
for _, label := range spec.LabelsIncludeAny {
if _, ok := labelsMap[label]; !ok {
return nil, ctxerr.New(ctx, "label not found")
}
}
}
return &fleet.Query{
Name: spec.Name,
Description: spec.Description,
Query: spec.Query,
TeamID: teamID,
Interval: spec.Interval,
ObserverCanRun: spec.ObserverCanRun,
Platform: spec.Platform,
MinOsqueryVersion: spec.MinOsqueryVersion,
AutomationsEnabled: spec.AutomationsEnabled,
Logging: logging,
DiscardData: spec.DiscardData,
LabelsIncludeAny: queryLabels,
}, nil
}
////////////////////////////////////////////////////////////////////////////////
// Get Query Specs
////////////////////////////////////////////////////////////////////////////////
type getQuerySpecsResponse struct {
Specs []*fleet.QuerySpec `json:"specs"`
Err error `json:"error,omitempty"`
}
type getQuerySpecsRequest struct {
TeamID uint `url:"team_id,optional"`
}
func (r getQuerySpecsResponse) Error() error { return r.Err }
func getQuerySpecsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getQuerySpecsRequest)
var teamID *uint
if req.TeamID != 0 {
teamID = &req.TeamID
}
specs, err := svc.GetQuerySpecs(ctx, teamID)
if err != nil {
return getQuerySpecsResponse{Err: err}, nil
}
return getQuerySpecsResponse{Specs: specs}, nil
}
func (svc *Service) GetQuerySpecs(ctx context.Context, teamID *uint) ([]*fleet.QuerySpec, error) {
queries, _, _, _, err := svc.ListQueries(ctx, fleet.ListOptions{}, teamID, nil, false, nil)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting queries")
}
// Turn queries into specs.
var specs []*fleet.QuerySpec
for _, query := range queries {
spec, err := svc.specFromQuery(ctx, query)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create spec from query")
}
specs = append(specs, spec)
}
return specs, nil
}
func (svc *Service) specFromQuery(ctx context.Context, query *fleet.Query) (*fleet.QuerySpec, error) {
var teamName string
if query.TeamID != nil {
team, err := svc.ds.TeamLite(ctx, *query.TeamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get team from id")
}
teamName = team.Name
}
labelsAny := []string{}
for _, label := range query.LabelsIncludeAny {
labelsAny = append(labelsAny, label.LabelName)
}
return &fleet.QuerySpec{
Name: query.Name,
Description: query.Description,
Query: query.Query,
TeamName: teamName,
Interval: query.Interval,
ObserverCanRun: query.ObserverCanRun,
Platform: query.Platform,
MinOsqueryVersion: query.MinOsqueryVersion,
AutomationsEnabled: query.AutomationsEnabled,
Logging: query.Logging,
DiscardData: query.DiscardData,
LabelsIncludeAny: labelsAny,
}, nil
}
////////////////////////////////////////////////////////////////////////////////
// Get Query Spec
////////////////////////////////////////////////////////////////////////////////
type getQuerySpecResponse struct {
Spec *fleet.QuerySpec `json:"specs,omitempty"`
Err error `json:"error,omitempty"`
}
type getQuerySpecRequest struct {
Name string `url:"name"`
TeamID uint `query:"team_id,optional"`
}
func (r getQuerySpecResponse) Error() error { return r.Err }
func getQuerySpecEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getQuerySpecRequest)
var teamID *uint
if req.TeamID != 0 {
teamID = &req.TeamID
}
spec, err := svc.GetQuerySpec(ctx, teamID, req.Name)
if err != nil {
return getQuerySpecResponse{Err: err}, nil
}
return getQuerySpecResponse{Spec: spec}, nil
}
func (svc *Service) GetQuerySpec(ctx context.Context, teamID *uint, name string) (*fleet.QuerySpec, error) {
// Check the user is allowed to get the query on the requested team.
if err := svc.authz.Authorize(ctx, &fleet.Query{
TeamID: teamID,
}, fleet.ActionRead); err != nil {
return nil, err
}
query, err := svc.ds.QueryByName(ctx, teamID, name)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get query by name")
}
spec, err := svc.specFromQuery(ctx, query)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create spec from query")
}
return spec, nil
}