From c4aaf5f9d120db1cda5f988d9d73c1f288b103df Mon Sep 17 00:00:00 2001 From: Sofia Papagiannaki <1632407+papagian@users.noreply.github.com> Date: Fri, 17 Dec 2021 17:31:52 +0200 Subject: [PATCH] API: prevent provisioned dashboard from being updated (#41894) --- pkg/api/dashboard.go | 16 +- pkg/dashboards/ifaces.go | 1 + pkg/models/dashboards.go | 5 + pkg/services/dashboards/dashboard_service.go | 5 + .../sqlstore/dashboard_provisioning.go | 24 ++ .../api/dashboards/api_dashboards_test.go | 134 ++++++++++ pkg/tests/api/dashboards/home.json | 231 ++++++++++++++++++ 7 files changed, 413 insertions(+), 3 deletions(-) create mode 100644 pkg/tests/api/dashboards/home.json diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index d6b84825faf..812c3ad0881 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -319,9 +319,19 @@ func (hs *HTTPServer) postDashboard(c *models.ReqContext, cmd models.SaveDashboa } svc := dashboards.NewProvisioningService(hs.SQLStore) - provisioningData, err := svc.GetProvisionedDashboardDataByDashboardID(dash.Id) - if err != nil { - return response.Error(500, "Error while checking if dashboard is provisioned", err) + var provisioningData *models.DashboardProvisioning + if dash.Id != 0 { + data, err := svc.GetProvisionedDashboardDataByDashboardID(dash.Id) + if err != nil { + return response.Error(500, "Error while checking if dashboard is provisioned", err) + } + provisioningData = data + } else if dash.Uid != "" { + data, err := svc.GetProvisionedDashboardDataByDashboardUID(dash.OrgId, dash.Uid) + if err != nil && !errors.Is(err, models.ErrProvisionedDashboardNotFound) { + return response.Error(500, "Error while checking if dashboard is provisioned", err) + } + provisioningData = data } allowUiUpdate := true diff --git a/pkg/dashboards/ifaces.go b/pkg/dashboards/ifaces.go index b264d97c1cf..93fe150e583 100644 --- a/pkg/dashboards/ifaces.go +++ b/pkg/dashboards/ifaces.go @@ -13,6 +13,7 @@ type Store interface { // GetFolderByTitle retrieves a dashboard by its title and is used by unified alerting GetFolderByTitle(orgID int64, title string) (*models.Dashboard, error) GetProvisionedDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) + GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) SaveDashboard(cmd models.SaveDashboardCommand) (*models.Dashboard, error) diff --git a/pkg/models/dashboards.go b/pkg/models/dashboards.go index 0e829ed3d3c..11d8473b6e8 100644 --- a/pkg/models/dashboards.go +++ b/pkg/models/dashboards.go @@ -103,6 +103,11 @@ var ( Reason: "Unique identifier needed to be able to get a dashboard", StatusCode: 400, } + ErrProvisionedDashboardNotFound = DashboardErr{ + Reason: "Dashboard is not provisioned", + StatusCode: 404, + Status: "not-found", + } ) // DashboardErr represents a dashboard error. diff --git a/pkg/services/dashboards/dashboard_service.go b/pkg/services/dashboards/dashboard_service.go index c8cb42f5f20..0bfdd8905b6 100644 --- a/pkg/services/dashboards/dashboard_service.go +++ b/pkg/services/dashboards/dashboard_service.go @@ -32,6 +32,7 @@ type DashboardProvisioningService interface { SaveProvisionedDashboard(ctx context.Context, dto *SaveDashboardDTO, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) SaveFolderForProvisionedDashboards(context.Context, *SaveDashboardDTO) (*models.Dashboard, error) GetProvisionedDashboardData(name string) ([]*models.DashboardProvisioning, error) + GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) GetProvisionedDashboardDataByDashboardID(dashboardID int64) (*models.DashboardProvisioning, error) UnprovisionDashboard(ctx context.Context, dashboardID int64) error DeleteProvisionedDashboard(ctx context.Context, dashboardID int64, orgID int64) error @@ -81,6 +82,10 @@ func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardID(dashboa return GetProvisionedData(dr.dashboardStore, dashboardID) } +func (dr *dashboardServiceImpl) GetProvisionedDashboardDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { + return dr.dashboardStore.GetProvisionedDataByDashboardUID(orgID, dashboardUID) +} + func (dr *dashboardServiceImpl) buildSaveDashboardCommand(ctx context.Context, dto *SaveDashboardDTO, shouldValidateAlerts bool, validateProvisionedDashboard bool) (*models.SaveDashboardCommand, error) { dash := dto.Dashboard diff --git a/pkg/services/sqlstore/dashboard_provisioning.go b/pkg/services/sqlstore/dashboard_provisioning.go index 2e627631bda..38cd09337a6 100644 --- a/pkg/services/sqlstore/dashboard_provisioning.go +++ b/pkg/services/sqlstore/dashboard_provisioning.go @@ -32,6 +32,30 @@ func (ss *SQLStore) GetProvisionedDataByDashboardID(dashboardID int64) (*models. return nil, nil } +func (ss *SQLStore) GetProvisionedDataByDashboardUID(orgID int64, dashboardUID string) (*models.DashboardProvisioning, error) { + var provisionedDashboard models.DashboardProvisioning + err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error { + var dashboard models.Dashboard + exists, err := sess.Where("org_id = ? AND uid = ?", orgID, dashboardUID).Get(&dashboard) + if err != nil { + return err + } + if !exists { + return models. + ErrDashboardNotFound + } + exists, err = sess.Where("dashboard_id = ?", dashboard.Id).Get(&provisionedDashboard) + if err != nil { + return err + } + if !exists { + return models.ErrProvisionedDashboardNotFound + } + return nil + }) + return &provisionedDashboard, err +} + func (ss *SQLStore) SaveProvisionedDashboard(cmd models.SaveDashboardCommand, provisioning *models.DashboardProvisioning) (*models.Dashboard, error) { err := ss.WithTransactionalDbSession(context.Background(), func(sess *DBSession) error { diff --git a/pkg/tests/api/dashboards/api_dashboards_test.go b/pkg/tests/api/dashboards/api_dashboards_test.go index 4025910ef61..18687c5598f 100644 --- a/pkg/tests/api/dashboards/api_dashboards_test.go +++ b/pkg/tests/api/dashboards/api_dashboards_test.go @@ -7,12 +7,16 @@ import ( "fmt" "io/ioutil" "net/http" + "net/url" + "os" + "path/filepath" "testing" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/plugins" + "github.com/grafana/grafana/pkg/services/search" "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/tests/testinfra" "github.com/stretchr/testify/assert" @@ -92,3 +96,133 @@ func createUser(t *testing.T, store *sqlstore.SQLStore, cmd models.CreateUserCom require.NoError(t, err) return u.Id } + +func TestProvisionioningDashboards(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableAnonymous: true, + }) + + provDashboardsDir := filepath.Join(dir, "conf", "provisioning", "dashboards") + provDashboardsCfg := filepath.Join(provDashboardsDir, "dev.yaml") + blob := []byte(fmt.Sprintf(` +apiVersion: 1 + +providers: +- name: 'provisioned dashboards' + type: file + allowUiUpdates: false + options: + path: %s`, provDashboardsDir)) + err := os.WriteFile(provDashboardsCfg, blob, 0644) + require.NoError(t, err) + input, err := ioutil.ReadFile(filepath.Join("./home.json")) + require.NoError(t, err) + provDashboardFile := filepath.Join(provDashboardsDir, "home.json") + err = ioutil.WriteFile(provDashboardFile, input, 0644) + require.NoError(t, err) + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // Create user + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_ADMIN), + Password: "admin", + Login: "admin", + }) + + type errorResponseBody struct { + Message string `json:"message"` + } + + t.Run("when provisioned directory is not empty, dashboard should be created", func(t *testing.T) { + title := "Grafana Dev Overview & Home" + u := fmt.Sprintf("http://admin:admin@%s/api/search?query=%s", grafanaListedAddr, url.QueryEscape(title)) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + dashboardList := &search.HitList{} + err = json.Unmarshal(b, dashboardList) + require.NoError(t, err) + assert.Equal(t, 1, dashboardList.Len()) + var dashboardUID string + var dashboardID int64 + for _, d := range *dashboardList { + dashboardUID = d.UID + dashboardID = d.ID + } + assert.Equal(t, int64(1), dashboardID) + + testCases := []struct { + desc string + dashboardData string + }{ + { + desc: "when updating provisioned dashboard using ID it should fail", + dashboardData: fmt.Sprintf(`{"title":"just testing", "id": %d, "version": 1}`, dashboardID), + }, + { + desc: "when updating provisioned dashboard using UID is should fail", + dashboardData: fmt.Sprintf(`{"title":"just testing", "uid": %q, "version": 1}`, dashboardUID), + }, + } + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + u := fmt.Sprintf("http://admin:admin@%s/api/dashboards/db", grafanaListedAddr) + // nolint:gosec + dashboardData, err := simplejson.NewJson([]byte(tc.dashboardData)) + require.NoError(t, err) + buf := &bytes.Buffer{} + err = json.NewEncoder(buf).Encode(models.SaveDashboardCommand{ + Dashboard: dashboardData, + }) + require.NoError(t, err) + + // nolint:gosec + resp, err := http.Post(u, "application/json", buf) + require.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + dashboardErr := &errorResponseBody{} + err = json.Unmarshal(b, dashboardErr) + require.NoError(t, err) + assert.Equal(t, models.ErrDashboardCannotSaveProvisionedDashboard.Reason, dashboardErr.Message) + }) + } + + t.Run("deleting provisioned dashboard should fail", func(t *testing.T) { + u := fmt.Sprintf("http://admin:admin@%s/api/dashboards/uid/%s", grafanaListedAddr, dashboardUID) + req, err := http.NewRequest("DELETE", u, nil) + if err != nil { + fmt.Println(err) + return + } + + client := &http.Client{} + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + dashboardErr := &errorResponseBody{} + err = json.Unmarshal(b, dashboardErr) + require.NoError(t, err) + assert.Equal(t, models.ErrDashboardCannotDeleteProvisionedDashboard.Reason, dashboardErr.Message) + }) + }) +} diff --git a/pkg/tests/api/dashboards/home.json b/pkg/tests/api/dashboards/home.json new file mode 100644 index 00000000000..3fd98704fff --- /dev/null +++ b/pkg/tests/api/dashboards/home.json @@ -0,0 +1,231 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": "-- Grafana --", + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "target": { + "limit": 100, + "matchAny": false, + "tags": [], + "type": "dashboard" + }, + "type": "dashboard" + } + ] + }, + "editable": true, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "gridPos": { + "h": 26, + "w": 6, + "x": 0, + "y": 0 + }, + "id": 7, + "links": [], + "options": { + "maxItems": 100, + "query": "", + "showHeadings": true, + "showRecentlyViewed": true, + "showSearch": false, + "showStarred": true, + "tags": [] + }, + "pluginVersion": "8.1.0-pre", + "tags": [], + "title": "Starred", + "type": "dashlist" + }, + { + "gridPos": { + "h": 13, + "w": 6, + "x": 6, + "y": 0 + }, + "id": 2, + "links": [], + "options": { + "maxItems": 1000, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "panel-tests" + ] + }, + "pluginVersion": "8.1.0-pre", + "tags": [ + "panel-tests" + ], + "title": "tag: panel-tests", + "type": "dashlist" + }, + { + "gridPos": { + "h": 13, + "w": 6, + "x": 12, + "y": 0 + }, + "id": 3, + "links": [], + "options": { + "maxItems": 1000, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "gdev", + "demo" + ] + }, + "pluginVersion": "8.1.0-pre", + "tags": [ + "gdev", + "demo" + ], + "title": "tag: dashboard-demo", + "type": "dashlist" + }, + { + "gridPos": { + "h": 26, + "w": 6, + "x": 18, + "y": 0 + }, + "id": 5, + "links": [], + "options": { + "maxItems": 1000, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "gdev", + "datasource-test" + ] + }, + "pluginVersion": "8.1.0-pre", + "tags": [ + "gdev", + "datasource-test" + ], + "title": "Data source tests", + "type": "dashlist" + }, + { + "gridPos": { + "h": 13, + "w": 6, + "x": 6, + "y": 13 + }, + "id": 4, + "links": [], + "options": { + "maxItems": 1000, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "templating", + "gdev" + ] + }, + "pluginVersion": "8.1.0-pre", + "tags": [ + "templating", + "gdev" + ], + "title": "tag: templating ", + "type": "dashlist" + }, + { + "gridPos": { + "h": 13, + "w": 6, + "x": 12, + "y": 13 + }, + "id": 8, + "links": [], + "options": { + "maxItems": 1000, + "query": "", + "showHeadings": false, + "showRecentlyViewed": false, + "showSearch": true, + "showStarred": false, + "tags": [ + "gdev", + "transform" + ] + }, + "pluginVersion": "8.1.0-pre", + "tags": [ + "gdev", + "demo" + ], + "title": "tag: transforms", + "type": "dashlist" + } + ], + "schemaVersion": 30, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "timezone": "", + "title": "Grafana Dev Overview & Home", + "uid": "j6T00KRZz", + "version": 2 +}