fleet/server/service/campaigns.go
Nico b40fa26e2e
Follow-up changes to observer live query bypass (#41146)
<!-- 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"
/>
2026-03-11 13:42:33 -03:00

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