fleet/server/service/hosts.go
Lucas Manuel Rodriguez d467968c03
Add govet's nilness and golangci-lint nilnesserr (#33359)
These seemed easy to fix. And worth keeping the lint enabled moving
forward.
2025-09-23 17:55:50 -03:00

3069 lines
95 KiB
Go

package service
import (
"bytes"
"context"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"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/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/assets"
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
"github.com/fleetdm/fleet/v4/server/worker"
"github.com/go-kit/log/level"
"github.com/gocarina/gocsv"
"github.com/google/uuid"
)
// HostDetailResponse is the response struct that contains the full host information
// with the HostDetail details.
type HostDetailResponse struct {
fleet.HostDetail
Status fleet.HostStatus `json:"status"`
DisplayText string `json:"display_text"`
DisplayName string `json:"display_name"`
Geolocation *fleet.GeoLocation `json:"geolocation,omitempty"`
}
func hostDetailResponseForHost(ctx context.Context, svc fleet.Service, host *fleet.HostDetail) (*HostDetailResponse, error) {
return &HostDetailResponse{
HostDetail: *host,
Status: host.Status(time.Now()),
DisplayText: host.Hostname,
DisplayName: host.DisplayName(),
Geolocation: svc.LookupGeoIP(ctx, host.PublicIP),
}, nil
}
////////////////////////////////////////////////////////////////////////////////
// List Hosts
////////////////////////////////////////////////////////////////////////////////
type listHostsRequest struct {
Opts fleet.HostListOptions `url:"host_options"`
}
type listHostsResponse struct {
Hosts []fleet.HostResponse `json:"hosts"`
// Software is populated with the software version corresponding to the
// software_version_id (or software_id) filter if one is provided with the
// request (and it exists in the database). It is nil otherwise and absent of
// the JSON response payload.
Software *fleet.Software `json:"software,omitempty"`
// SoftwareTitle is populated with the title corresponding to the
// software_title_id filter if one is provided with the request (and it
// exists in the database). It is nil otherwise and absent of the JSON
// response payload.
SoftwareTitle *fleet.SoftwareTitle `json:"software_title,omitempty"`
// MDMSolution is populated with the MDM solution corresponding to the mdm_id
// filter if one is provided with the request (and it exists in the
// database). It is nil otherwise and absent of the JSON response payload.
MDMSolution *fleet.MDMSolution `json:"mobile_device_management_solution,omitempty"`
// MunkiIssue is populated with the munki issue corresponding to the
// munki_issue_id filter if one is provided with the request (and it exists
// in the database). It is nil otherwise and absent of the JSON response
// payload.
MunkiIssue *fleet.MunkiIssue `json:"munki_issue,omitempty"`
Err error `json:"error,omitempty"`
}
func (r listHostsResponse) Error() error { return r.Err }
func listHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listHostsRequest)
var software *fleet.Software
if req.Opts.SoftwareVersionIDFilter != nil || req.Opts.SoftwareIDFilter != nil {
var err error
id := req.Opts.SoftwareVersionIDFilter
if id == nil {
id = req.Opts.SoftwareIDFilter
}
software, err = svc.SoftwareByID(ctx, *id, req.Opts.TeamFilter, false)
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the software in that case
return listHostsResponse{Err: err}, nil
}
}
var softwareTitle *fleet.SoftwareTitle
if req.Opts.SoftwareTitleIDFilter != nil {
var err error
softwareTitle, err = svc.SoftwareTitleByID(ctx, *req.Opts.SoftwareTitleIDFilter, req.Opts.TeamFilter)
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the software title in that case
return listHostsResponse{Err: err}, nil
}
}
var mdmSolution *fleet.MDMSolution
if req.Opts.MDMIDFilter != nil {
var err error
mdmSolution, err = svc.GetMDMSolution(ctx, *req.Opts.MDMIDFilter)
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the MDM solution in that case
return listHostsResponse{Err: err}, nil
}
}
var munkiIssue *fleet.MunkiIssue
if req.Opts.MunkiIssueIDFilter != nil {
var err error
munkiIssue, err = svc.GetMunkiIssue(ctx, *req.Opts.MunkiIssueIDFilter)
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the munki issue in that case
return listHostsResponse{Err: err}, nil
}
}
hosts, err := svc.ListHosts(ctx, req.Opts)
if err != nil {
return listHostsResponse{Err: err}, nil
}
hostResponses := make([]fleet.HostResponse, len(hosts))
for i, host := range hosts {
h := fleet.HostResponseForHost(ctx, svc, host)
hostResponses[i] = *h
if req.Opts.PopulateLabels {
labels, err := svc.ListLabelsForHost(ctx, h.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("failed to list labels for host %d", h.ID))
}
hostResponses[i].Labels = labels
}
}
return listHostsResponse{
Hosts: hostResponses,
Software: software,
SoftwareTitle: softwareTitle,
MDMSolution: mdmSolution,
MunkiIssue: munkiIssue,
}, nil
}
func (svc *Service) GetMDMSolution(ctx context.Context, mdmID uint) (*fleet.MDMSolution, error) {
// require list hosts permission to view this information
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
return svc.ds.GetMDMSolution(ctx, mdmID)
}
func (svc *Service) GetMunkiIssue(ctx context.Context, munkiIssueID uint) (*fleet.MunkiIssue, error) {
// require list hosts permission to view this information
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
return svc.ds.GetMunkiIssue(ctx, munkiIssueID)
}
func (svc *Service) ListHosts(ctx context.Context, opt fleet.HostListOptions) ([]*fleet.Host, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
// TODO(Sarah): Are we missing any other filters here?
premiumLicense := license.IsPremium(ctx)
if !premiumLicense {
// the low disk space filter is premium-only
opt.LowDiskSpaceFilter = nil
// the bootstrap package filter is premium-only
opt.MDMBootstrapPackageFilter = nil
// including vulnerability details on software is premium-only
opt.PopulateSoftwareVulnerabilityDetails = false
}
hosts, err := svc.ds.ListHosts(ctx, filter, opt)
if err != nil {
return nil, err
}
// If issues are enabled, we need to remove the critical vulnerabilities count for non-premium license.
// If issues are disabled, we need to explicitly set the critical vulnerabilities count to 0 for premium license.
if !opt.DisableIssues && !premiumLicense {
// Remove critical vulnerabilities count if not premium license
for _, host := range hosts {
host.HostIssues.CriticalVulnerabilitiesCount = nil
}
} else if opt.DisableIssues && premiumLicense {
var zero uint64
for _, host := range hosts {
host.HostIssues.CriticalVulnerabilitiesCount = &zero
}
}
if opt.PopulateSoftware {
for _, host := range hosts {
if err = svc.ds.LoadHostSoftware(ctx, host, opt.PopulateSoftwareVulnerabilityDetails); err != nil {
return nil, err
}
}
}
if opt.PopulatePolicies {
for _, host := range hosts {
hp, err := svc.ds.ListPoliciesForHost(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get policies for host %d", host.ID))
}
host.Policies = &hp
}
}
if opt.PopulateUsers {
for _, host := range hosts {
hu, err := svc.ds.ListHostUsers(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get users for host %d", host.ID))
}
host.Users = hu
}
}
return hosts, nil
}
/////////////////////////////////////////////////////////////////////////////////
// Delete Hosts
/////////////////////////////////////////////////////////////////////////////////
// These values are modified during testing.
var (
deleteHostsTimeout = 30 * time.Second
deleteHostsSkipAuthorization = false
)
type deleteHostsRequest struct {
IDs []uint `json:"ids"`
// Using a pointer to help determine whether an empty filter was passed, like: "filters":{}
Filters *map[string]interface{} `json:"filters"`
}
type deleteHostsResponse struct {
Err error `json:"error,omitempty"`
StatusCode int `json:"-"`
}
func (r deleteHostsResponse) Error() error { return r.Err }
// Status implements statuser interface to send out custom HTTP success codes.
func (r deleteHostsResponse) Status() int { return r.StatusCode }
func deleteHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteHostsRequest)
// Since bulk deletes can take a long time, after DeleteHostsTimeout, we will return a 202 (Accepted) status code
// and allow the delete operation to proceed.
var err error
deleteDone := make(chan bool, 1)
ctx = context.WithoutCancel(ctx) // to make sure DB operations don't get killed after we return a 202
go func() {
err = svc.DeleteHosts(ctx, req.IDs, req.Filters)
if err != nil {
// logging the error for future debug in case we already sent http.StatusAccepted
logging.WithErr(ctx, err)
}
deleteDone <- true
}()
select {
case <-deleteDone:
if err != nil {
return deleteHostsResponse{Err: err}, nil
}
return deleteHostsResponse{StatusCode: http.StatusOK}, nil
case <-time.After(deleteHostsTimeout):
if deleteHostsSkipAuthorization {
// Only called during testing.
svc.(validationMiddleware).Service.(*Service).authz.SkipAuthorization(ctx)
}
return deleteHostsResponse{StatusCode: http.StatusAccepted}, nil
}
}
func (svc *Service) DeleteHosts(ctx context.Context, ids []uint, filter *map[string]interface{}) error {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
opts, lid, err := hostListOptionsFromFilters(filter)
if err != nil {
return err
}
if len(ids) == 0 && lid == nil && opts == nil {
return &fleet.BadRequestError{Message: "list of ids or filters must be specified"}
}
if len(ids) > 0 && (lid != nil || (opts != nil && !opts.Empty())) {
return &fleet.BadRequestError{Message: "Cannot specify a list of ids and filters at the same time"}
}
doDelete := func(hostIDs []uint, hosts []*fleet.Host) error {
if err := svc.ds.DeleteHosts(ctx, hostIDs); err != nil {
return err
}
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger)
lifecycleErrs := []error{}
serialsWithErrs := []string{}
for _, host := range hosts {
if fleet.MDMSupported(host.Platform) {
if err := mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
Action: mdmlifecycle.HostActionDelete,
Host: host,
Platform: host.Platform,
}); err != nil {
lifecycleErrs = append(lifecycleErrs, err)
serialsWithErrs = append(serialsWithErrs, host.HardwareSerial)
}
}
}
if len(lifecycleErrs) > 0 {
msg := fmt.Sprintf("failed to recreate pending host records for one or more MDM devices: %+v", serialsWithErrs)
return ctxerr.Wrap(ctx, errors.Join(lifecycleErrs...), msg)
}
return nil
}
if len(ids) > 0 {
if err := svc.checkWriteForHostIDs(ctx, ids); err != nil {
return err
}
hosts, err := svc.ds.ListHostsLiteByIDs(ctx, ids)
if err != nil {
return err
}
return doDelete(ids, hosts)
}
if opts == nil {
opts = &fleet.HostListOptions{}
}
opts.DisableIssues = true // don't check policies for hosts that are about to be deleted
hostIDs, _, hosts, err := svc.hostIDsAndNamesFromFilters(ctx, *opts, lid)
if err != nil {
return err
}
if len(hostIDs) == 0 {
return nil
}
err = svc.checkWriteForHostIDs(ctx, hostIDs)
if err != nil {
return err
}
return doDelete(hostIDs, hosts)
}
/////////////////////////////////////////////////////////////////////////////////
// Count
/////////////////////////////////////////////////////////////////////////////////
type countHostsRequest struct {
Opts fleet.HostListOptions `url:"host_options"`
LabelID *uint `query:"label_id,optional"`
}
type countHostsResponse struct {
Count int `json:"count"`
Err error `json:"error,omitempty"`
}
func (r countHostsResponse) Error() error { return r.Err }
func countHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*countHostsRequest)
count, err := svc.CountHosts(ctx, req.LabelID, req.Opts)
if err != nil {
return countHostsResponse{Err: err}, nil
}
return countHostsResponse{Count: count}, nil
}
func (svc *Service) CountHosts(ctx context.Context, labelID *uint, opts fleet.HostListOptions) (int, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return 0, err
}
return svc.countHostFromFilters(ctx, labelID, opts)
}
func (svc *Service) countHostFromFilters(ctx context.Context, labelID *uint, opt fleet.HostListOptions) (int, error) {
vc, ok := viewer.FromContext(ctx)
if !ok {
return 0, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
if !license.IsPremium(ctx) {
// the low disk space filter is premium-only
opt.LowDiskSpaceFilter = nil
}
var count int
var err error
if labelID != nil {
count, err = svc.ds.CountHostsInLabel(ctx, filter, *labelID, opt)
} else {
count, err = svc.ds.CountHosts(ctx, filter, opt)
}
if err != nil {
return 0, err
}
return count, nil
}
/////////////////////////////////////////////////////////////////////////////////
// Search
/////////////////////////////////////////////////////////////////////////////////
type searchHostsRequest 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"`
// ExcludedHostIDs is the list of IDs selected on the caller side
// (e.g. the UI) that will be excluded from the returned payload.
ExcludedHostIDs []uint `json:"excluded_host_ids"`
}
type searchHostsResponse struct {
Hosts []*fleet.HostResponse `json:"hosts"`
Err error `json:"error,omitempty"`
}
func (r searchHostsResponse) Error() error { return r.Err }
func searchHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*searchHostsRequest)
hosts, err := svc.SearchHosts(ctx, req.MatchQuery, req.QueryID, req.ExcludedHostIDs)
if err != nil {
return searchHostsResponse{Err: err}, nil
}
results := []*fleet.HostResponse{}
for _, h := range hosts {
results = append(results, fleet.HostResponseForHostCheap(h))
}
return searchHostsResponse{
Hosts: results,
}, nil
}
func (svc *Service) SearchHosts(ctx context.Context, matchQuery string, queryID *uint, excludedHostIDs []uint) ([]*fleet.Host, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
includeObserver := false
if queryID != nil {
canRun, err := svc.ds.ObserverCanRunQuery(ctx, *queryID)
if err != nil {
return nil, err
}
includeObserver = canRun
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: includeObserver}
results := []*fleet.Host{}
hosts, err := svc.ds.SearchHosts(ctx, filter, matchQuery, excludedHostIDs...)
if err != nil {
return nil, err
}
results = append(results, hosts...)
return results, nil
}
/////////////////////////////////////////////////////////////////////////////////
// Get host
/////////////////////////////////////////////////////////////////////////////////
type getHostRequest struct {
ID uint `url:"id"`
ExcludeSoftware bool `query:"exclude_software,optional"`
}
type getHostResponse struct {
Host *HostDetailResponse `json:"host"`
Err error `json:"error,omitempty"`
}
func (r getHostResponse) Error() error { return r.Err }
func getHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostRequest)
opts := fleet.HostDetailOptions{
IncludeCVEScores: false,
IncludePolicies: true, // intentionally true to preserve existing behavior,
ExcludeSoftware: req.ExcludeSoftware,
}
host, err := svc.GetHost(ctx, req.ID, opts)
if err != nil {
return getHostResponse{Err: err}, nil
}
resp, err := hostDetailResponseForHost(ctx, svc, host)
if err != nil {
return getHostResponse{Err: err}, nil
}
return getHostResponse{Host: resp}, nil
}
func (svc *Service) GetHost(ctx context.Context, id uint, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) {
alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken)
if !alreadyAuthd {
// First ensure the user has access to list hosts, then check the specific
// host once team_id is loaded.
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
}
// recalculate host failing_policies_count & total_issues_count, at most every minute
lastUpdated, err := svc.ds.GetHostIssuesLastUpdated(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "checking host's host_issues last updated:")
}
if time.Since(lastUpdated) > time.Minute {
if err := svc.ds.UpdateHostIssuesFailingPoliciesForSingleHost(ctx, id); err != nil {
return nil, ctxerr.Wrap(ctx, err, "recalculate host failing policies count:")
}
}
host, err := svc.ds.Host(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host")
}
if !opts.IncludeCriticalVulnerabilitiesCount {
host.HostIssues.CriticalVulnerabilitiesCount = nil
}
if !alreadyAuthd {
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, err
}
}
hostDetails, err := svc.getHostDetails(ctx, host, opts)
if err != nil {
return nil, err
}
return hostDetails, nil
}
func (svc *Service) checkWriteForHostIDs(ctx context.Context, ids []uint) error {
for _, id := range ids {
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host for delete")
}
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
return err
}
}
return nil
}
// //////////////////////////////////////////////////////////////////////////////
// Get Host Lite
// //////////////////////////////////////////////////////////////////////////////
func (svc *Service) GetHostLite(ctx context.Context, id uint) (*fleet.Host, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host lite")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, err
}
return host, nil
}
////////////////////////////////////////////////////////////////////////////////
// Get Host Summary
////////////////////////////////////////////////////////////////////////////////
type getHostSummaryRequest struct {
TeamID *uint `query:"team_id,optional"`
Platform *string `query:"platform,optional"`
LowDiskSpace *int `query:"low_disk_space,optional"`
}
type getHostSummaryResponse struct {
fleet.HostSummary
Err error `json:"error,omitempty"`
}
func (r getHostSummaryResponse) Error() error { return r.Err }
func getHostSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostSummaryRequest)
summary, err := svc.GetHostSummary(ctx, req.TeamID, req.Platform, req.LowDiskSpace)
if err != nil {
return getHostSummaryResponse{Err: err}, nil
}
resp := getHostSummaryResponse{
HostSummary: *summary,
}
return resp, nil
}
func (svc *Service) GetHostSummary(ctx context.Context, teamID *uint, platform *string, lowDiskSpace *int) (*fleet.HostSummary, error) {
if lowDiskSpace != nil {
if *lowDiskSpace < 1 || *lowDiskSpace > 100 {
svc.authz.SkipAuthorization(ctx)
return nil, ctxerr.Wrap(
ctx, badRequest(fmt.Sprintf("invalid low_disk_space threshold, must be between 1 and 100: %d", *lowDiskSpace)),
)
}
}
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID}
if !license.IsPremium(ctx) {
lowDiskSpace = nil
}
hostSummary, err := svc.ds.GenerateHostStatusStatistics(ctx, filter, svc.clock.Now(), platform, lowDiskSpace)
if err != nil {
return nil, err
}
linuxCount := uint(0)
for _, p := range hostSummary.Platforms {
if fleet.IsLinux(p.Platform) {
linuxCount += p.HostsCount
}
}
hostSummary.AllLinuxCount = linuxCount
labelsSummary, err := svc.ds.LabelsSummary(ctx)
if err != nil {
return nil, err
}
// TODO: should query for "All linux" label be updated to use `platform` from `os_version` table
// so that the label tracks the way platforms are handled here in the host summary?
var builtinLabels []*fleet.LabelSummary
for _, l := range labelsSummary {
if l.LabelType == fleet.LabelTypeBuiltIn {
builtinLabels = append(builtinLabels, l)
}
}
hostSummary.BuiltinLabels = builtinLabels
return hostSummary, nil
}
////////////////////////////////////////////////////////////////////////////////
// Get Host By Identifier
////////////////////////////////////////////////////////////////////////////////
type hostByIdentifierRequest struct {
Identifier string `url:"identifier"`
ExcludeSoftware bool `query:"exclude_software,optional"`
}
func hostByIdentifierEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*hostByIdentifierRequest)
opts := fleet.HostDetailOptions{
IncludeCVEScores: false,
IncludePolicies: true, // intentionally true to preserve existing behavior
ExcludeSoftware: req.ExcludeSoftware,
}
host, err := svc.HostByIdentifier(ctx, req.Identifier, opts)
if err != nil {
return getHostResponse{Err: err}, nil
}
resp, err := hostDetailResponseForHost(ctx, svc, host)
if err != nil {
return getHostResponse{Err: err}, nil
}
return getHostResponse{
Host: resp,
}, nil
}
func (svc *Service) HostByIdentifier(ctx context.Context, identifier string, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
return nil, err
}
host, err := svc.ds.HostByIdentifier(ctx, identifier)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host by identifier")
}
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionSelectiveRead); err != nil {
return nil, err
}
hostDetails, err := svc.getHostDetails(ctx, host, opts)
if err != nil {
return nil, err
}
return hostDetails, nil
}
////////////////////////////////////////////////////////////////////////////////
// Delete Host
////////////////////////////////////////////////////////////////////////////////
type deleteHostRequest struct {
ID uint `url:"id"`
}
type deleteHostResponse struct {
Err error `json:"error,omitempty"`
}
func (r deleteHostResponse) Error() error { return r.Err }
func deleteHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*deleteHostRequest)
err := svc.DeleteHost(ctx, req.ID)
if err != nil {
return deleteHostResponse{Err: err}, nil
}
return deleteHostResponse{}, nil
}
func (svc *Service) DeleteHost(ctx context.Context, id uint) error {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host for delete")
}
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
return err
}
if err := svc.ds.DeleteHost(ctx, id); err != nil {
return ctxerr.Wrap(ctx, err, "delete host")
}
if fleet.MDMSupported(host.Platform) {
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger)
err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
Action: mdmlifecycle.HostActionDelete,
Platform: host.Platform,
UUID: host.UUID,
Host: host,
})
return ctxerr.Wrap(ctx, err, "performing MDM actions after delete")
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Add Hosts to Team
////////////////////////////////////////////////////////////////////////////////
type addHostsToTeamRequest struct {
TeamID *uint `json:"team_id"`
HostIDs []uint `json:"hosts"`
}
type addHostsToTeamResponse struct {
Err error `json:"error,omitempty"`
}
func (r addHostsToTeamResponse) Error() error { return r.Err }
func addHostsToTeamEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*addHostsToTeamRequest)
err := svc.AddHostsToTeam(ctx, req.TeamID, req.HostIDs, false)
if err != nil {
return addHostsToTeamResponse{Err: err}, nil
}
return addHostsToTeamResponse{}, err
}
func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []uint, skipBulkPending bool) error {
// This is currently treated as a "team write". If we ever give users
// besides global admins permissions to modify team hosts, we will need to
// check that the user has permissions for both the source and destination
// teams.
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
if err := svc.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(teamID, hostIDs)); err != nil {
return err
}
if !skipBulkPending {
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
}
serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "list mdm dep serials in host ids")
}
if len(serials) > 0 {
if _, err := worker.QueueMacosSetupAssistantJob(
ctx,
svc.ds,
svc.logger,
worker.MacosSetupAssistantHostsTransferred,
teamID,
serials...); err != nil {
return ctxerr.Wrap(ctx, err, "queue macos setup assistant hosts transferred job")
}
}
return svc.createTransferredHostsActivity(ctx, teamID, hostIDs, nil)
}
// creates the transferred hosts activity if hosts were transferred, taking
// care of loading the team name and the hosts names if necessary (hostNames
// may be passed as empty if they were not available to the caller, such as in
// AddHostsToTeam, while it may be provided if available, such as in
// AddHostsToTeamByFilter).
func (svc *Service) createTransferredHostsActivity(ctx context.Context, teamID *uint, hostIDs []uint, hostNames []string) error {
if len(hostIDs) == 0 {
return nil
}
var teamName *string
if teamID != nil {
tm, err := svc.ds.Team(ctx, *teamID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get team for activity")
}
teamName = &tm.Name
}
if len(hostNames) == 0 {
hosts, err := svc.ds.ListHostsLiteByIDs(ctx, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "list hosts by ids")
}
// index the hosts by ids to get the names in the same order as hostIDs
hostsByID := make(map[uint]*fleet.Host, len(hosts))
for _, h := range hosts {
hostsByID[h.ID] = h
}
hostNames = make([]string, 0, len(hostIDs))
for _, hid := range hostIDs {
if h, ok := hostsByID[hid]; ok {
hostNames = append(hostNames, h.DisplayName())
} else {
// should not happen unless a host gets deleted just after transfer,
// but this ensures hostNames always matches hostIDs at the same index
hostNames = append(hostNames, "")
}
}
}
if err := svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeTransferredHostsToTeam{
TeamID: teamID,
TeamName: teamName,
HostIDs: hostIDs,
HostDisplayNames: hostNames,
},
); err != nil {
return ctxerr.Wrap(ctx, err, "create transferred_hosts activity")
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Add Hosts to Team by Filter
////////////////////////////////////////////////////////////////////////////////
type addHostsToTeamByFilterRequest struct {
TeamID *uint `json:"team_id"`
Filters *map[string]interface{} `json:"filters"`
}
type addHostsToTeamByFilterResponse struct {
Err error `json:"error,omitempty"`
}
func (r addHostsToTeamByFilterResponse) Error() error { return r.Err }
func addHostsToTeamByFilterEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*addHostsToTeamByFilterRequest)
err := svc.AddHostsToTeamByFilter(ctx, req.TeamID, req.Filters)
if err != nil {
return addHostsToTeamByFilterResponse{Err: err}, nil
}
return addHostsToTeamByFilterResponse{}, err
}
func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, filters *map[string]interface{}) error {
// This is currently treated as a "team write". If we ever give users
// besides global admins permissions to modify team hosts, we will need to
// check that the user has permissions for both the source and destination
// teams.
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionWrite); err != nil {
return err
}
opt, lid, err := hostListOptionsFromFilters(filters)
if err != nil {
return err
}
if opt == nil {
return &fleet.BadRequestError{Message: "filters must be specified"}
}
hostIDs, hostNames, _, err := svc.hostIDsAndNamesFromFilters(ctx, *opt, lid)
if err != nil {
return err
}
if len(hostIDs) == 0 {
return nil
}
// Apply the team to the selected hosts.
if err := svc.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(teamID, hostIDs)); err != nil {
return err
}
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
}
serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "list mdm dep serials in host ids")
}
if len(serials) > 0 {
if _, err := worker.QueueMacosSetupAssistantJob(
ctx,
svc.ds,
svc.logger,
worker.MacosSetupAssistantHostsTransferred,
teamID,
serials...); err != nil {
return ctxerr.Wrap(ctx, err, "queue macos setup assistant hosts transferred job")
}
}
return svc.createTransferredHostsActivity(ctx, teamID, hostIDs, hostNames)
}
////////////////////////////////////////////////////////////////////////////////
// Refetch Host
////////////////////////////////////////////////////////////////////////////////
type refetchHostRequest struct {
ID uint `url:"id"`
}
type refetchHostResponse struct {
Err error `json:"error,omitempty"`
}
func (r refetchHostResponse) Error() error {
return r.Err
}
func refetchHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*refetchHostRequest)
err := svc.RefetchHost(ctx, req.ID)
if err != nil {
return refetchHostResponse{Err: err}, nil
}
return refetchHostResponse{}, nil
}
func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
var host *fleet.Host
// iOS and iPadOS refetch are not authenticated with device token because these devices do not have Fleet Desktop,
// so we don't handle that case
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
var err error
if err = svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return err
}
host, err = svc.ds.HostLite(ctx, id)
if err != nil {
return ctxerr.Wrap(ctx, err, "find host for refetch")
}
// We verify fleet.ActionRead instead of fleet.ActionWrite because we want to allow
// observers to be able to refetch hosts.
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return err
}
}
if err := svc.ds.UpdateHostRefetchRequested(ctx, id, true); err != nil {
return ctxerr.Wrap(ctx, err, "save host")
}
// TODO(android): add android to this list?
if host != nil && (host.Platform == "ios" || host.Platform == "ipados") {
// Get MDM commands already sent
commands, err := svc.ds.GetHostMDMCommands(ctx, host.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "get host MDM commands")
}
doAppRefetch := true
doDeviceInfoRefetch := true
doCertsRefetch := true
for _, cmd := range commands {
switch cmd.CommandType {
case fleet.RefetchDeviceCommandUUIDPrefix:
doDeviceInfoRefetch = false
case fleet.RefetchAppsCommandUUIDPrefix:
doAppRefetch = false
case fleet.RefetchCertsCommandUUIDPrefix:
doCertsRefetch = false
}
}
if !doAppRefetch && !doDeviceInfoRefetch && !doCertsRefetch {
// Nothing to do.
return nil
}
err = svc.verifyMDMConfiguredAndConnected(ctx, host)
if err != nil {
return err
}
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3)
cmdUUID := uuid.NewString()
if doAppRefetch {
err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID, false)
if err != nil {
return ctxerr.Wrap(ctx, err, "refetch apps with MDM")
}
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
})
}
if doCertsRefetch {
if err := svc.mdmAppleCommander.CertificateList(ctx, []string{host.UUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "refetch certs with MDM")
}
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
})
}
if doDeviceInfoRefetch {
// DeviceInformation is last because the refetch response clears the refetch_requested flag
err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchDeviceCommandUUIDPrefix+cmdUUID)
if err != nil {
return ctxerr.Wrap(ctx, err, "refetch host with MDM")
}
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
HostID: host.ID,
CommandType: fleet.RefetchDeviceCommandUUIDPrefix,
})
}
// Add commands to the database to track the commands sent
err = svc.ds.AddHostMDMCommands(ctx, hostMDMCommands)
if err != nil {
return ctxerr.Wrap(ctx, err, "add host mdm commands")
}
}
return nil
}
func (svc *Service) verifyMDMConfiguredAndConnected(ctx context.Context, host *fleet.Host) error {
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
if errors.Is(err, fleet.ErrMDMNotConfigured) {
err = fleet.NewInvalidArgumentError("id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
}
return ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
}
connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet")
}
if !connected {
return ctxerr.Wrap(ctx,
fleet.NewInvalidArgumentError("id", "Host does not have MDM turned on."))
}
return nil
}
func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) {
if !opts.ExcludeSoftware {
if err := svc.ds.LoadHostSoftware(ctx, host, opts.IncludeCVEScores); err != nil {
return nil, ctxerr.Wrap(ctx, err, "load host software")
}
}
if host.HostSoftware.Software == nil {
host.HostSoftware.Software = []fleet.HostSoftwareEntry{}
}
labels, err := svc.ds.ListLabelsForHost(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get labels for host")
}
packs, err := svc.ds.ListPacksForHost(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get packs for host")
}
bats, err := svc.ds.ListHostBatteries(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get batteries for host")
}
mws, err := svc.ds.ListUpcomingHostMaintenanceWindows(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows")
}
// we are only interested in the next maintenance window. There should only be one for now, anyway.
var nextMw *fleet.HostMaintenanceWindow
if len(mws) > 0 {
nextMw = mws[0]
}
// nil TimeZone is okay
if nextMw != nil && nextMw.TimeZone != nil {
// return the start time in the local timezone of the host's associated google calendar user
gCalLoc, err := time.LoadLocation(*nextMw.TimeZone)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows - invalid google calendar timezone")
}
nextMw.StartsAt = nextMw.StartsAt.In(gCalLoc)
}
var policies *[]*fleet.HostPolicy
if opts.IncludePolicies {
hp, err := svc.ds.ListPoliciesForHost(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get policies for host")
}
if hp == nil {
hp = []*fleet.HostPolicy{}
}
policies = &hp
}
// Calculate the number of failing policies for the host based on the returned policies to
// avoid discrepancies due to read replica delay.
var failingPolicies uint64
if policies != nil {
for _, p := range *policies {
if p != nil && p.Response == "fail" {
failingPolicies++
}
}
}
host.HostIssues.FailingPoliciesCount = failingPolicies
// If Fleet MDM is enabled and configured, we want to include MDM profiles,
// disk encryption status, and macOS setup details for non-linux hosts.
ac, err := svc.ds.AppConfig(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get app config for host mdm details")
}
var profiles []fleet.HostMDMProfile
var mdmLastEnrollment *time.Time
var mdmLastCheckedIn *time.Time
if ac.MDM.EnabledAndConfigured || ac.MDM.WindowsEnabledAndConfigured || ac.MDM.AndroidEnabledAndConfigured {
host.MDM.OSSettings = &fleet.HostMDMOSSettings{}
switch host.Platform {
case "windows":
if !ac.MDM.WindowsEnabledAndConfigured {
break
}
if license.IsPremium(ctx) {
// we include disk encryption status only for premium so initialize it to default struct
host.MDM.OSSettings.DiskEncryption = fleet.HostMDMDiskEncryption{}
// ensure host mdm info is loaded (we don't know if our caller populated it)
_, err := svc.ds.GetHostMDM(ctx, host.ID)
switch {
case err != nil && fleet.IsNotFound(err):
// assume host is unmanaged, log for debugging, and move on
level.Debug(svc.logger).Log("msg", "cannot determine bitlocker status because no mdm info for host", "host_id", host.ID)
case err != nil:
return nil, ctxerr.Wrap(ctx, err, "ensure host mdm info")
default:
hde, err := svc.ds.GetMDMWindowsBitLockerStatus(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm bitlocker status")
}
if hde != nil {
// overwrite the default disk encryption status
host.MDM.OSSettings.DiskEncryption = *hde
}
}
}
profs, err := svc.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm windows profiles")
}
if profs == nil {
profs = []fleet.HostMDMWindowsProfile{}
}
for _, p := range profs {
p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message()
profiles = append(profiles, p.ToHostMDMProfile())
}
case "android":
if !ac.MDM.AndroidEnabledAndConfigured {
break
}
profs, err := svc.ds.GetHostMDMAndroidProfiles(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm android profiles")
}
if profs == nil {
profs = []fleet.HostMDMAndroidProfile{}
}
for _, p := range profs {
p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message()
profiles = append(profiles, p.ToHostMDMProfile())
}
case "darwin", "ios", "ipados":
if ac.MDM.EnabledAndConfigured {
profs, err := svc.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm profiles")
}
// determine disk encryption and action required here based on profiles and
// raw decryptable key status.
host.MDM.PopulateOSSettingsAndMacOSSettings(profs, mobileconfig.FleetFileVaultPayloadIdentifier)
for _, p := range profs {
if p.Identifier == mobileconfig.FleetFileVaultPayloadIdentifier {
p.Status = host.MDM.ProfileStatusFromDiskEncryptionState(p.Status)
}
p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message()
profiles = append(profiles, p.ToHostMDMProfile(host.Platform))
}
// fetch host last seen at and last enrolled at times, currently only supported for
// Apple platforms
mdmLastEnrollment, mdmLastCheckedIn, err = svc.ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm enrollment times")
}
}
}
}
host.MDM.Profiles = &profiles
isHDEKArchived, err := svc.ds.IsHostDiskEncryptionKeyArchived(ctx, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "check if host disk encryption key is archived")
}
host.MDM.EncryptionKeyArchived = &isHDEKArchived
if host.IsLUKSSupported() {
// since Linux hosts don't require MDM to be enabled & configured, explicitly check that disk encryption is
// enabled for the host's team
diskEncryptionConfig, err := svc.ds.GetConfigEnableDiskEncryption(ctx, host.TeamID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host disk encryption enabled setting")
}
if diskEncryptionConfig.Enabled {
status, err := svc.LinuxHostDiskEncryptionStatus(ctx, *host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host disk encryption status")
}
host.MDM.OSSettings = &fleet.HostMDMOSSettings{
DiskEncryption: status,
}
if status.Status != nil && *status.Status == fleet.DiskEncryptionVerified {
host.MDM.EncryptionKeyAvailable = true
}
}
}
var macOSSetup *fleet.HostMDMMacOSSetup
if ac.MDM.EnabledAndConfigured && license.IsPremium(ctx) {
macOSSetup, err = svc.ds.GetHostMDMMacOSSetup(ctx, host.ID)
if err != nil {
if !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get host mdm macos setup")
}
// TODO(Sarah): What should we do for not found? Should we return an empty struct or nil?
macOSSetup = &fleet.HostMDMMacOSSetup{}
}
}
host.MDM.MacOSSetup = macOSSetup
mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host mdm lock/wipe status")
}
host.MDM.DeviceStatus = ptr.String(string(mdmActions.DeviceStatus()))
host.MDM.PendingAction = ptr.String(string(mdmActions.PendingAction()))
host.Policies = policies
endUsers, err := getEndUsers(ctx, svc.ds, host.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get end users for host")
}
return &fleet.HostDetail{
Host: *host,
Labels: labels,
Packs: packs,
Batteries: &bats,
MaintenanceWindow: nextMw,
EndUsers: endUsers,
LastMDMEnrolledAt: mdmLastEnrollment,
LastMDMCheckedInAt: mdmLastCheckedIn,
}, nil
}
func getEndUsers(ctx context.Context, ds fleet.Datastore, hostID uint) ([]fleet.HostEndUser, error) {
scimUser, err := ds.ScimUserByHostID(ctx, hostID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "get scim user by host id")
}
var endUsers []fleet.HostEndUser
if scimUser != nil {
endUser := fleet.HostEndUser{
IdpUserName: scimUser.UserName,
IdpFullName: scimUser.DisplayName(),
IdpInfoUpdatedAt: ptr.Time(scimUser.UpdatedAt),
}
if scimUser.ExternalID != nil {
endUser.IdpID = *scimUser.ExternalID
}
for _, group := range scimUser.Groups {
endUser.IdpGroups = append(endUser.IdpGroups, group.DisplayName)
}
if scimUser.Department != nil {
endUser.Department = *scimUser.Department
}
endUsers = append(endUsers, endUser)
}
deviceMapping, err := ds.ListHostDeviceMapping(ctx, hostID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host device mapping")
}
if len(deviceMapping) > 0 {
endUser := fleet.HostEndUser{}
for _, email := range deviceMapping {
switch {
case email.Source == fleet.DeviceMappingMDMIdpAccounts && len(endUsers) == 0:
// If SCIM data is missing, we still populate IdpUserName if present.
// Note: Username and email is the same thing here until we split them with https://github.com/fleetdm/fleet/issues/27952
endUser.IdpUserName = email.Email
case email.Source != fleet.DeviceMappingMDMIdpAccounts:
endUser.OtherEmails = append(endUser.OtherEmails, *email)
}
}
if len(endUsers) > 0 {
endUsers[0].OtherEmails = endUser.OtherEmails
} else {
endUsers = append(endUsers, endUser)
}
}
return endUsers, nil
}
////////////////////////////////////////////////////////////////////////////////
// Get Host Query Report
////////////////////////////////////////////////////////////////////////////////
type getHostQueryReportRequest struct {
ID uint `url:"id"`
QueryID uint `url:"query_id"`
}
type getHostQueryReportResponse struct {
QueryID uint `json:"query_id"`
HostID uint `json:"host_id"`
HostName string `json:"host_name"`
LastFetched *time.Time `json:"last_fetched"`
ReportClipped bool `json:"report_clipped"`
Results []fleet.HostQueryReportResult `json:"results"`
Err error `json:"error,omitempty"`
}
func (r getHostQueryReportResponse) Error() error { return r.Err }
func getHostQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostQueryReportRequest)
// Need to return hostname in response even if there are no report results
host, err := svc.GetHostLite(ctx, req.ID)
if err != nil {
return getHostQueryReportResponse{Err: err}, nil
}
reportResults, lastFetched, err := svc.GetHostQueryReportResults(ctx, req.ID, req.QueryID)
if err != nil {
return getHostQueryReportResponse{Err: err}, nil
}
appConfig, err := svc.AppConfigObfuscated(ctx)
if err != nil {
return getHostQueryReportResponse{Err: err}, nil
}
isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID, appConfig.ServerSettings.GetQueryReportCap())
if err != nil {
return getHostQueryReportResponse{Err: err}, nil
}
return getHostQueryReportResponse{
QueryID: req.QueryID,
HostID: host.ID,
HostName: host.DisplayName(),
LastFetched: lastFetched,
ReportClipped: isClipped,
Results: reportResults,
}, nil
}
func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint, queryID uint) ([]fleet.HostQueryReportResult, *time.Time, error) {
query, err := svc.ds.Query(ctx, queryID)
if err != nil {
setAuthCheckedOnPreAuthErr(ctx)
return nil, nil, ctxerr.Wrap(ctx, err, "get query from datastore")
}
if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil {
return nil, nil, err
}
rows, err := svc.ds.QueryResultRowsForHost(ctx, queryID, hostID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get query result rows for host")
}
if len(rows) == 0 {
return []fleet.HostQueryReportResult{}, nil, nil
}
var lastFetched *time.Time
result := make([]fleet.HostQueryReportResult, 0, len(rows))
for _, row := range rows {
fetched := row.LastFetched // copy to avoid loop reuse issue
lastFetched = &fetched // need to return value even if data is nil
if row.Data != nil {
columns := map[string]string{}
if err := json.Unmarshal(*row.Data, &columns); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "unmarshal query result row data")
}
result = append(result, fleet.HostQueryReportResult{Columns: columns})
}
}
return result, lastFetched, nil
}
func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, []*fleet.Host, error) {
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, nil, nil, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
// Load hosts, either from label if provided or from all hosts.
var hosts []*fleet.Host
var err error
if lid != nil {
hosts, err = svc.ds.ListHostsInLabel(ctx, filter, *lid, opt)
} else {
opt.DisableIssues = true // intentionally ignore failing policies
hosts, err = svc.ds.ListHosts(ctx, filter, opt)
}
if err != nil {
return nil, nil, nil, err
}
if len(hosts) == 0 {
return nil, nil, nil, nil
}
hostIDs := make([]uint, 0, len(hosts))
hostNames := make([]string, 0, len(hosts))
for _, h := range hosts {
hostIDs = append(hostIDs, h.ID)
hostNames = append(hostNames, h.DisplayName())
}
return hostIDs, hostNames, hosts, nil
}
////////////////////////////////////////////////////////////////////////////////
// List Host Device Mappings
////////////////////////////////////////////////////////////////////////////////
type listHostDeviceMappingRequest struct {
ID uint `url:"id"`
}
type listHostDeviceMappingResponse struct {
HostID uint `json:"host_id"`
DeviceMapping []*fleet.HostDeviceMapping `json:"device_mapping"`
Err error `json:"error,omitempty"`
}
func (r listHostDeviceMappingResponse) Error() error { return r.Err }
// listHostDeviceMappingEndpoint
// Deprecated: Emails are now included in host details endpoint /api/_version_/fleet/hosts/{id}
func listHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listHostDeviceMappingRequest)
dms, err := svc.ListHostDeviceMapping(ctx, req.ID)
if err != nil {
return listHostDeviceMappingResponse{Err: err}, nil
}
return listHostDeviceMappingResponse{HostID: req.ID, DeviceMapping: dms}, nil
}
func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host")
}
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, err
}
}
return svc.ds.ListHostDeviceMapping(ctx, id)
}
////////////////////////////////////////////////////////////////////////////////
// Put Custom Host Device Mapping
////////////////////////////////////////////////////////////////////////////////
type putHostDeviceMappingRequest struct {
ID uint `url:"id"`
Email string `json:"email"`
}
type putHostDeviceMappingResponse struct {
HostID uint `json:"host_id"`
DeviceMapping []*fleet.HostDeviceMapping `json:"device_mapping"`
Err error `json:"error,omitempty"`
}
func (r putHostDeviceMappingResponse) Error() error { return r.Err }
// putHostDeviceMappingEndpoint
// Deprecated: Because the corresponding GET endpoint is deprecated.
func putHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*putHostDeviceMappingRequest)
dms, err := svc.SetCustomHostDeviceMapping(ctx, req.ID, req.Email)
if err != nil {
return putHostDeviceMappingResponse{Err: err}, nil
}
return putHostDeviceMappingResponse{HostID: req.ID, DeviceMapping: dms}, nil
}
func (svc *Service) SetCustomHostDeviceMapping(ctx context.Context, hostID uint, email string) ([]*fleet.HostDeviceMapping, error) {
isInstallerSource := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnOrbitToken)
if !isInstallerSource {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host")
}
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
return nil, err
}
}
source := fleet.DeviceMappingCustomOverride
if isInstallerSource {
source = fleet.DeviceMappingCustomInstaller
}
return svc.ds.SetOrUpdateCustomHostDeviceMapping(ctx, hostID, email, source)
}
////////////////////////////////////////////////////////////////////////////////
// MDM
////////////////////////////////////////////////////////////////////////////////
type getHostMDMRequest struct {
ID uint `url:"id"`
}
type getHostMDMResponse struct {
*fleet.HostMDM
Err error `json:"error,omitempty"`
}
func (r getHostMDMResponse) Error() error { return r.Err }
func getHostMDM(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostMDMRequest)
mdm, err := svc.MDMData(ctx, req.ID)
if err != nil {
return getHostMDMResponse{Err: err}, nil
}
return getHostMDMResponse{HostMDM: mdm}, nil
}
type getHostMDMSummaryResponse struct {
fleet.AggregatedMDMData
Err error `json:"error,omitempty"`
}
type getHostMDMSummaryRequest struct {
TeamID *uint `query:"team_id,optional"`
Platform string `query:"platform,optional"`
}
func (r getHostMDMSummaryResponse) Error() error { return r.Err }
func getHostMDMSummary(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostMDMSummaryRequest)
resp := getHostMDMSummaryResponse{}
var err error
resp.AggregatedMDMData, err = svc.AggregatedMDMData(ctx, req.TeamID, req.Platform)
if err != nil {
return getHostMDMSummaryResponse{Err: err}, nil
}
return resp, nil
}
////////////////////////////////////////////////////////////////////////////////
// Macadmins
////////////////////////////////////////////////////////////////////////////////
type getMacadminsDataRequest struct {
ID uint `url:"id"`
}
type getMacadminsDataResponse struct {
Err error `json:"error,omitempty"`
Macadmins *fleet.MacadminsData `json:"macadmins"`
}
func (r getMacadminsDataResponse) Error() error { return r.Err }
func getMacadminsDataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getMacadminsDataRequest)
data, err := svc.MacadminsData(ctx, req.ID)
if err != nil {
return getMacadminsDataResponse{Err: err}, nil
}
return getMacadminsDataResponse{Macadmins: data}, nil
}
func (svc *Service) MacadminsData(ctx context.Context, id uint) (*fleet.MacadminsData, error) {
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "find host for macadmins")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, err
}
}
var munkiInfo *fleet.HostMunkiInfo
switch version, err := svc.ds.GetHostMunkiVersion(ctx, id); {
case err != nil && !fleet.IsNotFound(err):
return nil, err
case err == nil:
munkiInfo = &fleet.HostMunkiInfo{Version: version}
}
var mdm *fleet.HostMDM
switch hmdm, err := svc.ds.GetHostMDM(ctx, id); {
case err != nil && !fleet.IsNotFound(err):
return nil, err
case err == nil:
mdm = hmdm
}
var munkiIssues []*fleet.HostMunkiIssue
issues, err := svc.ds.GetHostMunkiIssues(ctx, id)
if err != nil {
return nil, err
}
munkiIssues = issues
if munkiInfo == nil && mdm == nil && len(munkiIssues) == 0 {
return nil, nil
}
data := &fleet.MacadminsData{
Munki: munkiInfo,
MDM: mdm,
MunkiIssues: munkiIssues,
}
return data, nil
}
////////////////////////////////////////////////////////////////////////////////
// Aggregated Macadmins
////////////////////////////////////////////////////////////////////////////////
type getAggregatedMacadminsDataRequest struct {
TeamID *uint `query:"team_id,optional"`
}
type getAggregatedMacadminsDataResponse struct {
Err error `json:"error,omitempty"`
Macadmins *fleet.AggregatedMacadminsData `json:"macadmins"`
}
func (r getAggregatedMacadminsDataResponse) Error() error { return r.Err }
func getAggregatedMacadminsDataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getAggregatedMacadminsDataRequest)
data, err := svc.AggregatedMacadminsData(ctx, req.TeamID)
if err != nil {
return getAggregatedMacadminsDataResponse{Err: err}, nil
}
return getAggregatedMacadminsDataResponse{Macadmins: data}, nil
}
func (svc *Service) AggregatedMacadminsData(ctx context.Context, teamID *uint) (*fleet.AggregatedMacadminsData, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
return nil, err
}
if teamID != nil {
_, err := svc.ds.Team(ctx, *teamID)
if err != nil {
return nil, err
}
}
agg := &fleet.AggregatedMacadminsData{}
versions, munkiUpdatedAt, err := svc.ds.AggregatedMunkiVersion(ctx, teamID)
if err != nil {
return nil, err
}
agg.MunkiVersions = versions
issues, munkiIssUpdatedAt, err := svc.ds.AggregatedMunkiIssues(ctx, teamID)
if err != nil {
return nil, err
}
agg.MunkiIssues = issues
var mdmUpdatedAt, mdmSolutionsUpdatedAt time.Time
agg.MDMStatus, mdmUpdatedAt, err = svc.ds.AggregatedMDMStatus(ctx, teamID, "darwin")
if err != nil {
return nil, err
}
agg.MDMSolutions, mdmSolutionsUpdatedAt, err = svc.ds.AggregatedMDMSolutions(ctx, teamID, "darwin")
if err != nil {
return nil, err
}
agg.CountsUpdatedAt = munkiUpdatedAt
if munkiIssUpdatedAt.After(agg.CountsUpdatedAt) {
agg.CountsUpdatedAt = munkiIssUpdatedAt
}
if mdmUpdatedAt.After(agg.CountsUpdatedAt) {
agg.CountsUpdatedAt = mdmUpdatedAt
}
if mdmSolutionsUpdatedAt.After(agg.CountsUpdatedAt) {
agg.CountsUpdatedAt = mdmSolutionsUpdatedAt
}
return agg, nil
}
func (svc *Service) MDMData(ctx context.Context, id uint) (*fleet.HostMDM, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "find host for MDMData")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, err
}
hmdm, err := svc.ds.GetHostMDM(ctx, id)
switch {
case err == nil:
return hmdm, nil
case fleet.IsNotFound(err):
return nil, nil
default:
return nil, ctxerr.Wrap(ctx, err, "get host mdm")
}
}
func (svc *Service) AggregatedMDMData(ctx context.Context, teamID *uint, platform string) (fleet.AggregatedMDMData, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
return fleet.AggregatedMDMData{}, err
}
mdmStatus, mdmStatusUpdatedAt, err := svc.ds.AggregatedMDMStatus(ctx, teamID, platform)
if err != nil {
return fleet.AggregatedMDMData{}, err
}
mdmSolutions, mdmSolutionsUpdatedAt, err := svc.ds.AggregatedMDMSolutions(ctx, teamID, platform)
if err != nil {
return fleet.AggregatedMDMData{}, err
}
countsUpdatedAt := mdmStatusUpdatedAt
if mdmStatusUpdatedAt.Before(mdmSolutionsUpdatedAt) {
countsUpdatedAt = mdmSolutionsUpdatedAt
}
return fleet.AggregatedMDMData{
MDMStatus: mdmStatus,
MDMSolutions: mdmSolutions,
CountsUpdatedAt: countsUpdatedAt,
}, nil
}
////////////////////////////////////////////////////////////////////////////////
// Hosts Report in CSV downloadable file
////////////////////////////////////////////////////////////////////////////////
type hostsReportRequest struct {
Opts fleet.HostListOptions `url:"host_options"`
LabelID *uint `query:"label_id,optional"`
Format string `query:"format"`
Columns string `query:"columns,optional"`
}
type hostsReportResponse struct {
Columns []string `json:"-"` // used to control the generated csv, see the HijackRender method
Hosts []*fleet.HostResponse `json:"-"` // they get rendered explicitly, in csv
Err error `json:"error,omitempty"`
}
func (r hostsReportResponse) Error() error { return r.Err }
func (r hostsReportResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
// post-process the Device Mappings for CSV rendering
for _, h := range r.Hosts {
if h.DeviceMapping != nil {
// return the list of emails, comma-separated, as part of that single CSV field
var dms []struct {
Email string `json:"email"`
}
if err := json.Unmarshal(*h.DeviceMapping, &dms); err != nil {
// log the error but keep going
logging.WithErr(ctx, err)
continue
}
var sb strings.Builder
for i, dm := range dms {
if i > 0 {
sb.WriteString(",")
}
sb.WriteString(dm.Email)
}
h.CSVDeviceMapping = sb.String()
}
}
var buf bytes.Buffer
if err := gocsv.Marshal(r.Hosts, &buf); err != nil {
logging.WithErr(ctx, err)
endpoint_utils.EncodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w)
return
}
returnAll := len(r.Columns) == 0
var outRows [][]string
if !returnAll {
// read back the CSV to filter out any unwanted columns
recs, err := csv.NewReader(&buf).ReadAll()
if err != nil {
logging.WithErr(ctx, err)
endpoint_utils.EncodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w)
return
}
if len(recs) > 0 {
// map the header names to their field index
hdrs := make(map[string]int, len(recs))
for i, hdr := range recs[0] {
hdrs[hdr] = i
}
outRows = make([][]string, len(recs))
for i, rec := range recs {
for _, col := range r.Columns {
colIx, ok := hdrs[col]
if !ok {
// invalid column name - it would be nice to catch this in the
// endpoint before processing the results, but it would require
// duplicating the list of columns from the Host's struct tags to a
// map and keep this in sync, for what is essentially a programmer
// mistake that should be caught and corrected early.
endpoint_utils.EncodeError(ctx, &fleet.BadRequestError{Message: fmt.Sprintf("invalid column name: %q", col)}, w)
return
}
outRows[i] = append(outRows[i], rec[colIx])
}
}
}
}
w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="Hosts %s.csv"`, time.Now().Format("2006-01-02")))
w.Header().Set("Content-Type", "text/csv")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
var err error
if returnAll {
_, err = io.Copy(w, &buf)
} else {
err = csv.NewWriter(w).WriteAll(outRows)
}
if err != nil {
logging.WithErr(ctx, err)
}
}
func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*hostsReportRequest)
// for now, only csv format is allowed
if req.Format != "csv" {
// prevent returning an "unauthorized" error, we want that specific error
if az, ok := authzctx.FromContext(ctx); ok {
az.SetChecked()
}
err := ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("format", "unsupported or unspecified report format").
WithStatus(http.StatusUnsupportedMediaType))
return hostsReportResponse{Err: err}, nil
}
req.Opts.AdditionalFilters = nil
req.Opts.Page = 0
req.Opts.PerPage = 0 // explicitly disable any limit, we want all matching hosts
req.Opts.After = ""
req.Opts.DeviceMapping = false
rawCols := strings.Split(req.Columns, ",")
var cols []string
for _, rawCol := range rawCols {
if rawCol = strings.TrimSpace(rawCol); rawCol != "" {
cols = append(cols, rawCol)
if rawCol == "device_mapping" {
req.Opts.DeviceMapping = true
}
}
}
if len(cols) == 0 {
// enable device_mapping retrieval, as no column means all columns
req.Opts.DeviceMapping = true
}
var (
hosts []*fleet.Host
err error
)
if req.LabelID == nil {
hosts, err = svc.ListHosts(ctx, req.Opts)
} else {
hosts, err = svc.ListHostsInLabel(ctx, *req.LabelID, req.Opts)
}
if err != nil {
return hostsReportResponse{Err: err}, nil
}
hostResps := make([]*fleet.HostResponse, len(hosts))
for i, h := range hosts {
hr := fleet.HostResponseForHost(ctx, svc, h)
hostResps[i] = hr
}
return hostsReportResponse{Columns: cols, Hosts: hostResps}, nil
}
func (svc *Service) ListLabelsForHost(ctx context.Context, hostID uint) ([]*fleet.Label, error) {
// require list hosts permission to view this information
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
return svc.ds.ListLabelsForHost(ctx, hostID)
}
type osVersionsRequest struct {
fleet.ListOptions
TeamID *uint `query:"team_id,optional"`
Platform *string `query:"platform,optional"`
Name *string `query:"os_name,optional"`
Version *string `query:"os_version,optional"`
}
type osVersionsResponse struct {
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Count int `json:"count"`
CountsUpdatedAt *time.Time `json:"counts_updated_at"`
OSVersions []fleet.OSVersion `json:"os_versions"`
Err error `json:"error,omitempty"`
}
func (r osVersionsResponse) Error() error { return r.Err }
func osVersionsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*osVersionsRequest)
osVersions, count, metadata, err := svc.OSVersions(ctx, req.TeamID, req.Platform, req.Name, req.Version, req.ListOptions, false)
if err != nil {
return &osVersionsResponse{Err: err}, nil
}
return &osVersionsResponse{
CountsUpdatedAt: &osVersions.CountsUpdatedAt,
OSVersions: osVersions.OSVersions,
Meta: metadata,
Count: count,
}, nil
}
func (svc *Service) OSVersions(ctx context.Context, teamID *uint, platform *string, name *string, version *string, opts fleet.ListOptions, includeCVSS bool) (*fleet.OSVersions, int, *fleet.PaginationMetadata, error) {
var count int
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
return nil, count, nil, err
}
if name != nil && version == nil {
return nil, count, nil, &fleet.BadRequestError{Message: "Cannot specify os_name without os_version"}
}
if name == nil && version != nil {
return nil, count, nil, &fleet.BadRequestError{Message: "Cannot specify os_version without os_name"}
}
if opts.OrderKey != "" && opts.OrderKey != "hosts_count" {
return nil, count, nil, &fleet.BadRequestError{Message: "Invalid order key"}
}
if teamID != nil {
// This auth check ensures we return 403 if the user doesn't have access to the team
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, count, nil, err
}
if *teamID != 0 {
exists, err := svc.ds.TeamExists(ctx, *teamID)
if err != nil {
return nil, count, nil, ctxerr.Wrap(ctx, err, "checking if team exists")
} else if !exists {
return nil, count, nil, fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)).
WithStatus(http.StatusNotFound)
}
}
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, count, nil, fleet.ErrNoContext
}
osVersions, err := svc.ds.OSVersions(
ctx, &fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
TeamID: teamID,
}, platform, name, version,
)
if err != nil && fleet.IsNotFound(err) {
// It is possible that os exists, but aggregation job has not run yet.
osVersions = &fleet.OSVersions{}
} else if err != nil {
return nil, count, nil, err
}
// Use batch query for better performance.
// Note: The OSVersions API endpoint does not include kernels (ds.ListKernelsByOS).
if len(osVersions.OSVersions) > 0 {
vulnsMap, err := svc.ds.ListVulnsByMultipleOSVersions(ctx, osVersions.OSVersions, includeCVSS, teamID)
if err != nil {
return nil, count, nil, ctxerr.Wrap(ctx, err, "list vulns by multiple os versions")
}
// Populate each OS version with its vulnerabilities
for i := range osVersions.OSVersions {
osV := &osVersions.OSVersions[i]
key := fmt.Sprintf("%s-%s", osV.NameOnly, osV.Version)
// Populate GeneratedCPEs for Darwin
if osV.Platform == "darwin" {
osV.GeneratedCPEs = []string{
fmt.Sprintf("cpe:2.3:o:apple:macos:%s:*:*:*:*:*:*:*", osV.Version),
fmt.Sprintf("cpe:2.3:o:apple:mac_os_x:%s:*:*:*:*:*:*:*", osV.Version),
}
}
// Populate Vulnerabilities from batch result
osV.Vulnerabilities = make(fleet.Vulnerabilities, 0) // avoid null in JSON
if vulns, ok := vulnsMap[key]; ok {
for _, vuln := range vulns {
vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE)
osV.Vulnerabilities = append(osV.Vulnerabilities, vuln)
}
}
// Initialize Kernels array to avoid null in JSON
osV.Kernels = make([]*fleet.Kernel, 0)
}
}
if opts.OrderKey == "hosts_count" && opts.OrderDirection == fleet.OrderAscending {
sort.Slice(osVersions.OSVersions, func(i, j int) bool {
return osVersions.OSVersions[i].HostsCount < osVersions.OSVersions[j].HostsCount
})
} else {
sort.Slice(osVersions.OSVersions, func(i, j int) bool {
return osVersions.OSVersions[i].HostsCount > osVersions.OSVersions[j].HostsCount
})
}
count = len(osVersions.OSVersions)
var metaData *fleet.PaginationMetadata
osVersions.OSVersions, metaData = paginateOSVersions(osVersions.OSVersions, opts)
return osVersions, count, metaData, nil
}
func paginateOSVersions(slice []fleet.OSVersion, opts fleet.ListOptions) ([]fleet.OSVersion, *fleet.PaginationMetadata) {
metaData := &fleet.PaginationMetadata{
HasPreviousResults: opts.Page > 0,
}
if opts.PerPage == 0 {
return slice, metaData
}
start := opts.Page * opts.PerPage
if start >= uint(len(slice)) {
return []fleet.OSVersion{}, metaData
}
end := start + opts.PerPage
if end >= uint(len(slice)) {
end = uint(len(slice))
} else {
metaData.HasNextResults = true
}
return slice[start:end], metaData
}
type getOSVersionRequest struct {
ID uint `url:"id"`
TeamID *uint `query:"team_id,optional"`
}
type getOSVersionResponse struct {
CountsUpdatedAt *time.Time `json:"counts_updated_at"`
OSVersion *fleet.OSVersion `json:"os_version"`
Err error `json:"error,omitempty"`
}
func (r getOSVersionResponse) Error() error { return r.Err }
func getOSVersionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getOSVersionRequest)
osVersion, updateTime, err := svc.OSVersion(ctx, req.ID, req.TeamID, false)
if err != nil {
return getOSVersionResponse{Err: err}, nil
}
if osVersion == nil {
osVersion = &fleet.OSVersion{}
}
return getOSVersionResponse{CountsUpdatedAt: updateTime, OSVersion: osVersion}, nil
}
func (svc *Service) OSVersion(ctx context.Context, osID uint, teamID *uint, includeCVSS bool) (*fleet.OSVersion, *time.Time, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
return nil, nil, err
}
if teamID != nil && *teamID != 0 {
// This auth check ensures we return 403 if the user doesn't have access to the team
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil {
return nil, nil, err
}
exists, err := svc.ds.TeamExists(ctx, *teamID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "checking if team exists")
} else if !exists {
return nil, nil, fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)).
WithStatus(http.StatusNotFound)
}
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, nil, fleet.ErrNoContext
}
osVersion, updateTime, err := svc.ds.OSVersion(
ctx, osID, &fleet.TeamFilter{
User: vc.User,
IncludeObserver: true,
TeamID: teamID,
},
)
if err != nil {
if fleet.IsNotFound(err) {
// We return an empty result here to be consistent with the fleet/os_versions behavior.
// It is possible the os version exists, but the aggregation job has not run yet.
return nil, nil, nil
}
return nil, nil, err
}
if osVersion != nil {
if err = svc.populateOSVersionDetails(ctx, osVersion, includeCVSS, teamID, true); err != nil {
return nil, nil, err
}
}
return osVersion, updateTime, nil
}
// PopulateOSVersionDetails populates the GeneratedCPEs and Vulnerabilities for an OSVersion.
func (svc *Service) populateOSVersionDetails(ctx context.Context, osVersion *fleet.OSVersion, includeCVSS bool, teamID *uint, includeKernels bool) error {
// Populate GeneratedCPEs
if osVersion.Platform == "darwin" {
osVersion.GeneratedCPEs = []string{
fmt.Sprintf("cpe:2.3:o:apple:macos:%s:*:*:*:*:*:*:*", osVersion.Version),
fmt.Sprintf("cpe:2.3:o:apple:mac_os_x:%s:*:*:*:*:*:*:*", osVersion.Version),
}
}
// Populate Vulnerabilities
vulns, err := svc.ds.ListVulnsByOsNameAndVersion(ctx, osVersion.NameOnly, osVersion.Version, includeCVSS, teamID)
if err != nil {
return err
}
osVersion.Vulnerabilities = make(fleet.Vulnerabilities, 0) // avoid null in JSON
osVersion.Kernels = make([]*fleet.Kernel, 0) // avoid null in JSON
for _, vuln := range vulns {
vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE)
osVersion.Vulnerabilities = append(osVersion.Vulnerabilities, vuln)
}
if fleet.IsLinux(osVersion.Platform) && includeKernels {
kernels, err := svc.ds.ListKernelsByOS(ctx, osVersion.OSVersionID, teamID)
if err != nil {
return err
}
if len(kernels) > 0 {
osVersion.Kernels = kernels
}
}
return nil
}
////////////////////////////////////////////////////////////////////////////////
// Encryption Key
////////////////////////////////////////////////////////////////////////////////
type getHostEncryptionKeyRequest struct {
ID uint `url:"id"`
}
type getHostEncryptionKeyResponse struct {
Err error `json:"error,omitempty"`
EncryptionKey *fleet.HostDiskEncryptionKey `json:"encryption_key,omitempty"`
HostID uint `json:"host_id,omitempty"`
}
func (r getHostEncryptionKeyResponse) Error() error { return r.Err }
func getHostEncryptionKey(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostEncryptionKeyRequest)
key, err := svc.HostEncryptionKey(ctx, req.ID)
if err != nil {
return getHostEncryptionKeyResponse{Err: err}, nil
}
return getHostEncryptionKeyResponse{EncryptionKey: key, HostID: req.ID}, nil
}
func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
}
// Permissions to read encryption keys are exactly the same
// as the ones required to read hosts.
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, err
}
level.Info(svc.logger).Log("msg", "retrieving host disk encryption key", "host_id", host.ID, "host_name", host.DisplayName())
key, err := svc.getHostDiskEncryptionKey(ctx, host)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
}
err = svc.NewActivity(
ctx,
authz.UserFromContext(ctx),
fleet.ActivityTypeReadHostDiskEncryptionKey{
HostID: host.ID,
HostDisplayName: host.DisplayName(),
},
)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create read host disk encryption key activity")
}
return key, nil
}
func (svc *Service) getHostDiskEncryptionKey(ctx context.Context, host *fleet.Host) (*fleet.HostDiskEncryptionKey, error) {
// First, determine the decryption function based on the host platform and configuration.
var decryptFn func(b64 string) (string, error)
switch {
case host.IsLUKSSupported():
if svc.config.Server.PrivateKey == "" {
return nil, errors.New("private key is unavailable")
}
decryptFn = func(b64 string) (string, error) {
return mdm.DecodeAndDecrypt(b64, svc.config.Server.PrivateKey)
}
case host.FleetPlatform() == "windows":
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
return nil, err
}
cert, _, _, err := svc.config.MDM.MicrosoftWSTEP()
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key")
}
decryptFn = func(b64 string) (string, error) {
b, err := mdm.DecryptBase64CMS(b64, cert.Leaf, cert.PrivateKey)
return string(b), err
}
default:
// Fallback to using Apple MDM CA assets for decryption.
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
return nil, err
}
cert, err := assets.CAKeyPair(ctx, svc.ds)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database")
}
decryptFn = func(b64 string) (string, error) {
b, err := mdm.DecryptBase64CMS(b64, cert.Leaf, cert.PrivateKey)
return string(b), err
}
}
// Next, get host disk encryption key and archived key, if any
key, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
}
archivedKey, err := svc.ds.GetHostArchivedDiskEncryptionKey(ctx, host)
if err != nil && !fleet.IsNotFound(err) {
return nil, ctxerr.Wrap(ctx, err, "getting host archived disk encryption key")
}
if key == nil && archivedKey == nil {
return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not set")
}
var decrypted string
var decryptErrs []error
if key != nil && key.Base64Encrypted != "" {
// Assume the current key is not decryptable.
key.Decryptable = ptr.Bool(false)
key.DecryptedValue = ""
// Try to decrypt the current key.
decrypted, err = decryptFn(key.Base64Encrypted)
switch {
case err != nil:
decryptErrs = append(decryptErrs, fmt.Errorf("decrypting host disk encryption key: %w", err))
case decrypted == "":
decryptErrs = append(decryptErrs, fmt.Errorf("decrypted host disk encryption key is empty for host %d", host.ID))
default:
level.Info(svc.logger).Log("msg", "decrypted current host disk encryption key", "host_id", host.ID)
key.Decryptable = ptr.Bool(true)
key.DecryptedValue = decrypted
return key, nil // Return the decrypted key immediately if successful.
}
}
// If we have an archived key, try to decrypt it.
if archivedKey != nil && archivedKey.Base64Encrypted != "" {
decrypted, err = decryptFn(archivedKey.Base64Encrypted)
switch {
case err != nil:
decryptErrs = append(decryptErrs, fmt.Errorf("decrypting archived disk encryption key: %w", err))
case decrypted == "":
decryptErrs = append(decryptErrs, fmt.Errorf("decrypted archived disk encryption key is empty for host %d", host.ID))
default:
level.Info(svc.logger).Log("msg", "decrypted archived host disk encryption key", "host_id", host.ID)
// We successfully decrypted the archived key so we'll use it in place of the current key.
key = &fleet.HostDiskEncryptionKey{
HostID: host.ID,
Base64Encrypted: archivedKey.Base64Encrypted,
Base64EncryptedSalt: archivedKey.Base64EncryptedSalt,
KeySlot: archivedKey.KeySlot,
Decryptable: ptr.Bool(true),
DecryptedValue: decrypted,
UpdatedAt: archivedKey.CreatedAt,
}
}
}
if len(decryptErrs) > 0 {
// If we have any decryption errors, log them.
level.Error(svc.logger).Log("msg", "decryption errors for host disk encryption key", "host_id", host.ID, "errors", errors.Join(decryptErrs...))
}
if key == nil || key.DecryptedValue == "" {
// If we couldn't decrypt any key, return an error.
return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key")
}
return key, nil
}
////////////////////////////////////////////////////////////////////////////////
// Host Health
////////////////////////////////////////////////////////////////////////////////
type getHostHealthRequest struct {
ID uint `url:"id"`
}
type getHostHealthResponse struct {
Err error `json:"error,omitempty"`
HostID uint `json:"host_id,omitempty"`
HostHealth *fleet.HostHealth `json:"health,omitempty"`
}
func (r getHostHealthResponse) Error() error { return r.Err }
func getHostHealthEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostHealthRequest)
hh, err := svc.GetHostHealth(ctx, req.ID)
if err != nil {
return getHostHealthResponse{Err: err}, nil
}
// remove TeamID as it's needed for authorization internally but is not part of the external API
hh.TeamID = nil
return getHostHealthResponse{HostID: req.ID, HostHealth: hh}, nil
}
func (svc *Service) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHealth, error) {
svc.authz.SkipAuthorization(ctx)
hh, err := svc.ds.GetHostHealth(ctx, id)
if err != nil {
return nil, err
}
if err := svc.authz.Authorize(ctx, hh, fleet.ActionRead); err != nil {
return nil, err
}
return hh, nil
}
func (svc *Service) HostLiteByIdentifier(ctx context.Context, identifier string) (*fleet.HostLite, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLiteByIdentifier(ctx, identifier)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host by identifier")
}
if err := svc.authz.Authorize(ctx, fleet.Host{
TeamID: host.TeamID,
}, fleet.ActionRead); err != nil {
return nil, err
}
return host, nil
}
func (svc *Service) HostLiteByID(ctx context.Context, id uint) (*fleet.HostLite, error) {
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, err
}
host, err := svc.ds.HostLiteByID(ctx, id)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host by id")
}
if err := svc.authz.Authorize(ctx, fleet.Host{
TeamID: host.TeamID,
}, fleet.ActionRead); err != nil {
return nil, err
}
return host, nil
}
func hostListOptionsFromFilters(filter *map[string]interface{}) (*fleet.HostListOptions, *uint, error) {
var labelID *uint
if filter == nil {
return nil, nil, nil
}
opt := fleet.HostListOptions{}
for k, v := range *filter {
switch k {
case "label_id":
if l, ok := v.(float64); ok { // json unmarshals numbers as float64
lid := uint(l)
labelID = &lid
} else {
return nil, nil, badRequest("label_id must be a number")
}
case "team_id":
if teamID, ok := v.(float64); ok { // json unmarshals numbers as float64
teamID := uint(teamID)
opt.TeamFilter = &teamID
} else {
return nil, nil, badRequest("team_id must be a number")
}
case "status":
status, ok := v.(string)
if !ok {
return nil, nil, badRequest("status must be a string")
}
if !fleet.HostStatus(status).IsValid() {
return nil, nil, badRequest("status must be one of: new, online, offline, missing")
}
opt.StatusFilter = fleet.HostStatus(status)
case "query":
query, ok := v.(string)
if !ok {
return nil, nil, badRequest("query must be a string")
}
if query == "" {
return nil, nil, badRequest("query must not be empty")
}
opt.MatchQuery = query
default:
return nil, nil, badRequest(fmt.Sprintf("unknown filter key: %s", k))
}
}
return &opt, labelID, nil
}
////////////////////////////////////////////////////////////////////////////////
// Host Labels
////////////////////////////////////////////////////////////////////////////////
type addLabelsToHostRequest struct {
ID uint `url:"id"`
Labels []string `json:"labels"`
}
type addLabelsToHostResponse struct {
Err error `json:"error,omitempty"`
}
func (r addLabelsToHostResponse) Error() error { return r.Err }
func addLabelsToHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*addLabelsToHostRequest)
if err := svc.AddLabelsToHost(ctx, req.ID, req.Labels); err != nil {
return addLabelsToHostResponse{Err: err}, nil
}
return addLabelsToHostResponse{}, nil
}
func (svc *Service) AddLabelsToHost(ctx context.Context, id uint, labelNames []string) error {
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "load host")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil {
return ctxerr.Wrap(ctx, err)
}
labelIDs, err := svc.validateLabelNames(ctx, "add", labelNames)
if err != nil {
return err
}
if len(labelIDs) == 0 {
return nil
}
if err := svc.ds.AddLabelsToHost(ctx, host.ID, labelIDs); err != nil {
return ctxerr.Wrap(ctx, err, "add labels to host")
}
return nil
}
type removeLabelsFromHostRequest struct {
ID uint `url:"id"`
Labels []string `json:"labels"`
}
type removeLabelsFromHostResponse struct {
Err error `json:"error,omitempty"`
}
func (r removeLabelsFromHostResponse) Error() error { return r.Err }
func removeLabelsFromHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*removeLabelsFromHostRequest)
if err := svc.RemoveLabelsFromHost(ctx, req.ID, req.Labels); err != nil {
return removeLabelsFromHostResponse{Err: err}, nil
}
return removeLabelsFromHostResponse{}, nil
}
func (svc *Service) RemoveLabelsFromHost(ctx context.Context, id uint, labelNames []string) error {
host, err := svc.ds.HostLite(ctx, id)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return ctxerr.Wrap(ctx, err, "load host")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil {
return ctxerr.Wrap(ctx, err)
}
labelIDs, err := svc.validateLabelNames(ctx, "remove", labelNames)
if err != nil {
return err
}
if len(labelIDs) == 0 {
return nil
}
if err := svc.ds.RemoveLabelsFromHost(ctx, host.ID, labelIDs); err != nil {
return ctxerr.Wrap(ctx, err, "remove labels from host")
}
return nil
}
func (svc *Service) validateLabelNames(ctx context.Context, action string, labelNames []string) ([]uint, error) {
if len(labelNames) == 0 {
return nil, nil
}
labelNames = server.RemoveDuplicatesFromSlice(labelNames)
// Filter out empty label string.
for i, labelName := range labelNames {
if labelName == "" {
labelNames = append(labelNames[:i], labelNames[i+1:]...)
break
}
}
if len(labelNames) == 0 {
return nil, nil
}
labels, err := svc.ds.LabelIDsByName(ctx, labelNames)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
}
var labelsNotFound []string
for _, labelName := range labelNames {
if _, ok := labels[labelName]; !ok {
labelsNotFound = append(labelsNotFound, "\""+labelName+"\"")
}
}
if len(labelsNotFound) > 0 {
sort.Slice(labelsNotFound, func(i, j int) bool {
// Ignore quotes to sort.
return labelsNotFound[i][1:len(labelsNotFound[i])-1] < labelsNotFound[j][1:len(labelsNotFound[j])-1]
})
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf(
"Couldn't %s labels. Labels not found: %s. All labels must exist.",
action,
strings.Join(labelsNotFound, ", "),
),
}
}
var dynamicLabels []string
for labelName, labelID := range labels {
// we use a global admin filter because we want to get that label
// regardless of user roles
filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
label, _, err := svc.ds.Label(ctx, labelID, filter)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "load label from id")
}
if label.LabelMembershipType != fleet.LabelMembershipTypeManual {
dynamicLabels = append(dynamicLabels, "\""+labelName+"\"")
}
}
if len(dynamicLabels) > 0 {
sort.Slice(dynamicLabels, func(i, j int) bool {
// Ignore quotes to sort.
return dynamicLabels[i][1:len(dynamicLabels[i])-1] < dynamicLabels[j][1:len(dynamicLabels[j])-1]
})
return nil, &fleet.BadRequestError{
Message: fmt.Sprintf(
"Couldn't %s labels. Labels are dynamic: %s. Dynamic labels can not be assigned to hosts manually.",
action,
strings.Join(dynamicLabels, ", "),
),
}
}
labelIDs := make([]uint, 0, len(labels))
for _, labelID := range labels {
labelIDs = append(labelIDs, labelID)
}
return labelIDs, nil
}
////////////////////////////////////////////////////////////////////////////////
// Host Software
////////////////////////////////////////////////////////////////////////////////
type getHostSoftwareRequest struct {
ID uint `url:"id"`
fleet.HostSoftwareTitleListOptions
}
type getHostSoftwareResponse struct {
Software []*fleet.HostSoftwareWithInstaller `json:"software"`
Count int `json:"count"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Err error `json:"error,omitempty"`
}
func (r getHostSoftwareResponse) Error() error { return r.Err }
func (r getHostSoftwareRequest) DecodeRequest(ctx context.Context, req *http.Request) (interface{}, error) {
type defaultDecodeRequest struct {
ID uint `url:"id"`
fleet.HostSoftwareTitleListOptions
}
defaultDecoder := makeDecoder(defaultDecodeRequest{})
decoded, err := defaultDecoder(ctx, req)
if err != nil {
return nil, err
}
result := decoded.(*defaultDecodeRequest)
queryParams := req.URL.Query()
_, wasIncludeAvailableForInstallSet := queryParams["include_available_for_install"]
result.HostSoftwareTitleListOptions.IncludeAvailableForInstallExplicitlySet = wasIncludeAvailableForInstallSet
finalResult := getHostSoftwareRequest{
ID: result.ID,
HostSoftwareTitleListOptions: result.HostSoftwareTitleListOptions,
}
return &finalResult, nil
}
func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*getHostSoftwareRequest)
res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.HostSoftwareTitleListOptions)
if err != nil {
return getHostSoftwareResponse{Err: err}, nil
}
if res == nil {
res = []*fleet.HostSoftwareWithInstaller{}
}
return getHostSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil //nolint:gosec // dismiss G115
}
func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
// When accessed via "My device", we default to only showing inventory (excluding software available for install
// but not in inventory), unless we're asked to filter to self-service software only.
//
// Otherwise (e.g. host software UI within Fleet's admin interface), the default is to show both installed and
// available-for-install software, to maintain existing API behavior. This behavior can be explicitly overridden
// if needed (see opts.IncludeAvailableForInstallExplicitlySet).
var includeAvailableForInstall bool
var host *fleet.Host
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
includeAvailableForInstall = true
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
return nil, nil, err
}
h, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get host lite")
}
host = h
// Authorize again with team loaded now that we have team_id
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, nil, err
}
} else {
h, ok := hostctx.FromContext(ctx)
if !ok {
return nil, nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
}
host = h
}
mdmEnrolled, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "checking mdm enrollment status")
}
if opts.IncludeAvailableForInstallExplicitlySet {
includeAvailableForInstall = opts.IncludeAvailableForInstall
}
// cursor-based pagination is not supported
opts.ListOptions.After = ""
// custom ordering is not supported, always by name (but asc/desc is configurable)
opts.ListOptions.OrderKey = "name"
// always include metadata
opts.ListOptions.IncludeMetadata = true
opts.IncludeAvailableForInstall = includeAvailableForInstall || opts.SelfServiceOnly
opts.IsMDMEnrolled = mdmEnrolled
software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "list host software")
}
if len(software) > 0 {
var titleIDs []uint
softwareByTitleID := make(map[uint]*fleet.HostSoftwareWithInstaller)
for _, s := range software {
titleIDs = append(titleIDs, s.ID)
softwareByTitleID[s.ID] = s
}
categories, err := svc.ds.GetCategoriesForSoftwareTitles(ctx, titleIDs, host.TeamID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "getting categories for software titles")
}
for id, c := range categories {
if s, ok := softwareByTitleID[id]; ok && s.IsAppStoreApp() {
softwareByTitleID[id].AppStoreApp.Categories = c
}
if s, ok := softwareByTitleID[id]; ok && s.IsPackage() {
softwareByTitleID[id].SoftwarePackage.Categories = c
}
}
}
return software, meta, nil
}
////////////////////////////////////////////////////////////////////////////////
// Host Certificates
////////////////////////////////////////////////////////////////////////////////
type listHostCertificatesRequest struct {
ID uint `url:"id"`
fleet.ListOptions
}
var listHostCertificatesSortCols = map[string]bool{
"common_name": true,
"not_valid_after": true,
}
func (r *listHostCertificatesRequest) ValidateRequest() error {
if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] {
return badRequest("invalid order key")
}
return nil
}
type listHostCertificatesResponse struct {
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
Count uint `json:"count"`
Err error `json:"error,omitempty"`
}
func (r listHostCertificatesResponse) Error() error { return r.Err }
func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
req := request.(*listHostCertificatesRequest)
res, meta, err := svc.ListHostCertificates(ctx, req.ID, req.ListOptions)
if err != nil {
return listHostCertificatesResponse{Err: err}, nil
}
if res == nil {
res = []*fleet.HostCertificatePayload{}
}
return listHostCertificatesResponse{Certificates: res, Meta: meta, Count: meta.TotalResults}, nil
}
func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
host, err := svc.ds.HostLite(ctx, hostID)
if err != nil {
svc.authz.SkipAuthorization(ctx)
return nil, nil, ctxerr.Wrap(ctx, err, "failed to load host")
}
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
return nil, nil, err
}
}
// query/after not supported, always include pagination info
opts.MatchQuery = ""
opts.After = ""
opts.IncludeMetadata = true
// default sort order is common name ascending
if opts.OrderKey == "" {
opts.OrderKey = "common_name"
}
certs, meta, err := svc.ds.ListHostCertificates(ctx, hostID, opts)
if err != nil {
return nil, nil, err
}
payload := make([]*fleet.HostCertificatePayload, 0, len(certs))
for _, cert := range certs {
payload = append(payload, cert.ToPayload())
}
return payload, meta, nil
}