mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Resolves #41540 * Added new computed column to determinate whether query_result has data. * Added new index to query_results to to cover all query patterns. * Refactored queries used in host report page to improve performance. * Fixed various bugs with around query filtering for host reports.
4012 lines
127 KiB
Go
4012 lines
127 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"iter"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
|
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
|
|
|
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"
|
|
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
|
"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/mdm/nanodep/godep"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/worker"
|
|
"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) {
|
|
var isADEEnrolledIDevice bool
|
|
if host.Platform == "ipados" || host.Platform == "ios" {
|
|
ac, err := svc.AppConfigObfuscated(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ac.MDM.EnabledAndConfigured && license.IsPremium(ctx) {
|
|
hdep, err := svc.GetHostDEPAssignment(ctx, &host.Host)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, err
|
|
}
|
|
if hdep != nil {
|
|
isADEEnrolledIDevice = hdep.IsDEPAssignedToFleet()
|
|
}
|
|
}
|
|
}
|
|
|
|
// For ADE-enrolled iDevices, we get geolocation data via the MDM protocol
|
|
// and store it in Fleet.
|
|
var geoLoc *fleet.GeoLocation
|
|
if isADEEnrolledIDevice {
|
|
var err error
|
|
geoLoc, err = svc.GetHostLocationData(ctx, host.ID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
// For other types of hosts, use the MaxMind geoIP data (if it's enabled)
|
|
geoLoc = svc.LookupGeoIP(ctx, host.PublicIP)
|
|
}
|
|
|
|
return &HostDetailResponse{
|
|
HostDetail: *host,
|
|
Status: host.Status(time.Now()),
|
|
DisplayText: host.Hostname,
|
|
DisplayName: host.DisplayName(),
|
|
Geolocation: geoLoc,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostLocationData(ctx context.Context, hostID uint) (*fleet.GeoLocation, error) {
|
|
var ret fleet.GeoLocation
|
|
|
|
locData, err := svc.ds.GetHostLocationData(ctx, hostID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host location data")
|
|
}
|
|
|
|
ret.Geometry = &fleet.Geometry{
|
|
Coordinates: []float64{locData.Latitude, locData.Longitude},
|
|
}
|
|
|
|
return &ret, 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 }
|
|
|
|
type streamHostsResponse struct {
|
|
listHostsResponse
|
|
// HostResponseIterator is an iterator to stream hosts one by one.
|
|
HostResponseIterator iter.Seq2[*fleet.HostResponse, error] `json:"-"`
|
|
// MarshalJSON is an optional custom JSON marshaller for the response,
|
|
// used for testing purposes only.
|
|
MarshalJSON func(v any) ([]byte, error) `json:"-"`
|
|
}
|
|
|
|
func (r streamHostsResponse) Error() error { return r.Err }
|
|
|
|
func (r streamHostsResponse) HijackRender(_ context.Context, w http.ResponseWriter) {
|
|
aliasRules := endpointer.ExtractAliasRules(listHostsResponse{})
|
|
w.Header().Set("Content-Type", "application/json")
|
|
// If no iterator is provided, return a 500.
|
|
if r.HostResponseIterator == nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
fmt.Fprint(w, `{"error": "no host iterator provided"}`)
|
|
return
|
|
}
|
|
|
|
// From here on we're committing to a "successful" response,
|
|
// where the client will have to look for an `error` key
|
|
// in the JSON to determine actual status.
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
// Create a no-op flush function in case the ResponseWriter doesn't implement http.Flusher.
|
|
flush := func() {}
|
|
if f, ok := w.(http.Flusher); ok {
|
|
flush = f.Flush
|
|
}
|
|
|
|
// Use the default json marshaller unless a custom one is provided (for testing).
|
|
marshalJson := json.Marshal
|
|
if r.MarshalJSON != nil {
|
|
marshalJson = r.MarshalJSON
|
|
}
|
|
|
|
// Create function for returning errors in the JSON response.
|
|
marshalError := func(errString string) string {
|
|
errData, err := json.Marshal(map[string]string{"error": errString})
|
|
if err != nil {
|
|
return `{"error": "unknown error"}`
|
|
}
|
|
return string(errData[1 : len(errData)-1])
|
|
}
|
|
|
|
// Start the JSON object.
|
|
fmt.Fprint(w, `{`)
|
|
firstKey := true
|
|
|
|
t := reflect.TypeFor[listHostsResponse]()
|
|
v := reflect.ValueOf(r.listHostsResponse)
|
|
|
|
// The set of properties of listHostsResponse to consider for output.
|
|
fieldNames := []string{"Software", "SoftwareTitle", "MDMSolution", "MunkiIssue"}
|
|
|
|
// Iterate over the non-host keys in the response and write them if they are non-nil.
|
|
for i, fieldName := range fieldNames {
|
|
// Get the JSON tag name for the field.
|
|
fieldDef, _ := t.FieldByName(fieldName)
|
|
tag := fieldDef.Tag.Get("json")
|
|
parts := strings.Split(tag, ",")
|
|
name := parts[0]
|
|
|
|
// Get the actual value for the field.
|
|
fieldValue := v.FieldByName(fieldName)
|
|
if !fieldValue.IsValid() {
|
|
// Panic if the field is not found.
|
|
// This indicates a programming error (we put something bad in the keys list).
|
|
panic(fmt.Sprintf("field %s not found in listHostsResponse", fieldName))
|
|
}
|
|
if !fieldValue.IsNil() {
|
|
if i > 0 && !firstKey {
|
|
fmt.Fprint(w, `,`)
|
|
}
|
|
data, err := marshalJson(fieldValue.Interface())
|
|
if err != nil {
|
|
// On error, write the error key and return.
|
|
// Marshal the error as a JSON object without the surrounding braces,
|
|
// in case the error string itself contains characters that would break
|
|
// the JSON response.
|
|
fmt.Fprint(w, marshalError(fmt.Sprintf("marshaling %s: %s", name, err.Error())))
|
|
fmt.Fprint(w, `}`)
|
|
return
|
|
}
|
|
// Output the key and value.
|
|
fmt.Fprintf(w, `"%s":`, name)
|
|
fmt.Fprint(w, string(data))
|
|
flush()
|
|
firstKey = false
|
|
}
|
|
}
|
|
|
|
if !firstKey {
|
|
fmt.Fprint(w, `,`)
|
|
}
|
|
|
|
// Start the hosts array.
|
|
fmt.Fprint(w, `"hosts": [`)
|
|
firstHost := true
|
|
// Get hosts one at a time from the iterator and write them out.
|
|
for hostResp, err := range r.HostResponseIterator {
|
|
if err != nil {
|
|
fmt.Fprint(w, `],`)
|
|
fmt.Fprint(w, marshalError(fmt.Sprintf("getting host %s: ", err.Error())))
|
|
fmt.Fprint(w, `}`)
|
|
return
|
|
}
|
|
data, err := marshalJson(hostResp)
|
|
if err != nil {
|
|
fmt.Fprint(w, `],`)
|
|
fmt.Fprint(w, marshalError(fmt.Sprintf("marshaling host response: %s", err.Error())))
|
|
fmt.Fprint(w, `}`)
|
|
return
|
|
}
|
|
data = endpointer.DuplicateJSONKeys(data, aliasRules, endpointer.DuplicateJSONKeysOpts{Compact: true})
|
|
if !firstHost {
|
|
fmt.Fprint(w, `,`)
|
|
}
|
|
fmt.Fprint(w, string(data))
|
|
flush()
|
|
firstHost = false
|
|
}
|
|
// Close the hosts array and the JSON object.
|
|
fmt.Fprint(w, `]}`)
|
|
}
|
|
|
|
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 {
|
|
titleID := *req.Opts.SoftwareTitleIDFilter
|
|
|
|
// 1. Try full title for this team.
|
|
// Needed in order to grab display_name if it exists
|
|
st, err := svc.SoftwareTitleByID(ctx, titleID, req.Opts.TeamFilter)
|
|
switch {
|
|
case err == nil:
|
|
fmt.Println("regular")
|
|
softwareTitle = st
|
|
|
|
case fleet.IsNotFound(err):
|
|
// Not found: only ID + Name as string from helper.
|
|
name, displayName, errName := svc.SoftwareTitleNameForHostFilter(ctx, titleID)
|
|
if errName != nil && !fleet.IsNotFound(errName) {
|
|
return listHostsResponse{Err: errName}, nil
|
|
}
|
|
if errName == nil {
|
|
fmt.Println("here")
|
|
softwareTitle = &fleet.SoftwareTitle{
|
|
ID: titleID,
|
|
}
|
|
if displayName != "" {
|
|
softwareTitle.DisplayName = displayName
|
|
} else {
|
|
softwareTitle.Name = name
|
|
}
|
|
}
|
|
default:
|
|
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
|
|
}
|
|
}
|
|
|
|
// Get an iterator to stream hosts one by one.
|
|
hostIterator, err := svc.StreamHosts(ctx, req.Opts)
|
|
if err != nil {
|
|
return listHostsResponse{Err: err}, nil
|
|
}
|
|
|
|
// The `hostIterator` only yields `fleet.Host` instances, which doesn't include
|
|
// labels or other fields included in `fleet.HostResponse`, so we create another
|
|
// iterator to act as a transformer from `fleet.Host` to `fleet.HostResponse`.
|
|
hostResponseIterator := func() iter.Seq2[*fleet.HostResponse, error] {
|
|
return func(yield func(*fleet.HostResponse, error) bool) {
|
|
for host, err := range hostIterator {
|
|
if err != nil {
|
|
yield(nil, err)
|
|
return
|
|
}
|
|
h := fleet.HostResponseForHost(ctx, svc, host)
|
|
if req.Opts.PopulateLabels {
|
|
labels, err := svc.ListLabelsForHost(ctx, h.ID)
|
|
if err != nil {
|
|
yield(nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("failed to list labels for host %d", h.ID)))
|
|
return
|
|
}
|
|
h.Labels = labels
|
|
}
|
|
if !yield(h, nil) {
|
|
return // consumer wants us to stop
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return streamHostsResponse{
|
|
listHostsResponse: listHostsResponse{
|
|
Software: software,
|
|
SoftwareTitle: softwareTitle,
|
|
MDMSolution: mdmSolution,
|
|
MunkiIssue: munkiIssue,
|
|
},
|
|
HostResponseIterator: hostResponseIterator(),
|
|
}, 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) {
|
|
hostIterator, err := svc.StreamHosts(ctx, opt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var hosts []*fleet.Host
|
|
for host, err := range hostIterator {
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hosts = append(hosts, host)
|
|
}
|
|
return hosts, nil
|
|
}
|
|
|
|
func (svc *Service) StreamHosts(ctx context.Context, opt fleet.HostListOptions) (iter.Seq2[*fleet.Host, error], 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
|
|
}
|
|
|
|
statusMap := map[uint]*fleet.HostLockWipeStatus{}
|
|
if opt.IncludeDeviceStatus {
|
|
// We query the MDM lock/wipe status for all hosts in a batch to optimize performance.
|
|
statusMap, err = svc.ds.GetHostsLockWipeStatusBatch(ctx, hosts)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get hosts lock/wipe status batch")
|
|
}
|
|
}
|
|
|
|
// Create an iterator to return one host at a time, hydrated with extra details as needed.
|
|
hostIterator := func() iter.Seq2[*fleet.Host, error] {
|
|
return func(yield func(*fleet.Host, error) bool) {
|
|
for _, host := range hosts {
|
|
|
|
if !opt.DisableIssues && !premiumLicense {
|
|
host.HostIssues.CriticalVulnerabilitiesCount = nil
|
|
} else if opt.DisableIssues && premiumLicense {
|
|
var zero uint64
|
|
host.HostIssues.CriticalVulnerabilitiesCount = &zero
|
|
}
|
|
|
|
if opt.PopulateSoftware {
|
|
if err = svc.ds.LoadHostSoftware(ctx, host, opt.PopulateSoftwareVulnerabilityDetails); err != nil {
|
|
yield(nil, ctxerr.Wrapf(ctx, err, "get software vulnerability details for host %d", host.ID))
|
|
return
|
|
}
|
|
}
|
|
|
|
if opt.PopulatePolicies {
|
|
hp, err := svc.ds.ListPoliciesForHost(ctx, host)
|
|
if err != nil {
|
|
yield(nil, ctxerr.Wrapf(ctx, err, "get policies for host %d", host.ID))
|
|
return
|
|
}
|
|
host.Policies = &hp
|
|
}
|
|
|
|
if opt.PopulateUsers {
|
|
hu, err := svc.ds.ListHostUsers(ctx, host.ID)
|
|
if err != nil {
|
|
yield(nil, ctxerr.Wrapf(ctx, err, "get users for host %d", host.ID))
|
|
return
|
|
}
|
|
host.Users = hu
|
|
}
|
|
|
|
if opt.IncludeDeviceStatus {
|
|
if status, ok := statusMap[host.ID]; ok {
|
|
host.MDM.DeviceStatus = ptr.String(string(status.DeviceStatus()))
|
|
host.MDM.PendingAction = ptr.String(string(status.PendingAction()))
|
|
} else {
|
|
// Host has no MDM actions, set defaults
|
|
host.MDM.DeviceStatus = ptr.String(string(fleet.DeviceStatusUnlocked))
|
|
host.MDM.PendingAction = ptr.String(string(fleet.PendingActionNone))
|
|
}
|
|
}
|
|
|
|
if !yield(host, nil) {
|
|
return // consumer wants us to stop
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return hostIterator(), 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
|
|
}
|
|
|
|
if err := svc.activitySvc.CleanupHostActivities(ctx, hostIDs); err != nil {
|
|
// Log and continue; hosts are already deleted, so aborting would be worse
|
|
// than leaving orphaned activity_host_past rows.
|
|
err = ctxerr.Wrap(ctx, err, "cleanup host activities after bulk delete")
|
|
svc.logger.ErrorContext(ctx, "failed to cleanup host activities", "err", err)
|
|
ctxerr.Handle(ctx, err)
|
|
}
|
|
|
|
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, svc.NewActivity)
|
|
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)
|
|
}
|
|
|
|
// Create activities for host deletions
|
|
adminUser := authz.UserFromContext(ctx)
|
|
for _, host := range hosts {
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
adminUser,
|
|
fleet.ActivityTypeDeletedHost{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
HostSerial: host.HardwareSerial,
|
|
TriggeredBy: fleet.DeletedHostTriggeredByManual,
|
|
},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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" renameto:"report_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
|
|
}
|
|
|
|
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
|
|
// Scope observer access to the query's own team. A user who is observer on
|
|
// multiple teams may only search hosts from the team the query belongs to.
|
|
filter.ObserverTeamID = query.TeamID
|
|
}
|
|
|
|
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) ||
|
|
svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) ||
|
|
svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL)
|
|
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" renameto:"fleet_id"`
|
|
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, fleet.TeamFilter{})
|
|
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 err := svc.activitySvc.CleanupHostActivities(ctx, []uint{id}); err != nil {
|
|
// Log and continue — host is already deleted, so aborting would be worse
|
|
// than leaving orphaned activity_host_past rows.
|
|
err = ctxerr.Wrap(ctx, err, "cleanup host activities after delete")
|
|
svc.logger.ErrorContext(ctx, "failed to cleanup host activities", "err", err)
|
|
ctxerr.Handle(ctx, err)
|
|
}
|
|
|
|
// Create activity for host deletion
|
|
adminUser := authz.UserFromContext(ctx)
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
adminUser,
|
|
fleet.ActivityTypeDeletedHost{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
HostSerial: host.HardwareSerial,
|
|
TriggeredBy: fleet.DeletedHostTriggeredByManual,
|
|
},
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if fleet.MDMSupported(host.Platform) {
|
|
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger, svc.NewActivity)
|
|
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
|
|
}
|
|
|
|
func (svc *Service) CleanupExpiredHosts(ctx context.Context) ([]fleet.DeletedHostDetails, error) {
|
|
// Call datastore to get expired hosts and their details
|
|
hostDetails, err := svc.ds.CleanupExpiredHosts(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(hostDetails) > 0 {
|
|
hostIDs := make([]uint, 0, len(hostDetails))
|
|
for _, h := range hostDetails {
|
|
hostIDs = append(hostIDs, h.ID)
|
|
}
|
|
if err := svc.activitySvc.CleanupHostActivities(ctx, hostIDs); err != nil {
|
|
err = ctxerr.Wrap(ctx, err, "cleanup host activities after expired host deletion")
|
|
svc.logger.ErrorContext(ctx, "failed to cleanup host activities", "err", err)
|
|
ctxerr.Handle(ctx, err)
|
|
}
|
|
}
|
|
|
|
// Create activities for each deleted host
|
|
for _, hostDetail := range hostDetails {
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
nil, // Fleet automation user
|
|
fleet.ActivityTypeDeletedHost{
|
|
HostID: hostDetail.ID,
|
|
HostDisplayName: hostDetail.DisplayName,
|
|
HostSerial: hostDetail.Serial,
|
|
TriggeredBy: fleet.DeletedHostTriggeredByExpiration,
|
|
HostExpiryWindow: &hostDetail.HostExpiryWindow,
|
|
},
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return hostDetails, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Add Hosts to Team
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type addHostsToTeamRequest struct {
|
|
TeamID *uint `json:"team_id" renameto:"fleet_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
|
|
}
|
|
|
|
// authorizeHostSourceTeams checks that the caller has write access to the
|
|
// source teams of the hosts being transferred.
|
|
func (svc *Service) authorizeHostSourceTeams(ctx context.Context, hosts []*fleet.Host) error {
|
|
seenTeamIDs := make(map[uint]struct{})
|
|
var checkedNoTeam bool
|
|
for _, h := range hosts {
|
|
if h.TeamID == nil { // "No Team" team / "Unassigned" fleet
|
|
if !checkedNoTeam {
|
|
checkedNoTeam = true
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: nil}, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
} else if _, ok := seenTeamIDs[*h.TeamID]; !ok {
|
|
seenTeamIDs[*h.TeamID] = struct{}{}
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: h.TeamID}, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []uint, skipBulkPending bool) error {
|
|
// Authorize write access to the destination team.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Authorize write access to the source teams of the hosts being transferred.
|
|
hosts, err := svc.ds.ListHostsLiteByIDs(ctx, hostIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrapf(ctx, err, "list hosts by IDs for source team authorization (team_id: %v, host_count: %d)", teamID, len(hostIDs))
|
|
}
|
|
if err := svc.authorizeHostSourceTeams(ctx, hosts); 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")
|
|
}
|
|
}
|
|
|
|
// If there are any Android hosts, update their available apps.
|
|
androidUUIDs, err := svc.ds.ListMDMAndroidUUIDsToHostIDs(ctx, hostIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(androidUUIDs) > 0 {
|
|
enterprise, err := svc.ds.GetEnterprise(ctx)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get android enterprise")
|
|
}
|
|
|
|
if err := worker.QueueBulkSetAndroidAppsAvailableForHosts(ctx, svc.ds, svc.logger, androidUUIDs, enterprise.Name()); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "queue bulk set available android apps for hosts 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.TeamLite(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" renameto:"fleet_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 {
|
|
// Authorize write access to the destination team.
|
|
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, hosts, err := svc.hostIDsAndNamesFromFilters(ctx, *opt, lid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(hostIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Authorize write access to the source teams of the hosts being transferred.
|
|
if err := svc.authorizeHostSourceTeams(ctx, hosts); err != nil {
|
|
return err
|
|
}
|
|
|
|
// 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) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) {
|
|
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 {
|
|
hostMDM, err := svc.ds.GetHostMDM(ctx, host.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get host MDM info")
|
|
}
|
|
isBYOD := !hostMDM.InstalledFromDep
|
|
err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID, isBYOD)
|
|
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,
|
|
})
|
|
}
|
|
|
|
adeData, err := svc.ds.GetHostDEPAssignment(ctx, host.ID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return ctxerr.Wrap(ctx, err, "refetch host: get host DEP assignment")
|
|
}
|
|
|
|
lwStatus, err := svc.ds.GetHostLockWipeStatus(ctx, host)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "refetch host: get host location data")
|
|
}
|
|
|
|
if adeData.IsDEPAssignedToFleet() && lwStatus.IsLocked() {
|
|
err = svc.mdmAppleCommander.DeviceLocation(ctx, []string{host.UUID}, cmdUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "refetch host: get location with MDM")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
svc.logger.DebugContext(ctx, "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())
|
|
}
|
|
|
|
// Retrieve certificate templates associated with the host and marshal them into
|
|
// HostMDMProfile structs so that we can display them in the list of OS Settings items.
|
|
hCertTemplates, err := svc.ds.GetHostCertificateTemplates(ctx, host.UUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host certificate templates")
|
|
}
|
|
for _, ct := range hCertTemplates {
|
|
profiles = append(profiles, ct.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)
|
|
|
|
// populate recovery lock password status for macOS hosts
|
|
if host.Platform == "darwin" {
|
|
rlpStatus, err := svc.ds.GetHostRecoveryLockPasswordStatus(ctx, host.UUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host recovery lock password status")
|
|
}
|
|
if rlpStatus != nil {
|
|
rlpStatus.PopulateStatus()
|
|
host.MDM.OSSettings.RecoveryLockPassword = *rlpStatus
|
|
}
|
|
}
|
|
|
|
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 := fleet.GetEndUsers(ctx, svc.ds, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get end users for host")
|
|
}
|
|
|
|
conditionalAccessBypassedAt, err := svc.ds.ConditionalAccessBypassedAt(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get conditional access bypass status")
|
|
}
|
|
conditionalAccessBypassed := conditionalAccessBypassedAt != nil
|
|
|
|
return &fleet.HostDetail{
|
|
Host: *host,
|
|
Labels: labels,
|
|
Packs: packs,
|
|
Batteries: &bats,
|
|
MaintenanceWindow: nextMw,
|
|
EndUsers: endUsers,
|
|
LastMDMEnrolledAt: mdmLastEnrollment,
|
|
LastMDMCheckedInAt: mdmLastCheckedIn,
|
|
ConditionalAccessBypassed: conditionalAccessBypassed,
|
|
}, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Host Query Report
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostQueryReportRequest struct {
|
|
ID uint `url:"id"`
|
|
QueryID uint `url:"report_id"`
|
|
}
|
|
|
|
type getHostQueryReportResponse struct {
|
|
QueryID uint `json:"query_id" renameto:"report_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
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List Host Reports
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listHostReportsRequest struct {
|
|
ID uint `url:"id"`
|
|
ListOptions fleet.ListOptions `url:"list_options"`
|
|
// IncludeReportsDontStoreResults if true, include reports that don't store results
|
|
// (discard_data=1 AND logging_type != 'snapshot'). Defaults to false when omitted.
|
|
IncludeReportsDontStoreResults *bool `query:"include_reports_dont_store_results,optional"`
|
|
}
|
|
|
|
type listHostReportsResponse struct {
|
|
Reports []*fleet.HostReport `json:"reports"`
|
|
Count int `json:"count"`
|
|
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listHostReportsResponse) Error() error { return r.Err }
|
|
|
|
func listHostReportsEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listHostReportsRequest)
|
|
|
|
var includeReportsDontStoreResults bool
|
|
if req.IncludeReportsDontStoreResults != nil {
|
|
includeReportsDontStoreResults = *req.IncludeReportsDontStoreResults
|
|
}
|
|
|
|
opts := fleet.ListHostReportsOptions{
|
|
ListOptions: req.ListOptions,
|
|
IncludeReportsDontStoreResults: includeReportsDontStoreResults,
|
|
}
|
|
|
|
reports, count, meta, err := svc.ListHostReports(ctx, req.ID, opts)
|
|
if err != nil {
|
|
return listHostReportsResponse{Err: err}, nil
|
|
}
|
|
|
|
return listHostReportsResponse{
|
|
Reports: reports,
|
|
Count: count,
|
|
Meta: meta,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) ListHostReports(
|
|
ctx context.Context,
|
|
hostID uint,
|
|
opts fleet.ListHostReportsOptions,
|
|
) (
|
|
[]*fleet.HostReport,
|
|
int,
|
|
*fleet.PaginationMetadata,
|
|
error,
|
|
) {
|
|
// Load host to get team ID and authorize.
|
|
host, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
setAuthCheckedOnPreAuthErr(ctx)
|
|
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get host")
|
|
}
|
|
|
|
// Verify the caller can read this specific host.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{ID: host.ID, TeamID: host.TeamID}, fleet.ActionRead); err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
|
|
// Authorize against the host's team. Global queries (team_id IS NULL) are
|
|
// intentionally visible to all users who can read queries in this context —
|
|
// team-scoped users see global queries in addition to their own team's queries.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Query{TeamID: host.TeamID}, fleet.ActionRead); err != nil {
|
|
return nil, 0, nil, err
|
|
}
|
|
|
|
appConfig, err := svc.AppConfigObfuscated(ctx)
|
|
if err != nil {
|
|
return nil, 0, nil, ctxerr.Wrap(ctx, err, "get app config")
|
|
}
|
|
maxQueryReportRows := appConfig.ServerSettings.GetQueryReportCap()
|
|
|
|
// This end-point is always paginated; metadata is required for HasNextResults.
|
|
opts.ListOptions.IncludeMetadata = true
|
|
// Default page size for this endpoint is 50 (not the global default).
|
|
if opts.ListOptions.PerPage == 0 {
|
|
opts.ListOptions.PerPage = 50
|
|
}
|
|
// Validate the order key before it reaches the datastore allowlist, so that
|
|
// invalid values produce a clear 400 Bad Request instead of an internal error.
|
|
switch opts.ListOptions.OrderKey {
|
|
case "", "name", "last_fetched":
|
|
// valid
|
|
default:
|
|
return nil, 0, nil, fleet.NewInvalidArgumentError("order_key", "must be one of: name, last_fetched")
|
|
}
|
|
|
|
// Default: sort by newest results first. Applies only when the caller has
|
|
// not specified an order key; explicit sorts (e.g. order_key=name) are
|
|
// passed through unchanged.
|
|
if opts.ListOptions.OrderKey == "" {
|
|
opts.ListOptions.OrderKey = "last_fetched"
|
|
opts.ListOptions.OrderDirection = fleet.OrderDescending
|
|
}
|
|
|
|
reports, total, meta, err := svc.ds.ListHostReports(ctx, hostID, host.TeamID, fleet.PlatformFromHost(host.Platform), opts, maxQueryReportRows)
|
|
if err != nil {
|
|
return nil, 0, nil, ctxerr.Wrap(ctx, err, "list host reports from datastore")
|
|
}
|
|
|
|
return reports, total, meta, 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 returns the device mappings for a host.
|
|
//
|
|
// 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) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) {
|
|
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"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
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
|
|
func putHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*putHostDeviceMappingRequest)
|
|
|
|
var dms []*fleet.HostDeviceMapping
|
|
var err error
|
|
|
|
dms, err = svc.SetHostDeviceMapping(ctx, req.ID, req.Email, req.Source)
|
|
if err != nil {
|
|
return putHostDeviceMappingResponse{Err: err}, nil
|
|
}
|
|
return putHostDeviceMappingResponse{HostID: req.ID, DeviceMapping: dms}, nil
|
|
}
|
|
|
|
func (svc *Service) SetHostDeviceMapping(ctx context.Context, hostID uint, email string, source 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
|
|
}
|
|
}
|
|
|
|
if source == "" {
|
|
source = "custom"
|
|
}
|
|
|
|
if isInstallerSource {
|
|
source = fleet.DeviceMappingCustomInstaller
|
|
} else if source == "custom" {
|
|
source = fleet.DeviceMappingCustomOverride
|
|
}
|
|
|
|
switch source {
|
|
case fleet.DeviceMappingCustomOverride, fleet.DeviceMappingCustomInstaller:
|
|
return svc.ds.SetOrUpdateCustomHostDeviceMapping(ctx, hostID, email, source)
|
|
case fleet.DeviceMappingIDP:
|
|
// This is a premium-only feature
|
|
lic, err := svc.License(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lic == nil || !lic.IsPremium() {
|
|
return nil, fleet.ErrMissingLicense
|
|
}
|
|
|
|
// Get host information for the activity
|
|
host, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
// error before update to avoid update without activity
|
|
return nil, ctxerr.Wrap(ctx, err, "get host for activity")
|
|
}
|
|
|
|
// Check if the email has changed; if not, return early to avoid
|
|
// unnecessary database updates and profile resends.
|
|
emails, err := svc.ds.GetHostEmails(ctx, host.UUID, fleet.DeviceMappingIDP)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host emails for idempotency check")
|
|
}
|
|
for _, e := range emails {
|
|
if strings.EqualFold(e, email) {
|
|
return svc.ds.ListHostDeviceMapping(ctx, hostID)
|
|
}
|
|
}
|
|
|
|
// Store the IDP username for display (accept any value)
|
|
// This will appear in the host details API under the idp_username field
|
|
if err := svc.ds.SetOrUpdateIDPHostDeviceMapping(ctx, hostID, email); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "set IDP device mapping")
|
|
}
|
|
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
authz.UserFromContext(ctx),
|
|
fleet.ActivityTypeEditedHostIdpData{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
HostIdPUsername: email,
|
|
},
|
|
); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "create updated host idp activity")
|
|
}
|
|
|
|
// Check if the user is a valid SCIM user to manage the join table
|
|
scimUser, err := svc.ds.ScimUserByUserNameOrEmail(ctx, email, email)
|
|
if err != nil && !fleet.IsNotFound(err) && err != sql.ErrNoRows {
|
|
return nil, ctxerr.Wrap(ctx, err, "find SCIM user by username or email")
|
|
}
|
|
if err == nil && scimUser != nil {
|
|
// User exists in SCIM, create/update the mapping for additional attributes
|
|
// This enables fields like idp_full_name, idp_groups, etc. to appear in the API
|
|
if err := svc.ds.SetOrUpdateHostSCIMUserMapping(ctx, hostID, scimUser.ID); err != nil {
|
|
// Log the error but don't fail the request since the main IDP mapping succeeded
|
|
svc.logger.DebugContext(ctx, "failed to set SCIM user mapping", "err", err)
|
|
}
|
|
} else {
|
|
// User doesn't exist in SCIM, remove any existing SCIM mapping for this host
|
|
if err := svc.ds.DeleteHostSCIMUserMapping(ctx, hostID); err != nil && !fleet.IsNotFound(err) {
|
|
// Log the error but don't fail the request
|
|
svc.logger.DebugContext(ctx, "failed to delete SCIM user mapping", "err", err)
|
|
}
|
|
}
|
|
|
|
// Return the updated device mappings including the IDP mapping
|
|
return svc.ds.ListHostDeviceMapping(ctx, hostID)
|
|
default:
|
|
return nil, fleet.NewInvalidArgumentError("source", fmt.Sprintf("must be 'custom' or '%s'", fleet.DeviceMappingIDP))
|
|
}
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete Host IdP
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteHostIDPRequest struct {
|
|
HostID uint `url:"id"`
|
|
}
|
|
|
|
type deleteHostIDPResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteHostIDPResponse) Error() error { return r.Err }
|
|
func (r deleteHostIDPResponse) Status() int { return http.StatusNoContent }
|
|
|
|
func deleteHostIDPEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteHostIDPRequest)
|
|
if err := svc.DeleteHostIDP(ctx, req.HostID); err != nil {
|
|
return deleteHostIDPResponse{Err: err}, nil
|
|
}
|
|
return deleteHostIDPResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteHostIDP(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")
|
|
}
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
lic, err := svc.License(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if lic == nil || !lic.IsPremium() {
|
|
return fleet.ErrMissingLicense
|
|
}
|
|
|
|
// remove host device mapping and any SCIM mapping
|
|
if err := svc.ds.DeleteHostIDP(ctx, id); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete host IdP and SCIM mappings")
|
|
}
|
|
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
authz.UserFromContext(ctx),
|
|
fleet.ActivityTypeEditedHostIdpData{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
HostIdPUsername: "",
|
|
},
|
|
); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "create deleted host idp activity")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get host DEP assignment
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostDEPAssignmentRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getHostDEPAssignmentResponse struct {
|
|
ID uint `json:"id"`
|
|
HostDEPAssignment *fleet.HostDEPAssignment `json:"host_dep_assignment"`
|
|
DEPDevice *godep.Device `json:"dep_device"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostDEPAssignmentResponse) Error() error { return r.Err }
|
|
|
|
func getHostDEPAssignmentEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostDEPAssignmentRequest)
|
|
depAssignment, depDevice, err := svc.GetHostDEPAssignmentDetails(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostDEPAssignmentResponse{Err: err}, nil
|
|
}
|
|
|
|
return getHostDEPAssignmentResponse{
|
|
ID: req.ID,
|
|
HostDEPAssignment: depAssignment,
|
|
DEPDevice: depDevice,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostDEPAssignmentDetails(ctx context.Context, hostID uint) (*fleet.HostDEPAssignment, *godep.Device, error) {
|
|
// Load the host first so we can do a team-aware authorization check,
|
|
// mirroring what GET /hosts/:id does.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "get host for dep assignment")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Fetch Fleet's DEP assignment record. A not-found error means the host is
|
|
// not a DEP host; return all nils so the response contains JSON nulls.
|
|
depAssignment, err := svc.ds.GetHostDEPAssignment(ctx, hostID)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "get host dep assignment")
|
|
}
|
|
|
|
// Without an ABM token ID we can't resolve which org name to use for the
|
|
// Apple API call, so return what we have from Fleet's DB.
|
|
if depAssignment.ABMTokenID == nil {
|
|
return depAssignment, nil, nil
|
|
}
|
|
|
|
abmToken, err := svc.ds.GetABMTokenByID(ctx, *depAssignment.ABMTokenID)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "get ABM token for dep assignment")
|
|
}
|
|
|
|
// If Apple MDM is not configured (e.g. free tier), depStorage will be nil
|
|
// and NewDEPClient would panic. Return what we have from Fleet's DB.
|
|
if svc.depStorage == nil {
|
|
return depAssignment, nil, nil
|
|
}
|
|
|
|
// Call Apple's "Get Device Details" API. Per the issue spec: on error, log
|
|
// and return dep_device as nil rather than surfacing the error to the caller.
|
|
depClient := apple_mdm.NewDEPClient(svc.depStorage, svc.ds, svc.logger)
|
|
depDevice, err := depClient.GetDeviceDetails(ctx, abmToken.OrganizationName, host.HardwareSerial)
|
|
if err != nil {
|
|
svc.logger.ErrorContext(ctx, "get DEP device details from ABM",
|
|
"host_id", hostID,
|
|
"org_name", abmToken.OrganizationName,
|
|
"err", err,
|
|
)
|
|
return depAssignment, nil, nil
|
|
}
|
|
|
|
return depAssignment, depDevice, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// 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" renameto:"fleet_id"`
|
|
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) {
|
|
// iOS/iPadOS devices don't have macadmins data (Munki, etc.), return nil early.
|
|
if svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) {
|
|
return nil, nil
|
|
}
|
|
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) {
|
|
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" renameto:"fleet_id"`
|
|
}
|
|
|
|
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.TeamLite(ctx, *teamID) // TODO see if we can use TeamExists here
|
|
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()
|
|
// Add the aliased fields for team_id and team_name.
|
|
h.FleetID = h.TeamID
|
|
h.FleetName = h.TeamName
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := gocsv.Marshal(r.Hosts, &buf); err != nil {
|
|
logging.WithErr(ctx, err)
|
|
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)
|
|
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.
|
|
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 team_id / team_name are requested, also include their aliases.
|
|
// TODO: clean up in Fleet 5.
|
|
if rawCol == "team_id" {
|
|
cols = append(cols, "fleet_id")
|
|
}
|
|
if rawCol == "team_name" {
|
|
cols = append(cols, "fleet_name")
|
|
}
|
|
}
|
|
}
|
|
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" renameto:"fleet_id"`
|
|
Platform *string `query:"platform,optional"`
|
|
Name *string `query:"os_name,optional"`
|
|
Version *string `query:"os_version,optional"`
|
|
MaxVulnerabilities *int `query:"max_vulnerabilities,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, req.MaxVulnerabilities)
|
|
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,
|
|
maxVulnerabilities *int,
|
|
) (*fleet.OSVersions, int, *fleet.PaginationMetadata, error) {
|
|
var count int
|
|
// Input validation
|
|
if maxVulnerabilities != nil && *maxVulnerabilities < 0 {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
return nil, count, nil, fleet.NewInvalidArgumentError("max_vulnerabilities", "max_vulnerabilities must be >= 0")
|
|
}
|
|
|
|
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"}
|
|
}
|
|
|
|
// Default to 20 per page if no pagination is specified to avoid returning too many results
|
|
// and slowing down the response time significantly.
|
|
if opts.Page == 0 && opts.PerPage == 0 {
|
|
opts.PerPage = 20
|
|
}
|
|
|
|
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/fleet_id", fmt.Sprintf("fleet %d does not exist", *teamID)).
|
|
WithStatus(http.StatusNotFound)
|
|
}
|
|
}
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, count, nil, fleet.ErrNoContext
|
|
}
|
|
|
|
// Load all OS versions (unpaged)
|
|
osVersions, err := svc.ds.OSVersions(
|
|
ctx, &fleet.TeamFilter{
|
|
User: vc.User,
|
|
IncludeObserver: true,
|
|
TeamID: teamID,
|
|
}, platform, name, version,
|
|
)
|
|
if err != nil && fleet.IsNotFound(err) {
|
|
osVersions = &fleet.OSVersions{}
|
|
} else if err != nil {
|
|
return nil, count, nil, err
|
|
}
|
|
|
|
// Sort by hosts_count (default: desc to match previous behavior)
|
|
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
|
|
})
|
|
}
|
|
|
|
// Total count BEFORE pagination
|
|
count = len(osVersions.OSVersions)
|
|
|
|
// Paginate first
|
|
paged, meta := paginateOSVersions(osVersions.OSVersions, opts)
|
|
|
|
// Pull vulnerabilities ONLY for the paginated slice, as the full list slows
|
|
// response times down significantly with many CVEs.
|
|
if len(paged) == 0 {
|
|
return &fleet.OSVersions{
|
|
CountsUpdatedAt: osVersions.CountsUpdatedAt,
|
|
OSVersions: []fleet.OSVersion{},
|
|
}, count, meta, nil
|
|
}
|
|
|
|
vulnsMap, err := svc.ds.ListVulnsByMultipleOSVersions(ctx, paged, includeCVSS, teamID, maxVulnerabilities)
|
|
if err != nil {
|
|
return nil, count, nil, ctxerr.Wrap(ctx, err, "list vulns by multiple os versions (paged)")
|
|
}
|
|
|
|
// Populate only the paginated entries
|
|
for i := range paged {
|
|
osV := &paged[i]
|
|
|
|
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),
|
|
}
|
|
}
|
|
|
|
osV.Vulnerabilities = make(fleet.Vulnerabilities, 0) // avoid null
|
|
key := fmt.Sprintf("%s-%s", osV.NameOnly, osV.Version)
|
|
if vulnData, ok := vulnsMap[key]; ok {
|
|
osV.VulnerabilitiesCount = vulnData.Count
|
|
for _, v := range vulnData.Vulnerabilities {
|
|
v.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", v.CVE)
|
|
osV.Vulnerabilities = append(osV.Vulnerabilities, v)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return only the page, but with total count
|
|
return &fleet.OSVersions{
|
|
CountsUpdatedAt: osVersions.CountsUpdatedAt,
|
|
OSVersions: paged,
|
|
}, count, meta, 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" renameto:"fleet_id"`
|
|
MaxVulnerabilities *int `query:"max_vulnerabilities,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, req.MaxVulnerabilities)
|
|
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, maxVulnerabilities *int) (*fleet.OSVersion, *time.Time, error) {
|
|
// Input validation
|
|
if maxVulnerabilities != nil && *maxVulnerabilities < 0 {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
return nil, nil, fleet.NewInvalidArgumentError("max_vulnerabilities", "max_vulnerabilities must be >= 0")
|
|
}
|
|
|
|
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/fleet_id", fmt.Sprintf("fleet %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, maxVulnerabilities); 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, maxVulnerabilities *int) 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
|
|
vulnData, err := svc.ds.ListVulnsByOsNameAndVersion(ctx, osVersion.NameOnly, osVersion.Version, includeCVSS, teamID, maxVulnerabilities)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
osVersion.Vulnerabilities = make(fleet.Vulnerabilities, 0) // avoid null in JSON
|
|
osVersion.VulnerabilitiesCount = vulnData.Count
|
|
for _, vuln := range vulnData.Vulnerabilities {
|
|
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 {
|
|
emptyKernels := make([]*fleet.Kernel, 0)
|
|
osVersion.Kernels = &emptyKernels // avoid null in JSON, pointer to empty slice shows as []
|
|
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
|
|
}
|
|
|
|
svc.logger.InfoContext(ctx, "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:
|
|
svc.logger.InfoContext(ctx, "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:
|
|
svc.logger.InfoContext(ctx, "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.
|
|
svc.logger.ErrorContext(ctx, "decryption errors for host disk encryption key", "host_id", host.ID, "errors", errors.Join(decryptErrs...))
|
|
}
|
|
|
|
if key == nil || key.DecryptedValue == "" {
|
|
if len(decryptErrs) > 0 {
|
|
// Decryption failed, likely due to rotated MDM certificates
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewUserMessageError(
|
|
errors.New("Couldn't decrypt the disk encryption key. The decryption certificate and key are invalid because MDM has been turned off."),
|
|
http.StatusUnprocessableEntity,
|
|
), "host encryption key decryption failed")
|
|
}
|
|
// No key found at all
|
|
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 "fleet_id", "team_id":
|
|
if teamID, ok := v.(float64); ok { // json unmarshals numbers as float64
|
|
teamID := uint(teamID)
|
|
opt.TeamFilter = &teamID
|
|
} else {
|
|
return nil, nil, badRequest("fleet_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)
|
|
}
|
|
|
|
var tmID uint
|
|
if host.TeamID != nil {
|
|
tmID = *host.TeamID
|
|
}
|
|
|
|
labelIDs, err := svc.validateLabelNames(ctx, "add", labelNames, tmID)
|
|
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)
|
|
}
|
|
|
|
var tmID uint
|
|
if host.TeamID != nil {
|
|
tmID = *host.TeamID
|
|
}
|
|
|
|
labelIDs, err := svc.validateLabelNames(ctx, "remove", labelNames, tmID)
|
|
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, teamID uint) ([]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
|
|
}
|
|
|
|
// team ID is always set because we are assigning labels to an entity; no-team entities can only use global labels
|
|
labels, err := svc.ds.LabelIDsByName(ctx, labelNames, fleet.TeamFilter{TeamID: &teamID, User: authz.UserFromContext(ctx)})
|
|
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 and be either global or on the same team as the host.",
|
|
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{}, platform_http.MaxRequestBodySize)
|
|
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) {
|
|
// Default to only showing inventory (excluding software available for install but not in
|
|
// inventory). Callers can explicitly set include_available_for_install=true to also see
|
|
// library items. See #41631.
|
|
var includeAvailableForInstall bool
|
|
|
|
var host *fleet.Host
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) {
|
|
|
|
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) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceCertificate) &&
|
|
!svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceURL) {
|
|
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
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Host Recovery Lock Password
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostRecoveryLockPasswordRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type recoveryLockPasswordPayload struct {
|
|
Password string `json:"password"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
type getHostRecoveryLockPasswordResponse struct {
|
|
HostID uint `json:"host_id"`
|
|
RecoveryLockPassword *recoveryLockPasswordPayload `json:"recovery_lock_password"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostRecoveryLockPasswordResponse) Error() error { return r.Err }
|
|
|
|
func getHostRecoveryLockPasswordEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostRecoveryLockPasswordRequest)
|
|
password, err := svc.GetHostRecoveryLockPassword(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostRecoveryLockPasswordResponse{Err: err}, nil
|
|
}
|
|
return getHostRecoveryLockPasswordResponse{
|
|
HostID: req.ID,
|
|
RecoveryLockPassword: &recoveryLockPasswordPayload{
|
|
Password: password.Password,
|
|
UpdatedAt: password.UpdatedAt,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostRecoveryLockPassword(ctx context.Context, hostID uint) (*fleet.HostRecoveryLockPassword, error) {
|
|
// 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
|
|
}
|
|
|
|
host, err := svc.ds.Host(ctx, hostID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host")
|
|
}
|
|
|
|
// Permissions to read recovery lock passwords 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
|
|
}
|
|
|
|
// Recovery lock is only supported on Apple Silicon macOS hosts
|
|
if host.Platform != "darwin" || !strings.Contains(strings.ToLower(host.CPUType), "arm") {
|
|
return nil, ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("host_id", "recovery lock is only available on Apple Silicon macOS hosts"), "check host platform and cpu type")
|
|
}
|
|
|
|
// Check that MDM is enabled
|
|
appConfig, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get app config")
|
|
}
|
|
if !appConfig.MDM.EnabledAndConfigured {
|
|
return nil, fleet.ErrMDMNotConfigured
|
|
}
|
|
|
|
password, err := svc.ds.GetHostRecoveryLockPassword(ctx, host.UUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host recovery lock password")
|
|
}
|
|
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
authz.UserFromContext(ctx),
|
|
fleet.ActivityTypeViewedHostRecoveryLockPassword{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
},
|
|
); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "create activity for viewed host recovery lock password")
|
|
}
|
|
|
|
return password, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Rotate Host Recovery Lock Password
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type rotateRecoveryLockPasswordRequest struct {
|
|
HostID uint `url:"id"`
|
|
}
|
|
|
|
type rotateRecoveryLockPasswordResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r rotateRecoveryLockPasswordResponse) Error() error { return r.Err }
|
|
|
|
func rotateRecoveryLockPasswordEndpoint(ctx context.Context, request any, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*rotateRecoveryLockPasswordRequest)
|
|
err := svc.RotateRecoveryLockPassword(ctx, req.HostID)
|
|
if err != nil {
|
|
return rotateRecoveryLockPasswordResponse{Err: err}, nil
|
|
}
|
|
return rotateRecoveryLockPasswordResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) RotateRecoveryLockPassword(ctx context.Context, hostID uint) error {
|
|
// skipauth: No authorization check needed due to implementation returning
|
|
// only license error.
|
|
svc.authz.SkipAuthorization(ctx)
|
|
|
|
return fleet.ErrMissingLicense
|
|
}
|