diff --git a/pkg/api/api.go b/pkg/api/api.go index 1cfce02cb92..24957d75201 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -438,7 +438,7 @@ func (hs *HTTPServer) registerRoutes() { // Snapshots r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, bind(models.CreateDashboardSnapshotCommand{}), CreateDashboardSnapshot) r.Get("/api/snapshot/shared-options/", reqSignedIn, GetSharingOptions) - r.Get("/api/snapshots/:key", GetDashboardSnapshot) + r.Get("/api/snapshots/:key", Wrap(GetDashboardSnapshot)) r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, Wrap(DeleteDashboardSnapshotByDeleteKey)) r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot)) diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index eeda6ec3b1d..d657b98809a 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -86,7 +86,7 @@ func CreateDashboardSnapshot(c *models.ReqContext, cmd models.CreateDashboardSna response, err := createExternalDashboardSnapshot(cmd) if err != nil { - c.JsonApiErr(500, "Failed to create external snaphost", err) + c.JsonApiErr(500, "Failed to create external snapshot", err) return } @@ -123,7 +123,7 @@ func CreateDashboardSnapshot(c *models.ReqContext, cmd models.CreateDashboardSna } if err := bus.Dispatch(&cmd); err != nil { - c.JsonApiErr(500, "Failed to create snaphost", err) + c.JsonApiErr(500, "Failed to create snapshot", err) return } @@ -136,26 +136,29 @@ func CreateDashboardSnapshot(c *models.ReqContext, cmd models.CreateDashboardSna } // GET /api/snapshots/:key -func GetDashboardSnapshot(c *models.ReqContext) { +func GetDashboardSnapshot(c *models.ReqContext) Response { key := c.Params(":key") query := &models.GetDashboardSnapshotQuery{Key: key} err := bus.Dispatch(query) if err != nil { - c.JsonApiErr(500, "Failed to get dashboard snapshot", err) - return + return Error(500, "Failed to get dashboard snapshot", err) } snapshot := query.Result // expired snapshots should also be removed from db if snapshot.Expires.Before(time.Now()) { - c.JsonApiErr(404, "Dashboard snapshot not found", err) - return + return Error(404, "Dashboard snapshot not found", err) + } + + dashboard, err := snapshot.DashboardJSON() + if err != nil { + return Error(500, "Failed to get dashboard data for dashboard snapshot", err) } dto := dtos.DashboardFullWithMeta{ - Dashboard: snapshot.Dashboard, + Dashboard: dashboard, Meta: dtos.DashboardMeta{ Type: models.DashTypeSnapshot, IsSnapshot: true, @@ -166,8 +169,7 @@ func GetDashboardSnapshot(c *models.ReqContext) { metrics.MApiDashboardSnapshotGet.Inc() - c.Resp.Header().Set("Cache-Control", "public, max-age=3600") - c.JSON(200, dto) + return JSON(200, dto).Header("Cache-Control", "public, max-age=3600") } func deleteExternalDashboardSnapshot(externalUrl string) error { @@ -238,7 +240,10 @@ func DeleteDashboardSnapshot(c *models.ReqContext) Response { if query.Result == nil { return Error(404, "Failed to get dashboard snapshot", nil) } - dashboard := query.Result.Dashboard + dashboard, err := query.Result.DashboardJSON() + if err != nil { + return Error(500, "Failed to get dashboard data for dashboard snapshot", err) + } dashboardID := dashboard.Get("id").MustInt64() guardian := guardian.New(dashboardID, c.OrgId, c.SignedInUser) diff --git a/pkg/api/dashboard_snapshot_test.go b/pkg/api/dashboard_snapshot_test.go index f3cd6bf3f58..f1b733721b3 100644 --- a/pkg/api/dashboard_snapshot_test.go +++ b/pkg/api/dashboard_snapshot_test.go @@ -7,9 +7,12 @@ import ( "testing" "time" + "github.com/grafana/grafana/pkg/components/securedata" + "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" + "github.com/grafana/grafana/pkg/setting" . "github.com/smartystreets/goconvey/convey" ) @@ -198,6 +201,62 @@ func TestDashboardSnapshotApiEndpoint(t *testing.T) { So(sc.resp.Code, ShouldEqual, 500) }) }) + + Convey("Should be able to read a snapshot's un-encrypted data", func() { + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = GetDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + + dashboard := respJSON.Get("dashboard") + id := dashboard.Get("id") + + So(id.MustInt64(), ShouldEqual, 100) + }) + }) + + Convey("Should be able to read a snapshot's encrypted data", func() { + origSecret := setting.SecretKey + setting.SecretKey = "dashboard_snapshot_api_test" + t.Cleanup(func() { + setting.SecretKey = origSecret + }) + + dashboardId := 123 + jsonModel, err := simplejson.NewJson([]byte(fmt.Sprintf(`{"id":%d}`, dashboardId))) + So(err, ShouldBeNil) + + jsonModelEncoded, err := jsonModel.Encode() + So(err, ShouldBeNil) + + encrypted, err := securedata.Encrypt(jsonModelEncoded) + So(err, ShouldBeNil) + + // mock snapshot with encrypted dashboard info + mockSnapshotResult := &models.DashboardSnapshot{ + Key: "12345", + DashboardEncrypted: encrypted, + Expires: time.Now().Add(time.Duration(1000) * time.Second), + } + + bus.AddHandler("test", func(query *models.GetDashboardSnapshotQuery) error { + query.Result = mockSnapshotResult + return nil + }) + + loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/snapshots/12345", "/api/snapshots/:key", models.ROLE_EDITOR, func(sc *scenarioContext) { + sc.handlerFunc = GetDashboardSnapshot + sc.fakeReqWithParams("GET", sc.url, map[string]string{"key": "12345"}).exec() + + So(sc.resp.Code, ShouldEqual, 200) + respJSON, err := simplejson.NewJson(sc.resp.Body.Bytes()) + So(err, ShouldBeNil) + So(respJSON.Get("dashboard").Get("id").MustInt64(), ShouldEqual, dashboardId) + }) + }) }) }) } diff --git a/pkg/components/securedata/securedata.go b/pkg/components/securedata/securedata.go new file mode 100644 index 00000000000..f2d6ba59958 --- /dev/null +++ b/pkg/components/securedata/securedata.go @@ -0,0 +1,16 @@ +package securedata + +import ( + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" +) + +type SecureData []byte + +func Encrypt(data []byte) (SecureData, error) { + return util.Encrypt(data, setting.SecretKey) +} + +func (s SecureData) Decrypt() ([]byte, error) { + return util.Decrypt(s, setting.SecretKey) +} diff --git a/pkg/models/dashboard_snapshot.go b/pkg/models/dashboard_snapshot.go index e8db0372758..babd5b26b13 100644 --- a/pkg/models/dashboard_snapshot.go +++ b/pkg/models/dashboard_snapshot.go @@ -3,6 +3,7 @@ package models import ( "time" + "github.com/grafana/grafana/pkg/components/securedata" "github.com/grafana/grafana/pkg/components/simplejson" ) @@ -22,7 +23,19 @@ type DashboardSnapshot struct { Created time.Time Updated time.Time - Dashboard *simplejson.Json + Dashboard *simplejson.Json + DashboardEncrypted securedata.SecureData +} + +func (ds *DashboardSnapshot) DashboardJSON() (*simplejson.Json, error) { + if ds.DashboardEncrypted != nil { + decrypted, err := ds.DashboardEncrypted.Decrypt() + if err != nil { + return nil, err + } + return simplejson.NewJson(decrypted) + } + return ds.Dashboard, nil } // DashboardSnapshotDTO without dashboard map diff --git a/pkg/services/sqlstore/dashboard_snapshot.go b/pkg/services/sqlstore/dashboard_snapshot.go index cc057f9397b..d12c43d9e94 100644 --- a/pkg/services/sqlstore/dashboard_snapshot.go +++ b/pkg/services/sqlstore/dashboard_snapshot.go @@ -4,6 +4,8 @@ import ( "time" "github.com/grafana/grafana/pkg/bus" + "github.com/grafana/grafana/pkg/components/securedata" + "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" ) @@ -45,22 +47,32 @@ func CreateDashboardSnapshot(cmd *models.CreateDashboardSnapshotCommand) error { expires = time.Now().Add(time.Second * time.Duration(cmd.Expires)) } - snapshot := &models.DashboardSnapshot{ - Name: cmd.Name, - Key: cmd.Key, - DeleteKey: cmd.DeleteKey, - OrgId: cmd.OrgId, - UserId: cmd.UserId, - External: cmd.External, - ExternalUrl: cmd.ExternalUrl, - ExternalDeleteUrl: cmd.ExternalDeleteUrl, - Dashboard: cmd.Dashboard, - Expires: expires, - Created: time.Now(), - Updated: time.Now(), + marshalledData, err := cmd.Dashboard.Encode() + if err != nil { + return err } - _, err := sess.Insert(snapshot) + encryptedDashboard, err := securedata.Encrypt(marshalledData) + if err != nil { + return err + } + + snapshot := &models.DashboardSnapshot{ + Name: cmd.Name, + Key: cmd.Key, + DeleteKey: cmd.DeleteKey, + OrgId: cmd.OrgId, + UserId: cmd.UserId, + External: cmd.External, + ExternalUrl: cmd.ExternalUrl, + ExternalDeleteUrl: cmd.ExternalDeleteUrl, + Dashboard: simplejson.New(), + DashboardEncrypted: encryptedDashboard, + Expires: expires, + Created: time.Now(), + Updated: time.Now(), + } + _, err = sess.Insert(snapshot) cmd.Result = snapshot return err diff --git a/pkg/services/sqlstore/dashboard_snapshot_test.go b/pkg/services/sqlstore/dashboard_snapshot_test.go index f393f6fbdd5..5012fb0beb4 100644 --- a/pkg/services/sqlstore/dashboard_snapshot_test.go +++ b/pkg/services/sqlstore/dashboard_snapshot_test.go @@ -4,7 +4,8 @@ import ( "testing" "time" - . "github.com/smartystreets/goconvey/convey" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/models" @@ -12,140 +13,157 @@ import ( ) func TestDashboardSnapshotDBAccess(t *testing.T) { - Convey("Testing DashboardSnapshot data access", t, func() { - InitTestDB(t) + InitTestDB(t) - Convey("Given saved snapshot", func() { + origSecret := setting.SecretKey + setting.SecretKey = "dashboard_snapshot_testing" + t.Cleanup(func() { + setting.SecretKey = origSecret + }) + + t.Run("Given saved snapshot", func(t *testing.T) { + cmd := models.CreateDashboardSnapshotCommand{ + Key: "hej", + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "hello": "mupp", + }), + UserId: 1000, + OrgId: 1, + } + err := CreateDashboardSnapshot(&cmd) + require.NoError(t, err) + + t.Run("Should be able to get snapshot by key", func(t *testing.T) { + query := models.GetDashboardSnapshotQuery{Key: "hej"} + err := GetDashboardSnapshot(&query) + require.NoError(t, err) + + assert.NotNil(t, query.Result) + + dashboard, err := query.Result.DashboardJSON() + require.NoError(t, err) + assert.Equal(t, "mupp", dashboard.Get("hello").MustString()) + }) + + t.Run("And the user has the admin role", func(t *testing.T) { + query := models.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_ADMIN}, + } + err := SearchDashboardSnapshots(&query) + require.NoError(t, err) + + t.Run("Should return all the snapshots", func(t *testing.T) { + assert.NotNil(t, query.Result) + assert.Len(t, query.Result, 1) + }) + }) + + t.Run("And the user has the editor role and has created a snapshot", func(t *testing.T) { + query := models.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR, UserId: 1000}, + } + err := SearchDashboardSnapshots(&query) + require.NoError(t, err) + + t.Run("Should return all the snapshots", func(t *testing.T) { + require.NotNil(t, query.Result) + assert.Len(t, query.Result, 1) + }) + }) + + t.Run("And the user has the editor role and has not created any snapshot", func(t *testing.T) { + query := models.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR, UserId: 2}, + } + err := SearchDashboardSnapshots(&query) + require.NoError(t, err) + + t.Run("Should not return any snapshots", func(t *testing.T) { + require.NotNil(t, query.Result) + assert.Empty(t, query.Result) + }) + }) + + t.Run("And the user is anonymous", func(t *testing.T) { cmd := models.CreateDashboardSnapshotCommand{ - Key: "hej", + Key: "strangesnapshotwithuserid0", + DeleteKey: "adeletekey", Dashboard: simplejson.NewFromAny(map[string]interface{}{ "hello": "mupp", }), - UserId: 1000, + UserId: 0, OrgId: 1, } err := CreateDashboardSnapshot(&cmd) - So(err, ShouldBeNil) + require.NoError(t, err) - Convey("Should be able to get snapshot by key", func() { - query := models.GetDashboardSnapshotQuery{Key: "hej"} - err = GetDashboardSnapshot(&query) - So(err, ShouldBeNil) - - So(query.Result, ShouldNotBeNil) - So(query.Result.Dashboard.Get("hello").MustString(), ShouldEqual, "mupp") - }) - - Convey("And the user has the admin role", func() { - Convey("Should return all the snapshots", func() { - query := models.GetDashboardSnapshotsQuery{ - OrgId: 1, - SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_ADMIN}, - } - err := SearchDashboardSnapshots(&query) - So(err, ShouldBeNil) - - So(query.Result, ShouldNotBeNil) - So(len(query.Result), ShouldEqual, 1) - }) - }) - - Convey("And the user has the editor role and has created a snapshot", func() { - Convey("Should return all the snapshots", func() { - query := models.GetDashboardSnapshotsQuery{ - OrgId: 1, - SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR, UserId: 1000}, - } - err := SearchDashboardSnapshots(&query) - So(err, ShouldBeNil) - - So(query.Result, ShouldNotBeNil) - So(len(query.Result), ShouldEqual, 1) - }) - }) - - Convey("And the user has the editor role and has not created any snapshot", func() { - Convey("Should not return any snapshots", func() { - query := models.GetDashboardSnapshotsQuery{ - OrgId: 1, - SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR, UserId: 2}, - } - err := SearchDashboardSnapshots(&query) - So(err, ShouldBeNil) - - So(query.Result, ShouldNotBeNil) - So(len(query.Result), ShouldEqual, 0) - }) - }) - - Convey("And the user is anonymous", func() { - cmd := models.CreateDashboardSnapshotCommand{ - Key: "strangesnapshotwithuserid0", - DeleteKey: "adeletekey", - Dashboard: simplejson.NewFromAny(map[string]interface{}{ - "hello": "mupp", - }), - UserId: 0, - OrgId: 1, + t.Run("Should not return any snapshots", func(t *testing.T) { + query := models.GetDashboardSnapshotsQuery{ + OrgId: 1, + SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR, IsAnonymous: true, UserId: 0}, } - err := CreateDashboardSnapshot(&cmd) - So(err, ShouldBeNil) + err := SearchDashboardSnapshots(&query) + require.NoError(t, err) - Convey("Should not return any snapshots", func() { - query := models.GetDashboardSnapshotsQuery{ - OrgId: 1, - SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_EDITOR, IsAnonymous: true, UserId: 0}, - } - err := SearchDashboardSnapshots(&query) - So(err, ShouldBeNil) - - So(query.Result, ShouldNotBeNil) - So(len(query.Result), ShouldEqual, 0) - }) + require.NotNil(t, query.Result) + assert.Empty(t, query.Result) }) }) + + t.Run("Should have encrypted dashboard data", func(t *testing.T) { + original, err := cmd.Dashboard.Encode() + require.NoError(t, err) + + decrypted, err := cmd.Result.DashboardEncrypted.Decrypt() + require.NoError(t, err) + + require.Equal(t, decrypted, original) + }) }) } func TestDeleteExpiredSnapshots(t *testing.T) { sqlstore := InitTestDB(t) - Convey("Testing dashboard snapshots clean up", t, func() { + t.Run("Testing dashboard snapshots clean up", func(t *testing.T) { setting.SnapShotRemoveExpired = true - notExpiredsnapshot := createTestSnapshot(sqlstore, "key1", 48000) - createTestSnapshot(sqlstore, "key2", -1200) - createTestSnapshot(sqlstore, "key3", -1200) + nonExpiredSnapshot := createTestSnapshot(t, sqlstore, "key1", 48000) + createTestSnapshot(t, sqlstore, "key2", -1200) + createTestSnapshot(t, sqlstore, "key3", -1200) err := DeleteExpiredSnapshots(&models.DeleteExpiredSnapshotsCommand{}) - So(err, ShouldBeNil) + require.NoError(t, err) query := models.GetDashboardSnapshotsQuery{ OrgId: 1, SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_ADMIN}, } err = SearchDashboardSnapshots(&query) - So(err, ShouldBeNil) + require.NoError(t, err) - So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key) + assert.Len(t, query.Result, 1) + assert.Equal(t, nonExpiredSnapshot.Key, query.Result[0].Key) err = DeleteExpiredSnapshots(&models.DeleteExpiredSnapshotsCommand{}) - So(err, ShouldBeNil) + require.NoError(t, err) query = models.GetDashboardSnapshotsQuery{ OrgId: 1, SignedInUser: &models.SignedInUser{OrgRole: models.ROLE_ADMIN}, } err = SearchDashboardSnapshots(&query) - So(err, ShouldBeNil) + require.NoError(t, err) - So(len(query.Result), ShouldEqual, 1) - So(query.Result[0].Key, ShouldEqual, notExpiredsnapshot.Key) + require.Len(t, query.Result, 1) + require.Equal(t, nonExpiredSnapshot.Key, query.Result[0].Key) }) } -func createTestSnapshot(sqlstore *SqlStore, key string, expires int64) *models.DashboardSnapshot { +func createTestSnapshot(t *testing.T, sqlstore *SqlStore, key string, expires int64) *models.DashboardSnapshot { cmd := models.CreateDashboardSnapshotCommand{ Key: key, DeleteKey: "delete" + key, @@ -157,13 +175,13 @@ func createTestSnapshot(sqlstore *SqlStore, key string, expires int64) *models.D Expires: expires, } err := CreateDashboardSnapshot(&cmd) - So(err, ShouldBeNil) + require.NoError(t, err) // Set expiry date manually - to be able to create expired snapshots if expires < 0 { expireDate := time.Now().Add(time.Second * time.Duration(expires)) _, err = sqlstore.engine.Exec("UPDATE dashboard_snapshot SET expires = ? WHERE id = ?", expireDate, cmd.Result.Id) - So(err, ShouldBeNil) + require.NoError(t, err) } return cmd.Result diff --git a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go index be0bc80134c..5b4b27455f2 100644 --- a/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go +++ b/pkg/services/sqlstore/migrations/dashboard_snapshot_mig.go @@ -64,4 +64,8 @@ func addDashboardSnapshotMigrations(mg *Migrator) { mg.AddMigration("Add column external_delete_url to dashboard_snapshots table", NewAddColumnMigration(snapshotV5, &Column{ Name: "external_delete_url", Type: DB_NVarchar, Length: 255, Nullable: true, })) + + mg.AddMigration("Add encrypted dashboard json column", NewAddColumnMigration(snapshotV5, &Column{ + Name: "dashboard_encrypted", Type: DB_Blob, Nullable: true, + })) }