From dc23643bee41663ddea876339bfc375d8018993c Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Wed, 10 Aug 2022 11:14:48 -0600 Subject: [PATCH] Public Dashboards: Usage Insights (#52768) --- .../grafana-runtime/src/types/analytics.ts | 1 + pkg/api/dtos/dashboard.go | 1 + pkg/services/publicdashboards/api/api.go | 6 ++ pkg/services/publicdashboards/api/api_test.go | 4 + .../publicdashboards/api/middleware.go | 29 +++++++ .../publicdashboards/api/middleware_test.go | 75 +++++++++++++++++++ .../publicdashboards/database/database.go | 18 +++++ .../database/database_test.go | 32 ++++++++ .../public_dashboard_service_mock.go | 21 ++++++ .../public_dashboard_store_mock.go | 21 ++++++ .../publicdashboards/publicdashboard.go | 2 + .../publicdashboards/service/service.go | 4 + .../dashboard/state/analyticsProcessor.ts | 1 + .../features/query/state/queryAnalytics.ts | 1 + public/app/types/dashboard.ts | 1 + 15 files changed, 217 insertions(+) create mode 100644 pkg/services/publicdashboards/api/middleware_test.go diff --git a/packages/grafana-runtime/src/types/analytics.ts b/packages/grafana-runtime/src/types/analytics.ts index 6ec90387714..c3e7f585303 100644 --- a/packages/grafana-runtime/src/types/analytics.ts +++ b/packages/grafana-runtime/src/types/analytics.ts @@ -11,6 +11,7 @@ export interface DashboardInfo { dashboardUid: string; dashboardName: string; folderName?: string; + publicDashboardUid?: string; } /** diff --git a/pkg/api/dtos/dashboard.go b/pkg/api/dtos/dashboard.go index 3d3499c2109..aaf43fc59cd 100644 --- a/pkg/api/dtos/dashboard.go +++ b/pkg/api/dtos/dashboard.go @@ -34,6 +34,7 @@ type DashboardMeta struct { ProvisionedExternalId string `json:"provisionedExternalId"` AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"` PublicDashboardAccessToken string `json:"publicDashboardAccessToken"` + PublicDashboardUID string `json:"publicDashboardUid"` PublicDashboardEnabled bool `json:"publicDashboardEnabled"` } type AnnotationPermission struct { diff --git a/pkg/services/publicdashboards/api/api.go b/pkg/services/publicdashboards/api/api.go index bec421898ea..d5da2ef7bee 100644 --- a/pkg/services/publicdashboards/api/api.go +++ b/pkg/services/publicdashboards/api/api.go @@ -79,6 +79,11 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard", err) } + pubDash, err := api.PublicDashboardService.GetPublicDashboardConfig(c.Req.Context(), dash.OrgId, dash.Uid) + if err != nil { + return handleDashboardErr(http.StatusInternalServerError, "Failed to get public dashboard config", err) + } + meta := dtos.DashboardMeta{ Slug: dash.Slug, Type: models.DashTypeDB, @@ -93,6 +98,7 @@ func (api *Api) GetPublicDashboard(c *models.ReqContext) response.Response { IsFolder: false, FolderId: dash.FolderId, PublicDashboardAccessToken: accessToken, + PublicDashboardUID: pubDash.Uid, } dto := dtos.DashboardFullWithMeta{Meta: meta, Dashboard: dash.Data} diff --git a/pkg/services/publicdashboards/api/api_test.go b/pkg/services/publicdashboards/api/api_test.go index e51c8bbbb51..2df7e1701f6 100644 --- a/pkg/services/publicdashboards/api/api_test.go +++ b/pkg/services/publicdashboards/api/api_test.go @@ -43,6 +43,8 @@ func TestAPIGetPublicDashboard(t *testing.T) { service := publicdashboards.NewFakePublicDashboardService(t) service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). Return(&models.Dashboard{}, nil).Maybe() + service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). + Return(&PublicDashboard{}, nil).Maybe() testServer := setupTestServer(t, cfg, qs, featuremgmt.WithFeatures(), service, nil) @@ -95,6 +97,8 @@ func TestAPIGetPublicDashboard(t *testing.T) { service := publicdashboards.NewFakePublicDashboardService(t) service.On("GetPublicDashboard", mock.Anything, mock.AnythingOfType("string")). Return(test.PublicDashboardResult, test.PublicDashboardErr).Maybe() + service.On("GetPublicDashboardConfig", mock.Anything, mock.AnythingOfType("int64"), mock.AnythingOfType("string")). + Return(&PublicDashboard{}, nil).Maybe() testServer := setupTestServer( t, diff --git a/pkg/services/publicdashboards/api/middleware.go b/pkg/services/publicdashboards/api/middleware.go index e31a461d2b3..f1792198b11 100644 --- a/pkg/services/publicdashboards/api/middleware.go +++ b/pkg/services/publicdashboards/api/middleware.go @@ -1,8 +1,12 @@ package api import ( + "net/http" + "github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/publicdashboards" + "github.com/grafana/grafana/pkg/web" ) func SetPublicDashboardFlag() func(c *models.ReqContext) { @@ -11,6 +15,31 @@ func SetPublicDashboardFlag() func(c *models.ReqContext) { } } +func RequiresValidAccessToken(publicDashboardService publicdashboards.Service) func(c *models.ReqContext) { + return func(c *models.ReqContext) { + accessToken, ok := web.Params(c.Req)[":accessToken"] + + // Check access token is present on the request + if !ok || accessToken == "" { + c.JsonApiErr(http.StatusBadRequest, "Invalid access token", nil) + return + } + + // Check that the access token references an enabled public dashboard + exists, err := publicDashboardService.AccessTokenExists(c.Req.Context(), accessToken) + + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Error validating access token", nil) + return + } + + if !exists { + c.JsonApiErr(http.StatusBadRequest, "Invalid access token", nil) + return + } + } +} + func CountPublicDashboardRequest() func(c *models.ReqContext) { return func(c *models.ReqContext) { metrics.MPublicDashboardRequestCount.Inc() diff --git a/pkg/services/publicdashboards/api/middleware_test.go b/pkg/services/publicdashboards/api/middleware_test.go new file mode 100644 index 00000000000..927c85420e5 --- /dev/null +++ b/pkg/services/publicdashboards/api/middleware_test.go @@ -0,0 +1,75 @@ +package api + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/services/contexthandler/ctxkey" + "github.com/grafana/grafana/pkg/services/publicdashboards" + publicdashboardsService "github.com/grafana/grafana/pkg/services/publicdashboards/service" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestRequiresValidAccessToken(t *testing.T) { + t.Run("Returns 404 when access token is empty", func(t *testing.T) { + request, err := http.NewRequest("GET", "/api/public/ma/events/", nil) + require.NoError(t, err) + + resp := runMiddleware(request, mockAccessTokenExistsResponse(false, nil)) + + require.Equal(t, http.StatusNotFound, resp.Code) + }) + + t.Run("Returns 200 when public dashboard with access token exists", func(t *testing.T) { + request, err := http.NewRequest("GET", "/api/public/ma/events/myAccessToken", nil) + require.NoError(t, err) + + resp := runMiddleware(request, mockAccessTokenExistsResponse(true, nil)) + + require.Equal(t, http.StatusOK, resp.Code) + }) + + t.Run("Returns 400 when public dashboard with access token does not exist", func(t *testing.T) { + request, err := http.NewRequest("GET", "/api/public/ma/events/myAccessToken", nil) + require.NoError(t, err) + + resp := runMiddleware(request, mockAccessTokenExistsResponse(false, nil)) + + require.Equal(t, http.StatusBadRequest, resp.Code) + }) + + t.Run("Returns 500 when public dashboard service gives an error", func(t *testing.T) { + request, err := http.NewRequest("GET", "/api/public/ma/events/myAccessToken", nil) + require.NoError(t, err) + + resp := runMiddleware(request, mockAccessTokenExistsResponse(false, fmt.Errorf("error not found"))) + + require.Equal(t, http.StatusInternalServerError, resp.Code) + }) +} + +func mockAccessTokenExistsResponse(returnArguments ...interface{}) *publicdashboardsService.PublicDashboardServiceImpl { + fakeStore := &publicdashboards.FakePublicDashboardStore{} + fakeStore.On("AccessTokenExists", mock.Anything, mock.Anything).Return(returnArguments[0], returnArguments[1]) + return publicdashboardsService.ProvideService(setting.NewCfg(), fakeStore) +} + +func runMiddleware(request *http.Request, pubdashService *publicdashboardsService.PublicDashboardServiceImpl) *httptest.ResponseRecorder { + recorder := httptest.NewRecorder() + m := web.New() + initCtx := &models.ReqContext{} + m.Use(func(c *web.Context) { + initCtx.Context = c + c.Req = c.Req.WithContext(ctxkey.Set(c.Req.Context(), initCtx)) + }) + m.Get("/api/public/ma/events/:accessToken", RequiresValidAccessToken(pubdashService)) + m.ServeHTTP(recorder, request) + + return recorder +} diff --git a/pkg/services/publicdashboards/database/database.go b/pkg/services/publicdashboards/database/database.go index 270b033cb09..57b511fa683 100644 --- a/pkg/services/publicdashboards/database/database.go +++ b/pkg/services/publicdashboards/database/database.go @@ -197,3 +197,21 @@ func (d *PublicDashboardStoreImpl) PublicDashboardEnabled(ctx context.Context, d return hasPublicDashboard, err } + +func (d *PublicDashboardStoreImpl) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) { + hasPublicDashboard := false + err := d.sqlStore.WithDbSession(ctx, func(dbSession *sqlstore.DBSession) error { + sql := "SELECT COUNT(*) FROM dashboard_public WHERE access_token=? AND is_enabled=true" + + result, err := dbSession.SQL(sql, accessToken).Count() + if err != nil { + return err + } + + hasPublicDashboard = result > 0 + + return err + }) + + return hasPublicDashboard, err +} diff --git a/pkg/services/publicdashboards/database/database_test.go b/pkg/services/publicdashboards/database/database_test.go index 6aa9f032fb4..727df3d30c5 100644 --- a/pkg/services/publicdashboards/database/database_test.go +++ b/pkg/services/publicdashboards/database/database_test.go @@ -64,6 +64,38 @@ func TestIntegrationGetPublicDashboard(t *testing.T) { publicdashboardStore = ProvideStore(sqlStore) savedDashboard = insertTestDashboard(t, dashboardStore, "testDashie", 1, 0, true) } + t.Run("AccessTokenExists will return true when at least one public dashboard has a matching access token", func(t *testing.T) { + setup() + + _, err := publicdashboardStore.SavePublicDashboardConfig(context.Background(), SavePublicDashboardConfigCommand{ + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + PublicDashboard: PublicDashboard{ + IsEnabled: true, + Uid: "abc123", + DashboardUid: savedDashboard.Uid, + OrgId: savedDashboard.OrgId, + CreatedAt: time.Now(), + CreatedBy: 7, + AccessToken: "accessToken", + }, + }) + require.NoError(t, err) + + res, err := publicdashboardStore.AccessTokenExists(context.Background(), "accessToken") + require.NoError(t, err) + + require.True(t, res) + }) + + t.Run("AccessTokenExists will return false when no public dashboard has matching access token", func(t *testing.T) { + setup() + + res, err := publicdashboardStore.AccessTokenExists(context.Background(), "accessToken") + + require.NoError(t, err) + require.False(t, res) + }) t.Run("PublicDashboardEnabled Will return true when dashboard has at least one enabled public dashboard", func(t *testing.T) { setup() diff --git a/pkg/services/publicdashboards/public_dashboard_service_mock.go b/pkg/services/publicdashboards/public_dashboard_service_mock.go index 4f1362deb6b..652b5c74962 100644 --- a/pkg/services/publicdashboards/public_dashboard_service_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_service_mock.go @@ -21,6 +21,27 @@ type FakePublicDashboardService struct { mock.Mock } +// AccessTokenExists provides a mock function with given fields: ctx, accessToken +func (_m *FakePublicDashboardService) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) { + ret := _m.Called(ctx, accessToken) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = rf(ctx, accessToken) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, accessToken) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // BuildAnonymousUser provides a mock function with given fields: ctx, dashboard func (_m *FakePublicDashboardService) BuildAnonymousUser(ctx context.Context, dashboard *models.Dashboard) (*user.SignedInUser, error) { ret := _m.Called(ctx, dashboard) diff --git a/pkg/services/publicdashboards/public_dashboard_store_mock.go b/pkg/services/publicdashboards/public_dashboard_store_mock.go index e36231051ef..e293a27cdfb 100644 --- a/pkg/services/publicdashboards/public_dashboard_store_mock.go +++ b/pkg/services/publicdashboards/public_dashboard_store_mock.go @@ -18,6 +18,27 @@ type FakePublicDashboardStore struct { mock.Mock } +// AccessTokenExists provides a mock function with given fields: ctx, accessToken +func (_m *FakePublicDashboardStore) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) { + ret := _m.Called(ctx, accessToken) + + var r0 bool + if rf, ok := ret.Get(0).(func(context.Context, string) bool); ok { + r0 = rf(ctx, accessToken) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, accessToken) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GenerateNewPublicDashboardUid provides a mock function with given fields: ctx func (_m *FakePublicDashboardStore) GenerateNewPublicDashboardUid(ctx context.Context) (string, error) { ret := _m.Called(ctx) diff --git a/pkg/services/publicdashboards/publicdashboard.go b/pkg/services/publicdashboards/publicdashboard.go index bae58c3a7f1..68d0c3871a6 100644 --- a/pkg/services/publicdashboards/publicdashboard.go +++ b/pkg/services/publicdashboards/publicdashboard.go @@ -20,6 +20,7 @@ type Service interface { SavePublicDashboardConfig(ctx context.Context, dto *SavePublicDashboardConfigDTO) (*PublicDashboard, error) BuildPublicDashboardMetricRequest(ctx context.Context, dashboard *models.Dashboard, publicDashboard *PublicDashboard, panelId int64) (dtos.MetricRequest, error) PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) + AccessTokenExists(ctx context.Context, accessToken string) (bool, error) } //go:generate mockery --name Store --structname FakePublicDashboardStore --inpackage --filename public_dashboard_store_mock.go @@ -31,4 +32,5 @@ type Store interface { SavePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) (*PublicDashboard, error) UpdatePublicDashboardConfig(ctx context.Context, cmd SavePublicDashboardConfigCommand) error PublicDashboardEnabled(ctx context.Context, dashboardUid string) (bool, error) + AccessTokenExists(ctx context.Context, accessToken string) (bool, error) } diff --git a/pkg/services/publicdashboards/service/service.go b/pkg/services/publicdashboards/service/service.go index c40d83549f9..c95489439be 100644 --- a/pkg/services/publicdashboards/service/service.go +++ b/pkg/services/publicdashboards/service/service.go @@ -211,6 +211,10 @@ func (pd *PublicDashboardServiceImpl) PublicDashboardEnabled(ctx context.Context return pd.store.PublicDashboardEnabled(ctx, dashboardUid) } +func (pd *PublicDashboardServiceImpl) AccessTokenExists(ctx context.Context, accessToken string) (bool, error) { + return pd.store.AccessTokenExists(ctx, accessToken) +} + // generates a uuid formatted without dashes to use as access token func GenerateAccessToken() (string, error) { token, err := uuid.NewRandom() diff --git a/public/app/features/dashboard/state/analyticsProcessor.ts b/public/app/features/dashboard/state/analyticsProcessor.ts index 258cf71ee45..eea9ed0a214 100644 --- a/public/app/features/dashboard/state/analyticsProcessor.ts +++ b/public/app/features/dashboard/state/analyticsProcessor.ts @@ -9,6 +9,7 @@ export function emitDashboardViewEvent(dashboard: DashboardModel) { dashboardUid: dashboard.uid, folderName: dashboard.meta.folderTitle, eventName: MetaAnalyticsEventName.DashboardView, + publicDashboardUid: dashboard.meta.publicDashboardUid, }; reportMetaAnalytics(eventData); diff --git a/public/app/features/query/state/queryAnalytics.ts b/public/app/features/query/state/queryAnalytics.ts index b3e3dccd160..c5cced926f6 100644 --- a/public/app/features/query/state/queryAnalytics.ts +++ b/public/app/features/query/state/queryAnalytics.ts @@ -50,6 +50,7 @@ export function emitDataRequestEvent(datasource: DataSourceApi) { eventData.dashboardName = dashboard.title; eventData.dashboardUid = dashboard.uid; eventData.folderName = dashboard.meta.folderTitle; + eventData.publicDashboardUid = dashboard.meta.publicDashboardUid; } if (data.series && data.series.length > 0) { diff --git a/public/app/types/dashboard.ts b/public/app/types/dashboard.ts index fd8924d5303..a910773a802 100644 --- a/public/app/types/dashboard.ts +++ b/public/app/types/dashboard.ts @@ -42,6 +42,7 @@ export interface DashboardMeta { hasUnsavedFolderChange?: boolean; annotationsPermissions?: AnnotationsPermissions; publicDashboardAccessToken?: string; + publicDashboardUid?: string; publicDashboardEnabled?: boolean; dashboardNotFound?: boolean; }