mirror of
https://github.com/fleetdm/fleet
synced 2026-05-24 09:28:54 +00:00
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** Resolves #36093 This is a follow-up of https://github.com/fleetdm/fleet/pull/40717 # 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] Added/updated automated tests - [x] QA'd all new/changed functionality manually Verified that the manual test cases I described in https://github.com/fleetdm/fleet/pull/40717 still pass. Used the following setup: - 1 host on Servers. - 1 host on Servers (canary). - 9999 hosts on Unassigned. <img width="1292" height="448" alt="Screenshot 2026-03-10 at 9 41 33 PM" src="https://github.com/user-attachments/assets/37ba2ad9-aa7b-4d40-b134-56a943e2635c" /> Users: - Team user with these assignments for test cases 1 and 2. <img width="570" height="269" alt="Screenshot 2026-03-10 at 9 42 41 PM" src="https://github.com/user-attachments/assets/f4bcf180-b7cc-4d80-a727-26ce887cbe84" /> - Global observer user for test cases 3 to 5. ### Test case 1 Report on Workstations (canary) with observers_can_run=true <img width="470" height="538" alt="Screenshot 2026-03-10 at 9 42 30 PM" src="https://github.com/user-attachments/assets/11c02ee9-c6eb-463a-9d4b-168a6155feed" /> Tested that I'm only able to target that host using "All hosts", "macOS" and other labels. Also, searching for specific hosts under "Target specific hosts" only retrieves that host. https://github.com/user-attachments/assets/150d986a-b4f2-49ab-86d9-0308685873eb ### Test case 2 Confirmed that I'm not able to target `perf-host-1` from `Servers (canary)` using a manual label with the same report above. For this, I created a manual label and assigned only to `perf-host-1`: <img width="603" height="349" alt="Screenshot 2026-03-10 at 9 50 52 PM" src="https://github.com/user-attachments/assets/98b4a27a-4e46-466e-a377-622d36903feb" /> Note that 0 hosts are targeted and **Run** is disabled: <img width="950" height="814" alt="Screenshot 2026-03-10 at 9 52 26 PM" src="https://github.com/user-attachments/assets/3b42c0e9-3005-40cc-8733-85b9b729ce89" /> ### Test case 3 Accessed same report in `Workstations (canary)` above with a Global Observer user. Confirmed that no hosts can be targeted in any way: <img width="977" height="649" alt="Screenshot 2026-03-11 at 8 29 26 AM" src="https://github.com/user-attachments/assets/ac87ac7e-3097-4228-a724-1d9324dec504" /> <img width="986" height="746" alt="Screenshot 2026-03-11 at 8 30 06 AM" src="https://github.com/user-attachments/assets/5ca592d2-be8c-43c0-8a27-d18fdee35442" /> <img width="1017" height="812" alt="Screenshot 2026-03-11 at 8 30 12 AM" src="https://github.com/user-attachments/assets/fb92940d-3ab2-4136-9e04-825f2c5eb3fe" /> <img width="998" height="809" alt="Screenshot 2026-03-11 at 8 30 17 AM" src="https://github.com/user-attachments/assets/67cc9c0a-e1aa-49df-ad68-1988d6471d32" /> <img width="1444" height="311" alt="Screenshot 2026-03-11 at 8 30 35 AM" src="https://github.com/user-attachments/assets/4b725bf1-0d6d-4458-840e-a96666a34903" /> <img width="1444" height="303" alt="Screenshot 2026-03-11 at 8 30 42 AM" src="https://github.com/user-attachments/assets/54a9cd65-90f5-4454-a713-334e23118295" /> ### Test case 4 As a global observer, accessing a global report with observers_can_run=true, I can target all the hosts across all teams. <img width="951" height="640" alt="Screenshot 2026-03-11 at 8 34 58 AM" src="https://github.com/user-attachments/assets/3c235b3d-acd5-4801-834f-6fe6cd67d3dd" /> <img width="1448" height="527" alt="Screenshot 2026-03-11 at 8 35 06 AM" src="https://github.com/user-attachments/assets/0f5f663d-8597-4320-aceb-ee6f168ec552" /> <img width="1474" height="179" alt="Screenshot 2026-03-11 at 8 35 14 AM" src="https://github.com/user-attachments/assets/042eda04-e7f6-4c21-9503-878a23435fcd" /> ### Test case 5 With the same report from test case 4, but observers_can_run=false, I can't target any hosts. <img width="971" height="804" alt="Screenshot 2026-03-11 at 8 36 49 AM" src="https://github.com/user-attachments/assets/3a3a9fe3-a159-4ef9-8b08-4c987b9c0828" /> <img width="967" height="813" alt="Screenshot 2026-03-11 at 8 37 00 AM" src="https://github.com/user-attachments/assets/aba5588d-dd96-4b88-9911-ebdd743bfa65" />
235 lines
7.8 KiB
Go
235 lines
7.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create Distributed Query Campaign
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type createDistributedQueryCampaignRequest struct {
|
|
QuerySQL string `json:"query"`
|
|
QueryID *uint `json:"query_id" renameto:"report_id"`
|
|
Selected fleet.HostTargets `json:"selected"`
|
|
}
|
|
|
|
type createDistributedQueryCampaignResponse struct {
|
|
Campaign *fleet.DistributedQueryCampaign `json:"campaign,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r createDistributedQueryCampaignResponse) Error() error { return r.Err }
|
|
|
|
func createDistributedQueryCampaignEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*createDistributedQueryCampaignRequest)
|
|
campaign, err := svc.NewDistributedQueryCampaign(ctx, req.QuerySQL, req.QueryID, req.Selected)
|
|
if err != nil {
|
|
return createDistributedQueryCampaignResponse{Err: err}, nil
|
|
}
|
|
return createDistributedQueryCampaignResponse{Campaign: campaign}, nil
|
|
}
|
|
|
|
func (svc *Service) NewDistributedQueryCampaign(ctx context.Context, queryString string, queryID *uint, targets fleet.HostTargets) (*fleet.DistributedQueryCampaign, error) {
|
|
if err := svc.StatusLiveQuery(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
if queryID == nil && strings.TrimSpace(queryString) == "" {
|
|
return nil, fleet.NewInvalidArgumentError("query", "one of query or query_id must be specified")
|
|
}
|
|
|
|
var query *fleet.Query
|
|
var err error
|
|
if queryID != nil {
|
|
query, err = svc.ds.Query(ctx, *queryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
queryString = query.Query
|
|
} else {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Query{}, fleet.ActionRunNew); err != nil {
|
|
return nil, err
|
|
}
|
|
query = &fleet.Query{
|
|
Name: fmt.Sprintf("distributed_%s_%d", vc.Email(), time.Now().UnixNano()),
|
|
Query: queryString,
|
|
Saved: false,
|
|
AuthorID: ptr.Uint(vc.UserID()),
|
|
// We must set a valid value for this field, even if unused by live queries.
|
|
Logging: fleet.LoggingSnapshot,
|
|
}
|
|
if err := query.Verify(); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf("query payload verification: %s", err),
|
|
})
|
|
}
|
|
query, err = svc.ds.NewQuery(ctx, query)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "new query")
|
|
}
|
|
}
|
|
|
|
tq := &fleet.TargetedQuery{Query: query, HostTargets: targets}
|
|
if err := svc.authz.Authorize(ctx, tq, fleet.ActionRun); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: query.ObserverCanRun, ObserverTeamID: query.TeamID}
|
|
|
|
campaign, err := svc.ds.NewDistributedQueryCampaign(ctx, &fleet.DistributedQueryCampaign{
|
|
QueryID: query.ID,
|
|
Status: fleet.QueryWaiting,
|
|
UserID: vc.UserID(),
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "new campaign")
|
|
}
|
|
|
|
defer func() {
|
|
var numHosts uint
|
|
if campaign != nil {
|
|
numHosts = campaign.Metrics.TotalHosts
|
|
}
|
|
logging.WithExtras(ctx, "sql", queryString, "query_id", queryID, "numHosts", numHosts)
|
|
}()
|
|
|
|
// Add host targets
|
|
for _, hid := range targets.HostIDs {
|
|
_, err = svc.ds.NewDistributedQueryCampaignTarget(ctx, &fleet.DistributedQueryCampaignTarget{
|
|
Type: fleet.TargetHost,
|
|
DistributedQueryCampaignID: campaign.ID,
|
|
TargetID: hid,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "adding host target")
|
|
}
|
|
}
|
|
|
|
// Add label targets
|
|
for _, lid := range targets.LabelIDs {
|
|
_, err = svc.ds.NewDistributedQueryCampaignTarget(ctx, &fleet.DistributedQueryCampaignTarget{
|
|
Type: fleet.TargetLabel,
|
|
DistributedQueryCampaignID: campaign.ID,
|
|
TargetID: lid,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "adding label target")
|
|
}
|
|
}
|
|
|
|
// Add team targets
|
|
for _, tid := range targets.TeamIDs {
|
|
_, err = svc.ds.NewDistributedQueryCampaignTarget(ctx, &fleet.DistributedQueryCampaignTarget{
|
|
Type: fleet.TargetTeam,
|
|
DistributedQueryCampaignID: campaign.ID,
|
|
TargetID: tid,
|
|
})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "adding team target")
|
|
}
|
|
}
|
|
|
|
hostIDs, err := svc.ds.HostIDsInTargets(ctx, filter, targets)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get target IDs")
|
|
}
|
|
|
|
if len(hostIDs) == 0 {
|
|
return nil, &fleet.BadRequestError{
|
|
Message: "no hosts targeted",
|
|
}
|
|
}
|
|
|
|
// Metrics are used for total hosts targeted for the activity feed.
|
|
campaign.Metrics, err = svc.ds.CountHostsInTargets(ctx, filter, targets, time.Now())
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "counting hosts")
|
|
}
|
|
|
|
err = svc.liveQueryStore.RunQuery(fmt.Sprint(campaign.ID), queryString, hostIDs)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "run query")
|
|
}
|
|
|
|
return campaign, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Create Distributed Query Campaign By Names
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type createDistributedQueryCampaignByIdentifierRequest struct {
|
|
QuerySQL string `json:"query"`
|
|
QueryID *uint `json:"query_id" renameto:"report_id"`
|
|
Selected distributedQueryCampaignTargetsByIdentifiers `json:"selected"`
|
|
}
|
|
|
|
type distributedQueryCampaignTargetsByIdentifiers struct {
|
|
Labels []string `json:"labels"`
|
|
// list of hostnames, UUIDs, and/or hardware serials
|
|
Hosts []string `json:"hosts"`
|
|
}
|
|
|
|
func createDistributedQueryCampaignByIdentifierEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer,
|
|
error,
|
|
) {
|
|
req := request.(*createDistributedQueryCampaignByIdentifierRequest)
|
|
campaign, err := svc.NewDistributedQueryCampaignByIdentifiers(ctx, req.QuerySQL, req.QueryID, req.Selected.Hosts, req.Selected.Labels)
|
|
if err != nil {
|
|
return createDistributedQueryCampaignResponse{Err: err}, nil
|
|
}
|
|
return createDistributedQueryCampaignResponse{Campaign: campaign}, nil
|
|
}
|
|
|
|
func (svc *Service) NewDistributedQueryCampaignByIdentifiers(ctx context.Context, queryString string, queryID *uint, hostIdentifiers []string, labels []string) (*fleet.DistributedQueryCampaign, error) {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
|
|
|
hostIDs, err := svc.ds.HostIDsByIdentifier(ctx, filter, hostIdentifiers)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "finding host IDs")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, &fleet.Label{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
labelMap, err := svc.ds.LabelIDsByName(ctx, labels, fleet.TeamFilter{User: vc.User})
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "finding label IDs")
|
|
}
|
|
|
|
// DetectMissingLabels will return the list of labels that are not found in the database
|
|
// These labels are considered invalid
|
|
invalidLabels := fleet.DetectMissingLabels(labelMap, labels)
|
|
if len(invalidLabels) > 0 {
|
|
return nil, ctxerr.Wrap(ctx, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf("%s %s.", fleet.InvalidLabelSpecifiedErrMsg, strings.Join(invalidLabels, ", ")),
|
|
}, "invalid labels")
|
|
}
|
|
|
|
var labelIDs []uint
|
|
for _, labelID := range labelMap {
|
|
labelIDs = append(labelIDs, labelID)
|
|
}
|
|
|
|
targets := fleet.HostTargets{HostIDs: hostIDs, LabelIDs: labelIDs}
|
|
return svc.NewDistributedQueryCampaign(ctx, queryString, queryID, targets)
|
|
}
|