Add support for downloading a list of hosts in CSV format (#4596)

This commit is contained in:
Martin Angers 2022-03-15 15:14:42 -04:00 committed by GitHub
parent f4d3159cc9
commit bb678b6b2e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 248 additions and 62 deletions

View file

@ -0,0 +1 @@
* Add support for downloading a list of hosts in CSV format

View file

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

1
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

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

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -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() {

View file

@ -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]

View file

@ -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{

View file

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