diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index 64c379e5817..163f8184be2 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -17,6 +17,7 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/publicdashboards" + "github.com/grafana/grafana/pkg/services/publicdashboards/internal/tokens" . "github.com/grafana/grafana/pkg/services/publicdashboards/models" "github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/web" @@ -52,7 +53,7 @@ func ProvideApi( return api } -// Registers Endpoints on Grafana Router +// RegisterAPIEndpoints Registers Endpoints on Grafana Router func (api *Api) RegisterAPIEndpoints() { auth := accesscontrol.Middleware(api.AccessControl) @@ -79,11 +80,15 @@ func (api *Api) RegisterAPIEndpoints() { routing.Wrap(api.SavePublicDashboardConfig)) } -// Gets public dashboard +// GetPublicDashboard Gets public dashboard // GET /api/public/dashboards/:accessToken func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { accessToken := web.Params(c.Req)[":accessToken"] + if !tokens.IsValidAccessToken(accessToken) { + return response.Error(http.StatusBadRequest, "Invalid Access Token", nil) + } + pubdash, dash, err := api.PublicDashboardService.GetPublicDashboard( c.Req.Context(), accessToken, @@ -115,7 +120,7 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, dto) } -// Gets list of public dashboards for an org +// ListPublicDashboards Gets list of public dashboards for an org // GET /api/dashboards/public func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { resp, err := api.PublicDashboardService.ListPublicDashboards(c.Req.Context(), c.SignedInUser, c.OrgID) @@ -125,7 +130,7 @@ func (api *Api) ListPublicDashboards(c *models.ReqContext) response.Response { return response.JSON(http.StatusOK, resp) } -// Gets public dashboard configuration for dashboard +// GetPublicDashboardConfig Gets public dashboard configuration for dashboard // GET /api/dashboards/uid/:uid/public-config func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response { pdc, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), c.OrgID, web.Params(c.Req)[":uid"]) @@ -135,7 +140,7 @@ func (api *Api) GetPublicDashboardConfig(c *models.ReqContext) response.Response return response.JSON(http.StatusOK, pdc) } -// Sets public dashboard configuration for dashboard +// SavePublicDashboardConfig Sets public dashboard configuration for dashboard // POST /api/dashboards/uid/:uid/public-config func (api *Api) SavePublicDashboardConfig(c *models.ReqContext) response.Response { // exit if we don't have a valid dashboardUid @@ -170,6 +175,11 @@ func (api *Api) SavePublicDashboardConfig(c *models.ReqContext) response.Respons // QueryPublicDashboard returns all results for a given panel on a public dashboard // POST /api/public/dashboard/:accessToken/panels/:panelId/query func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { + accessToken := web.Params(c.Req)[":accessToken"] + if !tokens.IsValidAccessToken(accessToken) { + return response.Error(http.StatusBadRequest, "Invalid Access Token", nil) + } + panelId, err := strconv.ParseInt(web.Params(c.Req)[":panelId"], 10, 64) if err != nil { return response.Error(http.StatusBadRequest, "QueryPublicDashboard: invalid panel ID", err) @@ -180,7 +190,7 @@ func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { return response.Error(http.StatusBadRequest, "QueryPublicDashboard: bad request data", err) } - resp, err := api.PublicDashboardService.GetQueryDataResponse(c.Req.Context(), c.SkipCache, reqDTO, panelId, web.Params(c.Req)[":accessToken"]) + resp, err := api.PublicDashboardService.GetQueryDataResponse(c.Req.Context(), c.SkipCache, reqDTO, panelId, accessToken) if err != nil { return api.handleError(c.Req.Context(), http.StatusInternalServerError, "QueryPublicDashboard: error running public dashboard panel queries", err) } @@ -188,13 +198,20 @@ func (api *Api) QueryPublicDashboard(c *models.ReqContext) response.Response { return toJsonStreamingResponse(api.Features, resp) } +// GetAnnotations returns annotations for a public dashboard +// GET /api/public/dashboards/:accessToken/annotations func (api *Api) GetAnnotations(c *models.ReqContext) response.Response { + accessToken := web.Params(c.Req)[":accessToken"] + if !tokens.IsValidAccessToken(accessToken) { + return response.Error(http.StatusBadRequest, "Invalid Access Token", nil) + } + reqDTO := AnnotationsQueryDTO{ From: c.QueryInt64("from"), To: c.QueryInt64("to"), } - annotations, err := api.PublicDashboardService.GetAnnotations(c.Req.Context(), reqDTO, web.Params(c.Req)[":accessToken"]) + annotations, err := api.PublicDashboardService.GetAnnotations(c.Req.Context(), reqDTO, accessToken) if err != nil { return api.handleError(c.Req.Context(), http.StatusInternalServerError, "error getting public dashboard annotations", err) diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index 99c9c0b4720..5e9581aa16d 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -10,7 +10,6 @@ import ( "testing" "github.com/aws/aws-sdk-go/aws" - "github.com/google/uuid" "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/stretchr/testify/assert" @@ -53,28 +52,44 @@ type JsonErrResponse struct { func TestAPIGetAnnotations(t *testing.T) { testCases := []struct { - Name string - ExpectedHttpResponse int - Annotations []AnnotationEvent - ServiceError error - From string - To string + Name string + ExpectedHttpResponse int + Annotations []AnnotationEvent + ServiceError error + AccessToken string + From string + To string + ExpectedServiceCalled bool }{ { - Name: "will return success when there is no error and to and from are provided", - ExpectedHttpResponse: http.StatusOK, - Annotations: []AnnotationEvent{{Id: 1}}, - ServiceError: nil, - From: "123", - To: "123", + Name: "will return success when there is no error and to and from are provided", + ExpectedHttpResponse: http.StatusOK, + Annotations: []AnnotationEvent{{Id: 1}}, + ServiceError: nil, + AccessToken: validAccessToken, + From: "123", + To: "123", + ExpectedServiceCalled: true, }, { - Name: "will return 500 when service returns an error", - ExpectedHttpResponse: http.StatusInternalServerError, - Annotations: nil, - ServiceError: errors.New("an error happened"), - From: "123", - To: "123", + Name: "will return 500 when service returns an error", + ExpectedHttpResponse: http.StatusInternalServerError, + Annotations: nil, + ServiceError: errors.New("an error happened"), + AccessToken: validAccessToken, + From: "123", + To: "123", + ExpectedServiceCalled: true, + }, + { + Name: "will return 400 when has an incorrect Access Token", + ExpectedHttpResponse: http.StatusBadRequest, + Annotations: nil, + ServiceError: errors.New("an error happened"), + AccessToken: "TooShortAccessToken", + From: "123", + To: "123", + ExpectedServiceCalled: false, }, } for _, test := range testCases { @@ -82,11 +97,15 @@ func TestAPIGetAnnotations(t *testing.T) { cfg := setting.NewCfg() cfg.RBACEnabled = false service := publicdashboards.NewFakePublicDashboardService(t) - service.On("GetAnnotations", mock.Anything, mock.Anything, mock.AnythingOfType("string")). - Return(test.Annotations, test.ServiceError).Once() + + if test.ExpectedServiceCalled { + service.On("GetAnnotations", mock.Anything, mock.Anything, mock.AnythingOfType("string")). + Return(test.Annotations, test.ServiceError).Once() + } + testServer := setupTestServer(t, cfg, featuremgmt.WithFeatures(featuremgmt.FlagPublicDashboards), service, nil, anonymousUser) - path := fmt.Sprintf("/api/public/dashboards/abc123/annotations?from=%s&to=%s", test.From, test.To) + path := fmt.Sprintf("/api/public/dashboards/%s/annotations?from=%s&to=%s", test.AccessToken, test.From, test.To) response := callAPI(testServer, http.MethodGet, path, nil, t) assert.Equal(t, test.ExpectedHttpResponse, response.Code) @@ -221,9 +240,6 @@ func TestAPIListPublicDashboard(t *testing.T) { func TestAPIGetPublicDashboard(t *testing.T) { DashboardUid := "dashboard-abcd1234" - token, err := uuid.NewRandom() - require.NoError(t, err) - accessToken := fmt.Sprintf("%x", token) testCases := []struct { Name string @@ -231,24 +247,35 @@ func TestAPIGetPublicDashboard(t *testing.T) { ExpectedHttpResponse int DashboardResult *models.Dashboard Err error + FixedErrorResponse string }{ { Name: "It gets a public dashboard", - AccessToken: accessToken, + AccessToken: validAccessToken, ExpectedHttpResponse: http.StatusOK, DashboardResult: &models.Dashboard{ Data: simplejson.NewFromAny(map[string]interface{}{ "Uid": DashboardUid, }), }, - Err: nil, + Err: nil, + FixedErrorResponse: "", }, { Name: "It should return 404 if no public dashboard", - AccessToken: accessToken, + AccessToken: validAccessToken, ExpectedHttpResponse: http.StatusNotFound, DashboardResult: nil, Err: ErrPublicDashboardNotFound, + FixedErrorResponse: "", + }, + { + Name: "It should return 400 if it is an invalid access token", + AccessToken: "SomeInvalidAccessToken", + ExpectedHttpResponse: http.StatusBadRequest, + DashboardResult: nil, + Err: nil, + FixedErrorResponse: "{\"message\":\"Invalid Access Token\"}", }, } @@ -278,7 +305,7 @@ func TestAPIGetPublicDashboard(t *testing.T) { assert.Equal(t, test.ExpectedHttpResponse, response.Code) - if test.Err == nil { + if test.Err == nil && test.FixedErrorResponse == "" { var dashResp dtos.DashboardFullWithMeta err := json.Unmarshal(response.Body.Bytes(), &dashResp) require.NoError(t, err) @@ -287,6 +314,9 @@ func TestAPIGetPublicDashboard(t *testing.T) { assert.Equal(t, false, dashResp.Meta.CanEdit) assert.Equal(t, false, dashResp.Meta.CanDelete) assert.Equal(t, false, dashResp.Meta.CanSave) + } else if test.FixedErrorResponse != "" { + require.Equal(t, test.ExpectedHttpResponse, response.Code) + require.JSONEq(t, "{\"message\":\"Invalid Access Token\"}", response.Body.String()) } else { var errResp JsonErrResponse err := json.Unmarshal(response.Body.Bytes(), &errResp) @@ -585,29 +615,37 @@ func TestAPIQueryPublicDashboard(t *testing.T) { t.Run("Status code is 400 when the panel ID is invalid", func(t *testing.T) { server, _ := setup(true) - resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/notanumber/query", strings.NewReader("{}"), t) + path := fmt.Sprintf("/api/public/dashboards/%s/panels/notanumber/query", validAccessToken) + resp := callAPI(server, http.MethodPost, path, strings.NewReader("{}"), t) require.Equal(t, http.StatusBadRequest, resp.Code) }) + t.Run("Status code is 400 when the access token is invalid", func(t *testing.T) { + server, _ := setup(true) + resp := callAPI(server, http.MethodPost, getValidQueryPath("SomeInvalidAccessToken"), strings.NewReader("{}"), t) + require.Equal(t, http.StatusBadRequest, resp.Code) + require.JSONEq(t, "{\"message\":\"Invalid Access Token\"}", resp.Body.String()) + }) + t.Run("Status code is 400 when the intervalMS is lesser than 0", func(t *testing.T) { server, fakeDashboardService := setup(true) - fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), "abc123").Return(&backend.QueryDataResponse{}, ErrPublicDashboardBadRequest) - resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader(`{"intervalMs":-100,"maxDataPoints":1000}`), t) + fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, ErrPublicDashboardBadRequest) + resp := callAPI(server, http.MethodPost, getValidQueryPath(validAccessToken), strings.NewReader(`{"intervalMs":-100,"maxDataPoints":1000}`), t) require.Equal(t, http.StatusBadRequest, resp.Code) }) t.Run("Status code is 400 when the maxDataPoints is lesser than 0", func(t *testing.T) { server, fakeDashboardService := setup(true) - fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), "abc123").Return(&backend.QueryDataResponse{}, ErrPublicDashboardBadRequest) - resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader(`{"intervalMs":100,"maxDataPoints":-1000}`), t) + fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, ErrPublicDashboardBadRequest) + resp := callAPI(server, http.MethodPost, getValidQueryPath(validAccessToken), strings.NewReader(`{"intervalMs":100,"maxDataPoints":-1000}`), t) require.Equal(t, http.StatusBadRequest, resp.Code) }) t.Run("Returns query data when feature toggle is enabled", func(t *testing.T) { server, fakeDashboardService := setup(true) - fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), "abc123").Return(mockedResponse, nil) + fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(mockedResponse, nil) - resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) + resp := callAPI(server, http.MethodPost, getValidQueryPath(validAccessToken), strings.NewReader("{}"), t) require.JSONEq( t, @@ -619,13 +657,17 @@ func TestAPIQueryPublicDashboard(t *testing.T) { t.Run("Status code is 500 when the query fails", func(t *testing.T) { server, fakeDashboardService := setup(true) - fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), "abc123").Return(&backend.QueryDataResponse{}, fmt.Errorf("error")) + fakeDashboardService.On("GetQueryDataResponse", mock.Anything, true, mock.Anything, int64(2), validAccessToken).Return(&backend.QueryDataResponse{}, fmt.Errorf("error")) - resp := callAPI(server, http.MethodPost, "/api/public/dashboards/abc123/panels/2/query", strings.NewReader("{}"), t) + resp := callAPI(server, http.MethodPost, getValidQueryPath(validAccessToken), strings.NewReader("{}"), t) require.Equal(t, http.StatusInternalServerError, resp.Code) }) } +func getValidQueryPath(accessToken string) string { + return fmt.Sprintf("/api/public/dashboards/%s/panels/2/query", accessToken) +} + func TestIntegrationUnauthenticatedUserCanGetPubdashPanelQueryData(t *testing.T) { db := db.InitTestDB(t) diff --git a/pkg/services/publicdashboards/api/middleware.go b/pkg/services/publicdashboards/api/middleware.go index 900701ad056..0b17ff9ee59 100644 --- a/pkg/services/publicdashboards/api/middleware.go +++ b/pkg/services/publicdashboards/api/middleware.go @@ -10,7 +10,7 @@ import ( "github.com/grafana/grafana/pkg/web" ) -// Adds orgId to context based on org of public dashboard +// SetPublicDashboardOrgIdOnContext Adds orgId to context based on org of public dashboard func SetPublicDashboardOrgIdOnContext(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) { return func(c *models.ReqContext) { accessToken, ok := web.Params(c.Req)[":accessToken"] @@ -28,14 +28,15 @@ func SetPublicDashboardOrgIdOnContext(publicDashboardService publicdashboards.Se } } -// Adds public dashboard flag on context +// SetPublicDashboardFlag Adds public dashboard flag on context func SetPublicDashboardFlag(c *models.ReqContext) { c.IsPublicDashboardView = true } -// Middleware to enforce that a public dashboards exists before continuing to -// handler -func RequiresValidAccessToken(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) { +// RequiresExistingAccessToken Middleware to enforce that a public dashboards exists before continuing to handler. This +// method will query the database to ensure that it exists. +// Use when we want to enforce a public dashboard is valid on an endpoint we do not maintain +func RequiresExistingAccessToken(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) { return func(c *models.ReqContext) { accessToken, ok := web.Params(c.Req)[":accessToken"] diff --git a/pkg/services/publicdashboards/api/middleware_test.go b/pkg/services/publicdashboards/api/middleware_test.go index 0a663fdca0e..4916706829f 100644 --- a/pkg/services/publicdashboards/api/middleware_test.go +++ b/pkg/services/publicdashboards/api/middleware_test.go @@ -20,7 +20,7 @@ import ( var validAccessToken, _ = tokens.GenerateAccessToken() -func TestRequiresValidAccessToken(t *testing.T) { +func TestRequiresExistingAccessToken(t *testing.T) { tests := []struct { Name string Path string @@ -76,7 +76,7 @@ func TestRequiresValidAccessToken(t *testing.T) { publicdashboardService := &publicdashboards.FakePublicDashboardService{} publicdashboardService.On("AccessTokenExists", mock.Anything, mock.Anything).Return(tt.AccessTokenExists, tt.AccessTokenExistsErr) params := map[string]string{":accessToken": tt.AccessToken} - mw := RequiresValidAccessToken(publicdashboardService) + mw := RequiresExistingAccessToken(publicdashboardService) _, resp := runMw(t, nil, "GET", tt.Path, params, mw) require.Equal(t, tt.ExpectedResponseCode, resp.Code) }) diff --git a/pkg/services/publicdashboards/internal/tokens/tokens.go b/pkg/services/publicdashboards/internal/tokens/tokens.go index e8f1f91c3aa..48e241872fa 100644 --- a/pkg/services/publicdashboards/internal/tokens/tokens.go +++ b/pkg/services/publicdashboards/internal/tokens/tokens.go @@ -6,17 +6,16 @@ import ( "github.com/google/uuid" ) -// generates a uuid formatted without dashes to use as access token +// GenerateAccessToken generates an uuid formatted without dashes to use as access token func GenerateAccessToken() (string, error) { token, err := uuid.NewRandom() if err != nil { return "", err } - return fmt.Sprintf("%x", token[:]), nil } -// asserts that an accessToken is a valid uuid +// IsValidAccessToken asserts that an accessToken is a valid uuid func IsValidAccessToken(token string) bool { _, err := uuid.Parse(token) return err == nil diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index 24b603410ae..5eda3c65438 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -25,7 +25,7 @@ import ( "github.com/grafana/grafana/pkg/tsdb/legacydata" ) -// Define the Service Implementation. We're generating mock implementation +// PublicDashboardServiceImpl Define the Service Implementation. We're generating mock implementation // automatically type PublicDashboardServiceImpl struct { log log.Logger @@ -43,7 +43,7 @@ var LogPrefix = "publicdashboards.service" // the interface var _ publicdashboards.Service = (*PublicDashboardServiceImpl)(nil) -// Factory for method used by wire to inject dependencies. +// ProvideService Factory for method used by wire to inject dependencies. // builds the service, and api, and configures routes func ProvideService( cfg *setting.Cfg, @@ -63,7 +63,7 @@ func ProvideService( } } -// Gets a dashboard by Uid +// GetDashboard Gets a dashboard by Uid func (pd *PublicDashboardServiceImpl) GetDashboard(ctx context.Context, dashboardUid string) (*models.Dashboard, error) { dashboard, err := pd.store.GetDashboard(ctx, dashboardUid) @@ -74,7 +74,7 @@ func (pd *PublicDashboardServiceImpl) GetDashboard(ctx context.Context, dashboar return dashboard, err } -// Gets public dashboard via access token +// GetPublicDashboard Gets public dashboard via access token func (pd *PublicDashboardServiceImpl) GetPublicDashboard(ctx context.Context, accessToken string) (*PublicDashboard, *models.Dashboard, error) { pubdash, dash, err := pd.store.GetPublicDashboard(ctx, accessToken) ctxLogger := pd.log.FromContext(ctx)