From b60d535d4a5798e504bdd7700979946cf35f6cdf Mon Sep 17 00:00:00 2001 From: Juan Fernandez Date: Mon, 12 Sep 2022 16:37:38 -0300 Subject: [PATCH] Feature 7084: Add new EE endpoint for Fleet Desktop (#7530) Added new EE endpoint, that is meant to be used by Fleet Desktop only. The new endpoint will return the number of failed policies. --- .../feature-7084-ee-fleet-desktop-endpoint | 2 + cmd/osquery-perf/agent.go | 2 +- docs/Contributing/API-for-contributors.md | 30 ++++++ ee/server/service/devices.go | 4 + server/datastore/mysql/hosts.go | 23 +++++ server/datastore/mysql/hosts_test.go | 97 +++++++++++++++++++ server/fleet/datastore.go | 3 + server/fleet/service.go | 4 + server/mock/datastore_mock.go | 10 ++ server/service/devices.go | 42 ++++++++ server/service/handler.go | 3 + server/service/integration_enterprise_test.go | 12 +++ 12 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 changes/feature-7084-ee-fleet-desktop-endpoint diff --git a/changes/feature-7084-ee-fleet-desktop-endpoint b/changes/feature-7084-ee-fleet-desktop-endpoint new file mode 100644 index 0000000000..2ee099abb7 --- /dev/null +++ b/changes/feature-7084-ee-fleet-desktop-endpoint @@ -0,0 +1,2 @@ +Added new EE endpoint at '/api/_version_/fleet/device/{token}/desktop' to be used by Fleet Desktop +to get the number of failing policies. \ No newline at end of file diff --git a/cmd/osquery-perf/agent.go b/cmd/osquery-perf/agent.go index c738998a1e..974a38ea9f 100644 --- a/cmd/osquery-perf/agent.go +++ b/cmd/osquery-perf/agent.go @@ -631,7 +631,7 @@ func (a *agent) runPolicy(query string) []map[string]string { {"1": "1"}, } } - return nil + return []map[string]string{} } func (a *agent) randomQueryStats() []map[string]string { diff --git a/docs/Contributing/API-for-contributors.md b/docs/Contributing/API-for-contributors.md index 7501c3308a..31c7aa9237 100644 --- a/docs/Contributing/API-for-contributors.md +++ b/docs/Contributing/API-for-contributors.md @@ -1619,6 +1619,36 @@ Same as [Get host's mobile device management and Munki information](../Using-Fle | --------------- | ------ | ----- | ---------------------------------------| | token | string | path | The device's authentication token. | + +#### Get Fleet Desktop information +_Available in Fleet Premium_ + +Gets all information required by Fleet Desktop to notify the user if there are any failing policies. + +`GET /api/v1/fleet/device/{token}/desktop` + +##### Parameters + +| Name | Type | In | Description | +| --------------- | ------ | ----- | ---------------------------------------| +| token | string | path | The device's authentication token. | + +##### Example + +`GET /api/v1/fleet/device/abcdef012456789/desktop` + +##### Default response + +`Status: 200` + +```json +{ + "failing_policies_count": 3 +} +``` + + + #### Get device's policies _Available in Fleet Premium_ diff --git a/ee/server/service/devices.go b/ee/server/service/devices.go index 6b9dc19908..61a71808a5 100644 --- a/ee/server/service/devices.go +++ b/ee/server/service/devices.go @@ -9,3 +9,7 @@ import ( func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { return svc.ds.ListPoliciesForHost(ctx, host) } + +func (svc *Service) FailingPoliciesCount(ctx context.Context, host *fleet.Host) (uint, error) { + return svc.ds.FailingPoliciesCount(ctx, host) +} diff --git a/server/datastore/mysql/hosts.go b/server/datastore/mysql/hosts.go index 5206062e05..a77e886077 100644 --- a/server/datastore/mysql/hosts.go +++ b/server/datastore/mysql/hosts.go @@ -1139,6 +1139,29 @@ func (ds *Datastore) DeleteHosts(ctx context.Context, ids []uint) error { return nil } +func (ds *Datastore) FailingPoliciesCount(ctx context.Context, host *fleet.Host) (uint, error) { + if host.FleetPlatform() == "" { + // We log to help troubleshooting in case this happens. + level.Error(ds.logger).Log("err", fmt.Sprintf("host %d with empty platform", host.ID)) + } + + query := ` + SELECT SUM(1 - pm.passes) AS n_failed + FROM policy_membership pm + WHERE pm.host_id = ? + GROUP BY host_id + ` + + var r uint + if err := sqlx.GetContext(ctx, ds.reader, &r, query, host.ID); err != nil { + if err == sql.ErrNoRows { + return 0, nil + } + return 0, ctxerr.Wrap(ctx, err, "get failing policies count") + } + return r, nil +} + func (ds *Datastore) ListPoliciesForHost(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { if host.FleetPlatform() == "" { // We log to help troubleshooting in case this happens. diff --git a/server/datastore/mysql/hosts_test.go b/server/datastore/mysql/hosts_test.go index 2c9f0a7cd4..a7d1c0cb38 100644 --- a/server/datastore/mysql/hosts_test.go +++ b/server/datastore/mysql/hosts_test.go @@ -127,6 +127,7 @@ func TestHosts(t *testing.T) { {"ShouldCleanTeamPolicies", testShouldCleanTeamPolicies}, {"ReplaceHostBatteries", testHostsReplaceHostBatteries}, {"CountHostsNotResponding", testCountHostsNotResponding}, + {"FailingPoliciesCount", testFailingPoliciesCount}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { @@ -5106,3 +5107,99 @@ func testCountHostsNotResponding(t *testing.T, ds *Datastore) { require.NoError(t, err) require.Equal(t, 2, count) // count unchanged } + +func testFailingPoliciesCount(t *testing.T, ds *Datastore) { + ctx := context.Background() + + var hosts []*fleet.Host + for i := 0; i < 10; i++ { + h := test.NewHost(t, ds, fmt.Sprintf("foo.local.%d", i), "1.1.1.1", + fmt.Sprintf("%d", i), fmt.Sprintf("%d", i), time.Now()) + hosts = append(hosts, h) + } + + t.Run("no policies", func(t *testing.T) { + for _, h := range hosts { + actual, err := ds.FailingPoliciesCount(ctx, h) + require.NoError(t, err) + require.Equal(t, actual, uint(0)) + } + }) + + t.Run("with policies and memberships", func(t *testing.T) { + u := test.NewUser(t, ds, "Bob", "bob@example.com", true) + + var policies []*fleet.Policy + for i := 0; i < 10; i++ { + q := test.NewQuery(t, ds, fmt.Sprintf("query%d", i), "select 1", 0, true) + p, err := ds.NewGlobalPolicy(ctx, &u.ID, fleet.PolicyPayload{QueryID: &q.ID}) + require.NoError(t, err) + policies = append(policies, p) + } + + testCases := []struct { + host *fleet.Host + policyEx map[uint]*bool + expected uint + }{ + { + host: hosts[0], + policyEx: map[uint]*bool{ + policies[0].ID: ptr.Bool(true), + policies[1].ID: ptr.Bool(true), + policies[2].ID: ptr.Bool(false), + policies[3].ID: ptr.Bool(true), + policies[4].ID: nil, + policies[5].ID: nil, + }, + expected: 1, + }, + { + host: hosts[1], + policyEx: map[uint]*bool{ + policies[0].ID: ptr.Bool(true), + policies[1].ID: ptr.Bool(true), + policies[2].ID: ptr.Bool(true), + policies[3].ID: ptr.Bool(true), + policies[4].ID: ptr.Bool(true), + policies[5].ID: ptr.Bool(true), + policies[6].ID: ptr.Bool(true), + policies[7].ID: ptr.Bool(true), + policies[8].ID: ptr.Bool(true), + policies[9].ID: ptr.Bool(true), + }, + expected: 0, + }, + { + host: hosts[2], + policyEx: map[uint]*bool{ + policies[0].ID: ptr.Bool(true), + policies[1].ID: ptr.Bool(true), + policies[2].ID: ptr.Bool(true), + policies[3].ID: ptr.Bool(true), + policies[4].ID: ptr.Bool(true), + policies[5].ID: ptr.Bool(false), + policies[6].ID: ptr.Bool(false), + policies[7].ID: ptr.Bool(false), + policies[8].ID: ptr.Bool(false), + policies[9].ID: ptr.Bool(false), + }, + expected: 5, + }, + { + host: hosts[3], + policyEx: map[uint]*bool{}, + expected: 0, + }, + } + + for _, tc := range testCases { + if len(tc.policyEx) != 0 { + require.NoError(t, ds.RecordPolicyQueryExecutions(ctx, tc.host, tc.policyEx, time.Now(), false)) + } + actual, err := ds.FailingPoliciesCount(ctx, tc.host) + require.NoError(t, err) + require.Equal(t, tc.expected, actual) + } + }) +} diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go index bee0b90793..c75417631f 100644 --- a/server/fleet/datastore.go +++ b/server/fleet/datastore.go @@ -237,6 +237,9 @@ type Datastore interface { // SetOrUpdateDeviceAuthToken inserts or updates the auth token for a host. SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint, authToken string) error + // FailingPoliciesCount returns the number of failling policies for 'host' + FailingPoliciesCount(ctx context.Context, host *Host) (uint, error) + // ListPoliciesForHost lists the policies that a host will check and whether they are passing ListPoliciesForHost(ctx context.Context, host *Host) ([]*HostPolicy, error) diff --git a/server/fleet/service.go b/server/fleet/service.go index 0ab479063e..7b38985d12 100644 --- a/server/fleet/service.go +++ b/server/fleet/service.go @@ -293,6 +293,10 @@ type Service interface { // ListHostDeviceMapping returns the list of device-mapping of user's email address // for the host. ListHostDeviceMapping(ctx context.Context, id uint) ([]*HostDeviceMapping, error) + + // FailingPoliciesCount returns the number of failling policies for 'host' + FailingPoliciesCount(ctx context.Context, host *Host) (uint, error) + // ListDevicePolicies lists all policies for the given host, including passing / failing summaries ListDevicePolicies(ctx context.Context, host *Host) ([]*HostPolicy, error) diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go index 31cb32d3df..193941d1ac 100644 --- a/server/mock/datastore_mock.go +++ b/server/mock/datastore_mock.go @@ -187,6 +187,8 @@ type LoadHostByDeviceAuthTokenFunc func(ctx context.Context, authToken string) ( type SetOrUpdateDeviceAuthTokenFunc func(ctx context.Context, hostID uint, authToken string) error +type FailingPoliciesCountFunc func(ctx context.Context, host *fleet.Host) (uint, error) + type ListPoliciesForHostFunc func(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) type GetHostMunkiVersionFunc func(ctx context.Context, hostID uint) (string, error) @@ -709,6 +711,9 @@ type DataStore struct { SetOrUpdateDeviceAuthTokenFunc SetOrUpdateDeviceAuthTokenFunc SetOrUpdateDeviceAuthTokenFuncInvoked bool + FailingPoliciesCountFunc FailingPoliciesCountFunc + FailingPoliciesCountFuncInvoked bool + ListPoliciesForHostFunc ListPoliciesForHostFunc ListPoliciesForHostFuncInvoked bool @@ -1535,6 +1540,11 @@ func (s *DataStore) SetOrUpdateDeviceAuthToken(ctx context.Context, hostID uint, return s.SetOrUpdateDeviceAuthTokenFunc(ctx, hostID, authToken) } +func (s *DataStore) FailingPoliciesCount(ctx context.Context, host *fleet.Host) (uint, error) { + s.FailingPoliciesCountFuncInvoked = true + return s.FailingPoliciesCountFunc(ctx, host) +} + func (s *DataStore) ListPoliciesForHost(ctx context.Context, host *fleet.Host) ([]*fleet.HostPolicy, error) { s.ListPoliciesForHostFuncInvoked = true return s.ListPoliciesForHostFunc(ctx, host) diff --git a/server/service/devices.go b/server/service/devices.go index 5595c44549..5e0038a4dd 100644 --- a/server/service/devices.go +++ b/server/service/devices.go @@ -9,6 +9,40 @@ import ( "github.com/fleetdm/fleet/v4/server/fleet" ) +///////////////////////////////////////////////////////////////////////////////// +// Fleet Desktop endpoints +///////////////////////////////////////////////////////////////////////////////// +type getFleetDesktopResponse struct { + Err error `json:"error,omitempty"` + FailingPolicies *uint `json:"failing_policies_count,omitempty"` +} + +type getFleetDesktopRequest struct { + Token string `url:"token"` +} + +func (r *getFleetDesktopRequest) deviceAuthToken() string { + return r.Token +} + +// getFleetDesktopEndpoint is meant to be the only API endpoint used by Fleet Desktop. This +// endpoint should not include any kind of identifying information about the host. +func getFleetDesktopEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) { + host, ok := hostctx.FromContext(ctx) + + if !ok { + err := ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context")) + return getFleetDesktopResponse{Err: err}, nil + } + + r, err := svc.FailingPoliciesCount(ctx, host) + if err != nil { + return getFleetDesktopResponse{Err: err}, nil + } + + return getFleetDesktopResponse{FailingPolicies: &r}, nil +} + ///////////////////////////////////////////////////////////////////////////////// // Get Current Device's Host ///////////////////////////////////////////////////////////////////////////////// @@ -216,6 +250,14 @@ func (svc *Service) ListDevicePolicies(ctx context.Context, host *fleet.Host) ([ return nil, fleet.ErrMissingLicense } +func (svc *Service) FailingPoliciesCount(ctx context.Context, host *fleet.Host) (uint, error) { + // skipauth: No authorization check needed due to implementation returning + // only license error. + svc.authz.SkipAuthorization(ctx) + + return 0, fleet.ErrMissingLicense +} + //////////////////////////////////////////////////////////////////////////////// // Device API features //////////////////////////////////////////////////////////////////////////////// diff --git a/server/service/handler.go b/server/service/handler.go index 0ffa0196bc..d39bf98493 100644 --- a/server/service/handler.go +++ b/server/service/handler.go @@ -411,6 +411,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC de.WithCustomMiddleware( errorLimiter.Limit("get_device_host", desktopQuota), ).GET("/api/_version_/fleet/device/{token}", getDeviceHostEndpoint, getDeviceHostRequest{}) + de.WithCustomMiddleware( + errorLimiter.Limit("get_fleet_desktop", desktopQuota), + ).GET("/api/_version_/fleet/device/{token}/desktop", getFleetDesktopEndpoint, getFleetDesktopRequest{}) de.WithCustomMiddleware( errorLimiter.Limit("refetch_device_host", desktopQuota), ).POST("/api/_version_/fleet/device/{token}/refetch", refetchDeviceHostEndpoint, refetchDeviceHostRequest{}) diff --git a/server/service/integration_enterprise_test.go b/server/service/integration_enterprise_test.go index 78dbf962c9..4ec5652cfa 100644 --- a/server/service/integration_enterprise_test.go +++ b/server/service/integration_enterprise_test.go @@ -1104,6 +1104,10 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { s.DoJSON("POST", "/api/latest/fleet/policies", gpParams, http.StatusOK, &gpResp) require.NotNil(t, gpResp.Policy) + // add a policy execution + require.NoError(t, s.ds.RecordPolicyQueryExecutions(context.Background(), host, + map[uint]*bool{gpResp.Policy.ID: ptr.Bool(false)}, time.Now(), false)) + // add a policy to team oldToken := s.token t.Cleanup(func() { @@ -1162,6 +1166,14 @@ func (s *integrationEnterpriseTestSuite) TestListDevicePolicies() { require.False(t, getDeviceHostResp.Host.RefetchRequested) require.Equal(t, "http://example.com/logo", getDeviceHostResp.OrgLogoURL) require.Len(t, *getDeviceHostResp.Host.Policies, 2) + + // GET `/api/_version_/fleet/device/{token}/desktop` + getDesktopResp := getFleetDesktopResponse{} + res = s.DoRawNoAuth("GET", "/api/latest/fleet/device/"+token+"/desktop", nil, http.StatusOK) + json.NewDecoder(res.Body).Decode(&getDesktopResp) + res.Body.Close() + require.NoError(t, getDesktopResp.Err) + require.Equal(t, *getDesktopResp.FailingPolicies, uint(1)) } // TestCustomTransparencyURL tests that Fleet Premium licensees can use custom transparency urls.