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" />
280 lines
7.8 KiB
Go
280 lines
7.8 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
)
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Search Targets
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type searchTargetsRequest struct {
|
|
// MatchQuery is the query SQL
|
|
MatchQuery string `json:"query"`
|
|
// QueryID is the ID of a saved query to run (used to determine if this is a
|
|
// query that observers can run).
|
|
QueryID *uint `json:"query_id" renameto:"report_id"`
|
|
// Selected is the list of IDs that are already selected on the caller side
|
|
// (e.g. the UI), so those are IDs that will be omitted from the returned
|
|
// payload.
|
|
Selected fleet.HostTargets `json:"selected"`
|
|
}
|
|
|
|
type labelSearchResult struct {
|
|
*fleet.Label
|
|
DisplayText string `json:"display_text"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
type teamSearchResult struct {
|
|
*fleet.Team
|
|
DisplayText string `json:"display_text"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
func (t teamSearchResult) MarshalJSON() ([]byte, error) {
|
|
x := struct {
|
|
ID uint `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
fleet.TeamConfig
|
|
UserCount int `json:"user_count"`
|
|
Users []fleet.TeamUser `json:"users,omitempty"`
|
|
HostCount int `json:"host_count"`
|
|
Hosts []fleet.HostResponse `json:"hosts,omitempty"`
|
|
Secrets []*fleet.EnrollSecret `json:"secrets,omitempty"`
|
|
DisplayText string `json:"display_text"`
|
|
Count int `json:"count"`
|
|
}{
|
|
ID: t.ID,
|
|
CreatedAt: t.CreatedAt,
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
TeamConfig: t.Config,
|
|
UserCount: t.UserCount,
|
|
Users: t.Users,
|
|
HostCount: t.HostCount,
|
|
Hosts: fleet.HostResponsesForHostsCheap(t.Hosts),
|
|
Secrets: t.Secrets,
|
|
DisplayText: t.DisplayText,
|
|
Count: t.Count,
|
|
}
|
|
|
|
return json.Marshal(x)
|
|
}
|
|
|
|
func (t *teamSearchResult) UnmarshalJSON(b []byte) error {
|
|
var x struct {
|
|
ID uint `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
fleet.TeamConfig
|
|
UserCount int `json:"user_count"`
|
|
Users []fleet.TeamUser `json:"users,omitempty"`
|
|
HostCount int `json:"host_count"`
|
|
Hosts []fleet.Host `json:"hosts,omitempty"`
|
|
Secrets []*fleet.EnrollSecret `json:"secrets,omitempty"`
|
|
DisplayText string `json:"display_text"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
if err := json.Unmarshal(b, &x); err != nil {
|
|
return err
|
|
}
|
|
|
|
*t = teamSearchResult{
|
|
Team: &fleet.Team{
|
|
ID: x.ID,
|
|
CreatedAt: x.CreatedAt,
|
|
Name: x.Name,
|
|
Description: x.Description,
|
|
Config: x.TeamConfig,
|
|
UserCount: x.UserCount,
|
|
Users: x.Users,
|
|
HostCount: x.HostCount,
|
|
Hosts: x.Hosts,
|
|
Secrets: x.Secrets,
|
|
},
|
|
DisplayText: x.DisplayText,
|
|
Count: x.Count,
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type targetsData struct {
|
|
Hosts []*fleet.HostResponse `json:"hosts"`
|
|
Labels []labelSearchResult `json:"labels"`
|
|
Teams []teamSearchResult `json:"teams" renameto:"fleets"`
|
|
}
|
|
|
|
type searchTargetsResponse struct {
|
|
Targets *targetsData `json:"targets,omitempty"`
|
|
TargetsCount uint `json:"targets_count"`
|
|
TargetsOnline uint `json:"targets_online"`
|
|
TargetsOffline uint `json:"targets_offline"`
|
|
TargetsMissingInAction uint `json:"targets_missing_in_action"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r searchTargetsResponse) Error() error { return r.Err }
|
|
|
|
func searchTargetsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*searchTargetsRequest)
|
|
|
|
results, err := svc.SearchTargets(ctx, req.MatchQuery, req.QueryID, req.Selected)
|
|
if err != nil {
|
|
return searchTargetsResponse{Err: err}, nil
|
|
}
|
|
|
|
targets := &targetsData{
|
|
Hosts: []*fleet.HostResponse{},
|
|
Labels: []labelSearchResult{},
|
|
Teams: []teamSearchResult{},
|
|
}
|
|
|
|
for _, host := range results.Hosts {
|
|
targets.Hosts = append(targets.Hosts, fleet.HostResponseForHostCheap(host))
|
|
}
|
|
|
|
for _, label := range results.Labels {
|
|
targets.Labels = append(targets.Labels,
|
|
labelSearchResult{
|
|
Label: label,
|
|
DisplayText: label.Name,
|
|
Count: label.HostCount,
|
|
},
|
|
)
|
|
}
|
|
|
|
for _, team := range results.Teams {
|
|
targets.Teams = append(targets.Teams,
|
|
teamSearchResult{
|
|
Team: team,
|
|
DisplayText: team.Name,
|
|
Count: team.HostCount,
|
|
},
|
|
)
|
|
}
|
|
|
|
metrics, err := svc.CountHostsInTargets(ctx, req.QueryID, req.Selected)
|
|
if err != nil {
|
|
return searchTargetsResponse{Err: err}, nil
|
|
}
|
|
|
|
return searchTargetsResponse{
|
|
Targets: targets,
|
|
TargetsCount: metrics.TotalHosts,
|
|
TargetsOnline: metrics.OnlineHosts,
|
|
TargetsOffline: metrics.OfflineHosts,
|
|
TargetsMissingInAction: metrics.MissingInActionHosts,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) SearchTargets(ctx context.Context, matchQuery string, queryID *uint, targets fleet.HostTargets) (*fleet.TargetSearchResults, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Target{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
filter := fleet.TeamFilter{User: vc.User}
|
|
if queryID != nil {
|
|
query, err := svc.ds.Query(ctx, *queryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter.IncludeObserver = query.ObserverCanRun
|
|
filter.ObserverTeamID = query.TeamID
|
|
}
|
|
|
|
results := &fleet.TargetSearchResults{}
|
|
|
|
hosts, err := svc.ds.SearchHosts(ctx, filter, matchQuery, targets.HostIDs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results.Hosts = append(results.Hosts, hosts...)
|
|
|
|
labels, err := svc.ds.SearchLabels(ctx, filter, matchQuery, targets.LabelIDs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results.Labels = labels
|
|
|
|
teams, err := svc.ds.SearchTeams(ctx, filter, matchQuery, targets.TeamIDs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results.Teams = teams
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func (svc *Service) CountHostsInTargets(ctx context.Context, queryID *uint, targets fleet.HostTargets) (*fleet.TargetMetrics, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Target{}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
filter := fleet.TeamFilter{User: vc.User}
|
|
if queryID != nil {
|
|
query, err := svc.ds.Query(ctx, *queryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
filter.IncludeObserver = query.ObserverCanRun
|
|
filter.ObserverTeamID = query.TeamID
|
|
}
|
|
|
|
metrics, err := svc.ds.CountHostsInTargets(ctx, filter, targets, svc.clock.Now())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &metrics, nil
|
|
}
|
|
|
|
type countTargetsRequest struct {
|
|
Selected fleet.HostTargets `json:"selected"`
|
|
QueryID *uint `json:"query_id" renameto:"report_id"`
|
|
}
|
|
|
|
type countTargetsResponse struct {
|
|
TargetsCount uint `json:"targets_count"`
|
|
TargetsOnline uint `json:"targets_online"`
|
|
TargetsOffline uint `json:"targets_offline"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r countTargetsResponse) Error() error { return r.Err }
|
|
|
|
func countTargetsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*countTargetsRequest)
|
|
|
|
counts, err := svc.CountHostsInTargets(ctx, req.QueryID, req.Selected)
|
|
if err != nil {
|
|
return searchTargetsResponse{Err: err}, nil
|
|
}
|
|
|
|
return countTargetsResponse{
|
|
TargetsCount: counts.TotalHosts,
|
|
TargetsOnline: counts.OnlineHosts,
|
|
TargetsOffline: counts.OfflineHosts,
|
|
}, nil
|
|
}
|