diff --git a/changes/issue-3640-add-endpoint-download-csv-hosts b/changes/issue-3640-add-endpoint-download-csv-hosts new file mode 100644 index 0000000000..51860d619b --- /dev/null +++ b/changes/issue-3640-add-endpoint-download-csv-hosts @@ -0,0 +1 @@ +* Add support for downloading a list of hosts in CSV format diff --git a/docs/Using-Fleet/REST-API.md b/docs/Using-Fleet/REST-API.md index 9910519d00..fd8bd47a4f 100644 --- a/docs/Using-Fleet/REST-API.md +++ b/docs/Using-Fleet/REST-API.md @@ -463,6 +463,7 @@ This is the callback endpoint that the identity provider will use to send securi - [Bulk delete hosts by filter or ids](#bulk-delete-hosts-by-filter-or-ids) - [Get host's Google Chrome profiles](#get-hosts-google-chrome-profiles) - [Get host's mobile device management (MDM) and Munki information](#get-hosts-mobile-device-management-mdm-and-munki-information) +- [Get hosts report in CSV](#get-hosts-report-in-csv) ### List hosts @@ -1206,6 +1207,49 @@ Retrieves aggregated host's MDM enrollment status and Munki versions. --- +### Get hosts report in CSV + +Returns the list of hosts corresponding to the search criteria in CSV format, ready for download when +requested by a web browser. + +`GET /api/v1/fleet/hosts/report` + +#### Parameters + +| Name | Type | In | Description | +| ----------------------- | ------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| format | string | query | **Required**, must be "csv" (only supported format for now). | +| page | integer | query | Page number of the results to fetch. | +| per_page | integer | query | Results per page. | +| order_key | string | query | What to order results by. Can be any column in the hosts table. | +| after | string | query | The value to get results after. This needs order_key defined, as that's the column that would be used. | +| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. | +| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, or `mia`. | +| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). | +| team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. | +| policy_id | integer | query | The ID of the policy to filter hosts by. `policy_response` must also be specified with `policy_id`. | +| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. | +| software_id | integer | query | The ID of the software to filter hosts by. | +| label_id | integer | query | A valid label ID. It cannot be used alongside policy filters. | + +#### Example + +`GET /api/v1/fleet/hosts/report?page=0&per_page=100&software_id=123` + +##### Default response + +`Status: 200` + +```csv +created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,last_enrolled_at,seen_time,refetch_requested,hostname,uuid,platform,osquery_version,os_version,build,platform_like,code_name,uptime,memory,cpu_type,cpu_subtype,cpu_brand,cpu_physical_cores,cpu_logical_cores,hardware_vendor,hardware_model,hardware_version,hardware_serial,computer_name,primary_ip_id,primary_ip,primary_mac,distributed_interval,config_tls_refresh,logger_tls_period,team_id,team_name,gigs_disk_space_available,percent_disk_space_available +2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,1,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,false,foo.local0,a4fc55a1-b5de-409c-a2f4-441f564680d3,debian,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0 +2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:22:56Z,false,foo.local1,689539e5-72f0-4bf7-9cc5-1530d3814660,rhel,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0 +2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,3,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:21:56Z,false,foo.local2,48ebe4b0-39c3-4a74-a67f-308f7b5dd171,linux,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0 +``` + +--- + + ## Labels - [Create label](#create-label) diff --git a/go.mod b/go.mod index db0f3f9371..bb88354755 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-kit/kit v0.9.0 github.com/go-sql-driver/mysql v1.6.0 + github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 // indirect github.com/gocolly/colly v1.2.0 github.com/golang-jwt/jwt/v4 v4.0.0 github.com/gomodule/redigo v1.8.5 diff --git a/go.sum b/go.sum index 86e7c709e3..09c871af9e 100644 --- a/go.sum +++ b/go.sum @@ -473,6 +473,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo= github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= +github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 h1:UfcDMw41lSx3XM7UvD1i7Fsu3rMgD55OU5LYwLoR/Yk= +github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI= github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI= github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= diff --git a/server/fleet/errors.go b/server/fleet/errors.go index 1538c43d3a..1b0692ff03 100644 --- a/server/fleet/errors.go +++ b/server/fleet/errors.go @@ -39,6 +39,19 @@ type ErrWithRetryAfter interface { RetryAfter() int } +type invalidArgWithStatusError struct { + InvalidArgumentError + code int +} + +func (e invalidArgWithStatusError) Status() int { + if e.code == 0 { + // 422 is the default code for invalid args + return http.StatusUnprocessableEntity + } + return e.code +} + // InvalidArgumentError is the error returned when invalid data is presented to // a service method. type InvalidArgumentError []InvalidArgument @@ -66,6 +79,7 @@ func (e *InvalidArgumentError) Append(name, reason string) { reason: reason, }) } + func (e *InvalidArgumentError) Appendf(name, reasonFmt string, args ...interface{}) { *e = append(*e, InvalidArgument{ name: name, @@ -73,6 +87,12 @@ func (e *InvalidArgumentError) Appendf(name, reasonFmt string, args ...interface }) } +// WithStatus returns an error that combines the InvalidArgumentError +// with a custom status code. +func (e InvalidArgumentError) WithStatus(code int) error { + return invalidArgWithStatusError{e, code} +} + func (e *InvalidArgumentError) HasErrors() bool { return len(*e) != 0 } @@ -213,7 +233,7 @@ const ( ErrNoRoleNeeded = 1 // ErrNoOneAdminNeeded is the error number when all admins are about to be removed ErrNoOneAdminNeeded = 2 - //ErrNoUnknownTranslate is returned when an item type in the translate payload is unknown + // ErrNoUnknownTranslate is returned when an item type in the translate payload is unknown ErrNoUnknownTranslate = 3 ) diff --git a/server/fleet/hosts.go b/server/fleet/hosts.go index 7418cf59ab..b883527249 100644 --- a/server/fleet/hosts.go +++ b/server/fleet/hosts.go @@ -70,73 +70,73 @@ type HostUser struct { type Host struct { UpdateCreateTimestamps HostSoftware - ID uint `json:"id"` + ID uint `json:"id" csv:"id"` // OsqueryHostID is the key used in the request context that is // used to retrieve host information. It is sent from osquery and may currently be // a GUID or a Host Name, but in either case, it MUST be unique - OsqueryHostID string `json:"-" db:"osquery_host_id"` - DetailUpdatedAt time.Time `json:"detail_updated_at" db:"detail_updated_at"` // Time that the host details were last updated - LabelUpdatedAt time.Time `json:"label_updated_at" db:"label_updated_at"` // Time that the host labels were last updated - PolicyUpdatedAt time.Time `json:"policy_updated_at" db:"policy_updated_at"` // Time that the host policies were last updated - LastEnrolledAt time.Time `json:"last_enrolled_at" db:"last_enrolled_at"` // Time that the host last enrolled - SeenTime time.Time `json:"seen_time" db:"seen_time"` // Time that the host was last "seen" - RefetchRequested bool `json:"refetch_requested" db:"refetch_requested"` - NodeKey string `json:"-" db:"node_key"` - Hostname string `json:"hostname" db:"hostname"` // there is a fulltext index on this field - UUID string `json:"uuid" db:"uuid"` // there is a fulltext index on this field + OsqueryHostID string `json:"-" db:"osquery_host_id" csv:"-"` + DetailUpdatedAt time.Time `json:"detail_updated_at" db:"detail_updated_at" csv:"detail_updated_at"` // Time that the host details were last updated + LabelUpdatedAt time.Time `json:"label_updated_at" db:"label_updated_at" csv:"label_updated_at"` // Time that the host labels were last updated + PolicyUpdatedAt time.Time `json:"policy_updated_at" db:"policy_updated_at" csv:"policy_updated_at"` // Time that the host policies were last updated + LastEnrolledAt time.Time `json:"last_enrolled_at" db:"last_enrolled_at" csv:"last_enrolled_at"` // Time that the host last enrolled + SeenTime time.Time `json:"seen_time" db:"seen_time" csv:"seen_time"` // Time that the host was last "seen" + RefetchRequested bool `json:"refetch_requested" db:"refetch_requested" csv:"refetch_requested"` + NodeKey string `json:"-" db:"node_key" csv:"-"` + Hostname string `json:"hostname" db:"hostname" csv:"hostname"` // there is a fulltext index on this field + UUID string `json:"uuid" db:"uuid" csv:"uuid"` // there is a fulltext index on this field // Platform is the host's platform as defined by osquery's os_version.platform. - Platform string `json:"platform"` - OsqueryVersion string `json:"osquery_version" db:"osquery_version"` - OSVersion string `json:"os_version" db:"os_version"` - Build string `json:"build"` - PlatformLike string `json:"platform_like" db:"platform_like"` - CodeName string `json:"code_name" db:"code_name"` - Uptime time.Duration `json:"uptime"` - Memory int64 `json:"memory" sql:"type:bigint" db:"memory"` + Platform string `json:"platform" csv:"platform"` + OsqueryVersion string `json:"osquery_version" db:"osquery_version" csv:"osquery_version"` + OSVersion string `json:"os_version" db:"os_version" csv:"os_version"` + Build string `json:"build" csv:"build"` + PlatformLike string `json:"platform_like" db:"platform_like" csv:"platform_like"` + CodeName string `json:"code_name" db:"code_name" csv:"code_name"` + Uptime time.Duration `json:"uptime" csv:"uptime"` + Memory int64 `json:"memory" sql:"type:bigint" db:"memory" csv:"memory"` // system_info fields - CPUType string `json:"cpu_type" db:"cpu_type"` - CPUSubtype string `json:"cpu_subtype" db:"cpu_subtype"` - CPUBrand string `json:"cpu_brand" db:"cpu_brand"` - CPUPhysicalCores int `json:"cpu_physical_cores" db:"cpu_physical_cores"` - CPULogicalCores int `json:"cpu_logical_cores" db:"cpu_logical_cores"` - HardwareVendor string `json:"hardware_vendor" db:"hardware_vendor"` - HardwareModel string `json:"hardware_model" db:"hardware_model"` - HardwareVersion string `json:"hardware_version" db:"hardware_version"` - HardwareSerial string `json:"hardware_serial" db:"hardware_serial"` - ComputerName string `json:"computer_name" db:"computer_name"` + CPUType string `json:"cpu_type" db:"cpu_type" csv:"cpu_type"` + CPUSubtype string `json:"cpu_subtype" db:"cpu_subtype" csv:"cpu_subtype"` + CPUBrand string `json:"cpu_brand" db:"cpu_brand" csv:"cpu_brand"` + CPUPhysicalCores int `json:"cpu_physical_cores" db:"cpu_physical_cores" csv:"cpu_physical_cores"` + CPULogicalCores int `json:"cpu_logical_cores" db:"cpu_logical_cores" csv:"cpu_logical_cores"` + HardwareVendor string `json:"hardware_vendor" db:"hardware_vendor" csv:"hardware_vendor"` + HardwareModel string `json:"hardware_model" db:"hardware_model" csv:"hardware_model"` + HardwareVersion string `json:"hardware_version" db:"hardware_version" csv:"hardware_version"` + HardwareSerial string `json:"hardware_serial" db:"hardware_serial" csv:"hardware_serial"` + ComputerName string `json:"computer_name" db:"computer_name" csv:"computer_name"` // PrimaryNetworkInterfaceID if present indicates to primary network for the host, the details of which // can be found in the NetworkInterfaces element with the same ip_address. - PrimaryNetworkInterfaceID *uint `json:"primary_ip_id,omitempty" db:"primary_ip_id"` - NetworkInterfaces []*NetworkInterface `json:"-" db:"-"` - PrimaryIP string `json:"primary_ip" db:"primary_ip"` - PrimaryMac string `json:"primary_mac" db:"primary_mac"` - DistributedInterval uint `json:"distributed_interval" db:"distributed_interval"` - ConfigTLSRefresh uint `json:"config_tls_refresh" db:"config_tls_refresh"` - LoggerTLSPeriod uint `json:"logger_tls_period" db:"logger_tls_period"` - TeamID *uint `json:"team_id" db:"team_id"` + PrimaryNetworkInterfaceID *uint `json:"primary_ip_id,omitempty" db:"primary_ip_id" csv:"primary_ip_id"` + NetworkInterfaces []*NetworkInterface `json:"-" db:"-" csv:"-"` + PrimaryIP string `json:"primary_ip" db:"primary_ip" csv:"primary_ip"` + PrimaryMac string `json:"primary_mac" db:"primary_mac" csv:"primary_mac"` + DistributedInterval uint `json:"distributed_interval" db:"distributed_interval" csv:"distributed_interval"` + ConfigTLSRefresh uint `json:"config_tls_refresh" db:"config_tls_refresh" csv:"config_tls_refresh"` + LoggerTLSPeriod uint `json:"logger_tls_period" db:"logger_tls_period" csv:"logger_tls_period"` + TeamID *uint `json:"team_id" db:"team_id" csv:"team_id"` // Loaded via JOIN in DB - PackStats []PackStats `json:"pack_stats"` + PackStats []PackStats `json:"pack_stats" csv:"-"` // TeamName is the name of the team, loaded by JOIN to the teams table. - TeamName *string `json:"team_name" db:"team_name"` + TeamName *string `json:"team_name" db:"team_name" csv:"team_name"` // Additional is the additional information from the host // additional_queries. This should be stored in a separate DB table. - Additional *json.RawMessage `json:"additional,omitempty" db:"additional"` + Additional *json.RawMessage `json:"additional,omitempty" db:"additional" csv:"-"` // Users currently in the host - Users []HostUser `json:"users,omitempty"` + Users []HostUser `json:"users,omitempty" csv:"-"` - GigsDiskSpaceAvailable float64 `json:"gigs_disk_space_available" db:"gigs_disk_space_available"` - PercentDiskSpaceAvailable float64 `json:"percent_disk_space_available" db:"percent_disk_space_available"` + GigsDiskSpaceAvailable float64 `json:"gigs_disk_space_available" db:"gigs_disk_space_available" csv:"gigs_disk_space_available"` + PercentDiskSpaceAvailable float64 `json:"percent_disk_space_available" db:"percent_disk_space_available" csv:"percent_disk_space_available"` - HostIssues `json:"issues,omitempty"` + HostIssues `json:"issues,omitempty" csv:"-"` - Modified bool `json:"-"` + Modified bool `json:"-" csv:"-"` } type HostIssues struct { - TotalIssuesCount int `json:"total_issues_count" db:"total_issues_count"` - FailingPoliciesCount int `json:"failing_policies_count" db:"failing_policies_count"` + TotalIssuesCount int `json:"total_issues_count" db:"total_issues_count" csv:"-"` + FailingPoliciesCount int `json:"failing_policies_count" db:"failing_policies_count" csv:"-"` } func (h Host) AuthzType() string { diff --git a/server/fleet/software.go b/server/fleet/software.go index 45bce98277..9479dfa130 100644 --- a/server/fleet/software.go +++ b/server/fleet/software.go @@ -48,12 +48,12 @@ type VulnerabilitiesSlice []SoftwareCVE // HostSoftware is the set of software installed on a specific host type HostSoftware struct { // Software is the software information. - Software []Software `json:"software,omitempty"` + Software []Software `json:"software,omitempty" csv:"-"` // Modified is a boolean indicating whether this has been modified since // loading. If Modified is true, datastore implementations should save the // data. We track this here because saving the software set is likely to be // an expensive operation. - Modified bool `json:"-"` + Modified bool `json:"-" csv:"-"` } type SoftwareIterator interface { diff --git a/server/fleet/traits.go b/server/fleet/traits.go index 870618ba27..9b04f88dc7 100644 --- a/server/fleet/traits.go +++ b/server/fleet/traits.go @@ -4,12 +4,12 @@ import "time" // CreateTimestamp contains common timestamp fields indicating create time type CreateTimestamp struct { - CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedAt time.Time `json:"created_at" db:"created_at" csv:"created_at"` } // UpdateTimestamp contains a timestamp that is set whenever an entity is changed type UpdateTimestamp struct { - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at" csv:"updated_at"` } type UpdateCreateTimestamps struct { diff --git a/server/service/handler.go b/server/service/handler.go index 6befae6816..4c2dfef5ad 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -306,6 +306,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC ue.POST("/api/_version_/fleet/hosts/transfer/filter", addHostsToTeamByFilterEndpoint, addHostsToTeamByFilterRequest{}) ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/refetch", refetchHostEndpoint, refetchHostRequest{}) ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{}) + ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{}) ue.POST("/api/_version_/fleet/labels", createLabelEndpoint, createLabelRequest{}) ue.PATCH("/api/_version_/fleet/labels/{id:[0-9]+}", modifyLabelEndpoint, modifyLabelRequest{}) diff --git a/server/service/hosts.go b/server/service/hosts.go index 40ba49c4b2..bfc942fc5f 100644 --- a/server/service/hosts.go +++ b/server/service/hosts.go @@ -2,11 +2,16 @@ package service import ( "context" + "fmt" + "net/http" "time" + "github.com/fleetdm/fleet/v4/server/contexts/authz" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" + "github.com/fleetdm/fleet/v4/server/contexts/logging" "github.com/fleetdm/fleet/v4/server/contexts/viewer" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/gocarina/gocsv" ) // HostResponse is the response struct that contains the full host information @@ -819,3 +824,64 @@ func (svc *Service) AggregatedMacadminsData(ctx context.Context, teamID *uint) ( return agg, 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"` +} + +type hostsReportResponse struct { + Hosts []*fleet.Host `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) { + 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.WriteHeader(http.StatusOK) + if err := gocsv.Marshal(r.Hosts, w); err != nil { + logging.WithErr(ctx, err) + } +} + +func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, 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 := authz.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 + } + + // Those are not supported when listing hosts in a label, so that's just to + // make the output consistent whether a label is used or not. + req.Opts.DisableFailingPolicies = true + req.Opts.AdditionalFilters = nil + + 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 + } + return hostsReportResponse{Hosts: hosts}, nil +} diff --git a/server/service/integration_core_test.go b/server/service/integration_core_test.go index 6e8bc4717d..bb9b54fbe0 100644 --- a/server/service/integration_core_test.go +++ b/server/service/integration_core_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/base64" + "encoding/csv" "encoding/json" "fmt" "io/ioutil" @@ -1033,7 +1034,8 @@ func (s *integrationTestSuite) TestInvites() { updateInviteReq := updateInviteRequest{InvitePayload: fleet.InvitePayload{ Teams: []fleet.UserTeam{ {Team: fleet.Team{ID: team.ID}, Role: fleet.RoleObserver}, - }}} + }, + }} updateInviteResp := updateInviteResponse{} s.DoJSON("PATCH", fmt.Sprintf("/api/v1/fleet/invites/%d", validInvite.ID+1), updateInviteReq, http.StatusNotFound, &updateInviteResp) @@ -3704,6 +3706,36 @@ func (s *integrationTestSuite) TestModifyUser() { require.Equal(t, u.ID, loginResp.User.ID) } +func (s *integrationTestSuite) TestHostsReportDownload() { + t := s.T() + + hosts := s.createHosts(t) + + res := s.DoRaw("GET", "/api/v1/fleet/hosts/report", nil, http.StatusUnsupportedMediaType, "format", "gzip") + var errs struct { + Message string `json:"message"` + Errors []struct { + Name string `json:"name"` + Reason string `json:"reason"` + } `json:"errors"` + } + require.NoError(t, json.NewDecoder(res.Body).Decode(&errs)) + res.Body.Close() + require.Len(t, errs.Errors, 1) + assert.Equal(t, "format", errs.Errors[0].Name) + + res = s.DoRaw("GET", "/api/v1/fleet/hosts/report", nil, http.StatusOK, "format", "csv") + rows, err := csv.NewReader(res.Body).ReadAll() + res.Body.Close() + require.NoError(t, err) + require.Len(t, rows, len(hosts)+1) + require.Contains(t, rows[0], "hostname") // first row contains headers + require.Contains(t, res.Header, "Content-Disposition") + require.Contains(t, res.Header, "Content-Type") + require.Contains(t, res.Header.Get("Content-Disposition"), "attachment;") + require.Contains(t, res.Header.Get("Content-Type"), "text/csv") +} + // creates a session and returns it, its key is to be passed as authorization header. func createSession(t *testing.T, uid uint, ds fleet.Datastore) *fleet.Session { key := make([]byte, 64) diff --git a/server/service/middleware/authzcheck/authzcheck.go b/server/service/middleware/authzcheck/authzcheck.go index 44db75b40c..965748efb4 100644 --- a/server/service/middleware/authzcheck/authzcheck.go +++ b/server/service/middleware/authzcheck/authzcheck.go @@ -42,6 +42,12 @@ func (m *Middleware) AuthzCheck() endpoint.Middleware { return nil, err } + // TODO(mna): currently, any error detected before an authorization check gets + // lost and the response is always Unauthorized because of the following condition. + // I _think_ it would be safe to check here of response.error() returns a non-nil + // error and if so, leave that error go through instead of returning a check missing + // authorization error. To look into when addressing #4406. + // If authorization was not checked, return a response that will // marshal to a generic error and log that the check was missed. if !authzctx.Checked() { diff --git a/server/service/transport.go b/server/service/transport.go index ddbe5dbea8..9007a6fa93 100644 --- a/server/service/transport.go +++ b/server/service/transport.go @@ -16,10 +16,8 @@ import ( "github.com/gorilla/mux" ) -var ( - // errBadRoute is used for mux errors - errBadRoute = errors.New("bad route") -) +// errBadRoute is used for mux errors +var errBadRoute = errors.New("bad route") func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error { // The has to happen first, if an error happens we'll redirect to an error @@ -35,9 +33,14 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa return nil } + if render, ok := response.(renderHijacker); ok { + render.hijackRender(ctx, w) + return nil + } + if e, ok := response.(statuser); ok { - w.WriteHeader(e.status()) - if e.status() == http.StatusNoContent { + w.WriteHeader(e.Status()) + if e.Status() == http.StatusNoContent { return nil } } @@ -50,7 +53,7 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa // statuser allows response types to implement a custom // http success status - default is 200 OK type statuser interface { - status() int + Status() int } // loads a html page @@ -59,6 +62,12 @@ type htmlPage interface { error() error } +// renderHijacker can be implemented by response values to take control of +// their own rendering. +type renderHijacker interface { + hijackRender(ctx context.Context, w http.ResponseWriter) +} + func uintFromRequest(r *http.Request, name string) (uint64, error) { vars := mux.Vars(r) s, ok := vars[name] diff --git a/server/service/transport_error.go b/server/service/transport_error.go index bbf406caad..4f043e6374 100644 --- a/server/service/transport_error.go +++ b/server/service/transport_error.go @@ -90,7 +90,11 @@ func encodeError(ctx context.Context, err error, w http.ResponseWriter) { Message: "Validation Failed", Errors: e.Invalid(), } - w.WriteHeader(http.StatusUnprocessableEntity) + if statusErr, ok := e.(statuser); ok { + w.WriteHeader(statusErr.Status()) + } else { + w.WriteHeader(http.StatusUnprocessableEntity) + } enc.Encode(ve) case permissionErrorInterface: pe := jsonError{ diff --git a/server/service/users.go b/server/service/users.go index c9549bffe4..4533830409 100644 --- a/server/service/users.go +++ b/server/service/users.go @@ -884,7 +884,7 @@ type forgotPasswordResponse struct { } func (r forgotPasswordResponse) error() error { return r.Err } -func (r forgotPasswordResponse) status() int { return http.StatusAccepted } +func (r forgotPasswordResponse) Status() int { return http.StatusAccepted } func forgotPasswordEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { req := request.(*forgotPasswordRequest)