diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 49194a625f4..62035a0e749 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -76,21 +76,27 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) { return } - // Do not check permissions when the instance snapshot public mode is enabled - if !hs.Cfg.SnapshotPublicMode { - evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))) - if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { - c.JsonApiErr(http.StatusForbidden, "forbidden", err) - return - } - } - - dashboardsnapshots.CreateDashboardSnapshot(c, snapshot.SnapshotSharingOptions{ + cfg := snapshot.SnapshotSharingOptions{ SnapshotsEnabled: hs.Cfg.SnapshotEnabled, ExternalEnabled: hs.Cfg.ExternalEnabled, ExternalSnapshotName: hs.Cfg.ExternalSnapshotName, ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl, - }, cmd, hs.dashboardsnapshotsService) + } + + if hs.Cfg.SnapshotPublicMode { + // Public mode: no user or dashboard validation needed + dashboardsnapshots.CreateDashboardSnapshotPublic(c, cfg, cmd, hs.dashboardsnapshotsService) + return + } + + // Regular mode: check permissions + evaluator := ac.EvalAll(ac.EvalPermission(dashboards.ActionSnapshotsCreate), ac.EvalPermission(dashboards.ActionDashboardsRead, dashboards.ScopeDashboardsProvider.GetResourceScopeUID(cmd.Dashboard.GetNestedString("uid")))) + if canSave, err := hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluator); err != nil || !canSave { + c.JsonApiErr(http.StatusForbidden, "forbidden", err) + return + } + + dashboardsnapshots.CreateDashboardSnapshot(c, cfg, cmd, hs.dashboardsnapshotsService) } // GET /api/snapshots/:key @@ -213,13 +219,6 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon return response.Error(http.StatusUnauthorized, "OrgID mismatch", nil) } - if queryResult.External { - err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) - if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) - } - } - // Dashboard can be empty (creation error or external snapshot). This means that the mustInt here returns a 0, // which before RBAC would result in a dashboard which has no ACL. A dashboard without an ACL would fallback // to the user’s org role, which for editors and admins would essentially always be allowed here. With RBAC, @@ -239,6 +238,13 @@ func (hs *HTTPServer) DeleteDashboardSnapshot(c *contextmodel.ReqContext) respon } } + if queryResult.External { + err := dashboardsnapshots.DeleteExternalDashboardSnapshot(queryResult.ExternalDeleteURL) + if err != nil { + return response.Error(http.StatusInternalServerError, "Failed to delete external dashboard", err) + } + } + cmd := &dashboardsnapshots.DeleteDashboardSnapshotCommand{DeleteKey: queryResult.DeleteKey} if err := hs.dashboardsnapshotsService.DeleteDashboardSnapshot(c.Req.Context(), cmd); err != nil { diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index 281afee38ac..98954b585a6 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -36,6 +36,9 @@ var client = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } +// CreateDashboardSnapshot creates a snapshot when running Grafana in regular mode. +// It validates the user and dashboard exist before creating the snapshot. +// This mode supports both local and external snapshots. func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { if !cfg.SnapshotsEnabled { c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) @@ -43,6 +46,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh } uid := cmd.Dashboard.GetNestedString("uid") + user, err := identity.GetRequester(c.Req.Context()) if err != nil { c.JsonApiErr(http.StatusBadRequest, "missing user in context", nil) @@ -59,21 +63,18 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh return } + cmd.ExternalURL = "" + cmd.OrgID = user.GetOrgID() + cmd.UserID, _ = identity.UserIdentifier(user.GetID()) + if cmd.Name == "" { cmd.Name = "Unnamed snapshot" } - var snapshotUrl string - cmd.ExternalURL = "" - cmd.OrgID = user.GetOrgID() - cmd.UserID, _ = identity.UserIdentifier(user.GetID()) - originalDashboardURL, err := createOriginalDashboardURL(&cmd) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) - return - } + var snapshotURL string if cmd.External { + // Handle external snapshot creation if !cfg.ExternalEnabled { c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) return @@ -85,40 +86,83 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh return } - snapshotUrl = resp.Url cmd.Key = resp.Key cmd.DeleteKey = resp.DeleteKey cmd.ExternalURL = resp.Url cmd.ExternalDeleteURL = resp.DeleteUrl cmd.Dashboard = &common.Unstructured{} + snapshotURL = resp.Url metrics.MApiDashboardSnapshotExternal.Inc() } else { - cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") - - if cmd.Key == "" { - var err error - cmd.Key, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return - } + // Handle local snapshot creation + originalDashboardURL, err := createOriginalDashboardURL(&cmd) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Invalid app URL", err) + return } - if cmd.DeleteKey == "" { - var err error - cmd.DeleteKey, err = util.GetRandomString(32) - if err != nil { - c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) - return - } + snapshotURL, err = prepareLocalSnapshot(&cmd, originalDashboardURL) + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return } - snapshotUrl = setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key) - metrics.MApiDashboardSnapshotCreate.Inc() } + saveAndRespond(c, svc, cmd, snapshotURL) +} + +// CreateDashboardSnapshotPublic creates a snapshot when running Grafana in public mode. +// In public mode, there is no user or dashboard information to validate. +// Only local snapshots are supported (external snapshots are not available). +func CreateDashboardSnapshotPublic(c *contextmodel.ReqContext, cfg snapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { + if !cfg.SnapshotsEnabled { + c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) + return + } + + if cmd.Name == "" { + cmd.Name = "Unnamed snapshot" + } + + snapshotURL, err := prepareLocalSnapshot(&cmd, "") + if err != nil { + c.JsonApiErr(http.StatusInternalServerError, "Could not generate random string", err) + return + } + + metrics.MApiDashboardSnapshotCreate.Inc() + + saveAndRespond(c, svc, cmd, snapshotURL) +} + +// prepareLocalSnapshot prepares the command for a local snapshot and returns the snapshot URL. +func prepareLocalSnapshot(cmd *CreateDashboardSnapshotCommand, originalDashboardURL string) (string, error) { + cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") + + if cmd.Key == "" { + key, err := util.GetRandomString(32) + if err != nil { + return "", err + } + cmd.Key = key + } + + if cmd.DeleteKey == "" { + deleteKey, err := util.GetRandomString(32) + if err != nil { + return "", err + } + cmd.DeleteKey = deleteKey + } + + return setting.ToAbsUrl("dashboard/snapshot/" + cmd.Key), nil +} + +// saveAndRespond saves the snapshot and sends the response. +func saveAndRespond(c *contextmodel.ReqContext, svc Service, cmd CreateDashboardSnapshotCommand, snapshotURL string) { result, err := svc.CreateDashboardSnapshot(c.Req.Context(), &cmd) if err != nil { c.JsonApiErr(http.StatusInternalServerError, "Failed to create snapshot", err) @@ -128,7 +172,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg snapshot.SnapshotSh c.JSON(http.StatusOK, snapshot.DashboardCreateResponse{ Key: result.Key, DeleteKey: result.DeleteKey, - URL: snapshotUrl, + URL: snapshotURL, DeleteURL: setting.ToAbsUrl("api/snapshots-delete/" + result.DeleteKey), }) } diff --git a/pkg/services/dashboardsnapshots/service_test.go b/pkg/services/dashboardsnapshots/service_test.go index c8e817b720e..7add1233581 100644 --- a/pkg/services/dashboardsnapshots/service_test.go +++ b/pkg/services/dashboardsnapshots/service_test.go @@ -20,40 +20,30 @@ import ( "github.com/grafana/grafana/pkg/web" ) -func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { - mockService := &MockService{} - cfg := snapshot.SnapshotSharingOptions{ - SnapshotsEnabled: true, - ExternalEnabled: false, +func createTestDashboard(t *testing.T) *common.Unstructured { + t.Helper() + dashboard := &common.Unstructured{} + dashboardData := map[string]any{ + "uid": "test-dashboard-uid", + "id": 123, } - testUser := &user.SignedInUser{ + dashboardBytes, _ := json.Marshal(dashboardData) + _ = json.Unmarshal(dashboardBytes, dashboard) + return dashboard +} + +func createTestUser() *user.SignedInUser { + return &user.SignedInUser{ UserID: 1, OrgID: 1, Login: "testuser", Name: "Test User", Email: "test@example.com", } - dashboard := &common.Unstructured{} - dashboardData := map[string]interface{}{ - "uid": "test-dashboard-uid", - "id": 123, - } - dashboardBytes, _ := json.Marshal(dashboardData) - _ = json.Unmarshal(dashboardBytes, dashboard) - - cmd := CreateDashboardSnapshotCommand{ - DashboardCreateCommand: snapshot.DashboardCreateCommand{ - Dashboard: dashboard, - Name: "Test Snapshot", - }, - } - - mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). - Return(dashboards.ErrDashboardNotFound) - - req, _ := http.NewRequest("POST", "/api/snapshots", nil) - req = req.WithContext(identity.WithRequester(req.Context(), testUser)) +} +func createReqContext(t *testing.T, req *http.Request, testUser *user.SignedInUser) (*contextmodel.ReqContext, *httptest.ResponseRecorder) { + t.Helper() recorder := httptest.NewRecorder() ctx := &contextmodel.ReqContext{ Context: &web.Context{ @@ -63,13 +53,319 @@ func TestCreateDashboardSnapshot_DashboardNotFound(t *testing.T) { SignedInUser: testUser, Logger: log.NewNopLogger(), } + return ctx, recorder +} - CreateDashboardSnapshot(ctx, cfg, cmd, mockService) +// TestCreateDashboardSnapshot tests snapshot creation in regular mode (non-public instance). +// These tests cover scenarios when Grafana is running as a regular server with user authentication. +func TestCreateDashboardSnapshot(t *testing.T) { + t.Run("should return error when dashboard not found", func(t *testing.T) { + mockService := &MockService{} + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) - mockService.AssertExpectations(t) - assert.Equal(t, http.StatusBadRequest, recorder.Code) - var response map[string]interface{} - err := json.Unmarshal(recorder.Body.Bytes(), &response) - require.NoError(t, err) - assert.Equal(t, "Dashboard not found", response["message"]) + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(dashboards.ErrDashboardNotFound) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusBadRequest, recorder.Code) + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard not found", response["message"]) + }) + + t.Run("should create external snapshot when external is enabled", func(t *testing.T) { + externalServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/api/snapshots", r.URL.Path) + assert.Equal(t, "POST", r.Method) + + response := map[string]any{ + "key": "external-key", + "deleteKey": "external-delete-key", + "url": "https://external.example.com/dashboard/snapshot/external-key", + "deleteUrl": "https://external.example.com/api/snapshots-delete/external-delete-key", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(response) + })) + defer externalServer.Close() + + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: true, + ExternalSnapshotURL: externalServer.URL, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test External Snapshot", + External: true, + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "external-key", + DeleteKey: "external-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "external-key", response["key"]) + assert.Equal(t, "external-delete-key", response["deleteKey"]) + assert.Equal(t, "https://external.example.com/dashboard/snapshot/external-key", response["url"]) + }) + + t.Run("should return forbidden when external is disabled", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + ExternalEnabled: false, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test External Snapshot", + External: true, + }, + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusForbidden, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "External dashboard creation is disabled", response["message"]) + }) + + t.Run("should create local snapshot", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + } + testUser := createTestUser() + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Local Snapshot", + }, + Key: "local-key", + DeleteKey: "local-delete-key", + } + + mockService.On("ValidateDashboardExists", mock.Anything, int64(1), "test-dashboard-uid"). + Return(nil) + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "local-key", + DeleteKey: "local-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + req = req.WithContext(identity.WithRequester(req.Context(), testUser)) + ctx, recorder := createReqContext(t, req, testUser) + + CreateDashboardSnapshot(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "local-key", response["key"]) + assert.Equal(t, "local-delete-key", response["deleteKey"]) + assert.Contains(t, response["url"], "dashboard/snapshot/local-key") + assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/local-delete-key") + }) +} + +// TestCreateDashboardSnapshotPublic tests snapshot creation in public mode. +// These tests cover scenarios when Grafana is running as a public snapshot server +// where no user authentication or dashboard validation is required. +func TestCreateDashboardSnapshotPublic(t *testing.T) { + t.Run("should create local snapshot without user context", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: true, + } + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + Key: "test-key", + DeleteKey: "test-delete-key", + } + + mockService.On("CreateDashboardSnapshot", mock.Anything, mock.Anything). + Return(&DashboardSnapshot{ + Key: "test-key", + DeleteKey: "test-delete-key", + }, nil) + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService) + + mockService.AssertExpectations(t) + assert.Equal(t, http.StatusOK, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "test-key", response["key"]) + assert.Equal(t, "test-delete-key", response["deleteKey"]) + assert.Contains(t, response["url"], "dashboard/snapshot/test-key") + assert.Contains(t, response["deleteUrl"], "api/snapshots-delete/test-delete-key") + }) + + t.Run("should return forbidden when snapshots are disabled", func(t *testing.T) { + mockService := NewMockService(t) + cfg := snapshot.SnapshotSharingOptions{ + SnapshotsEnabled: false, + } + dashboard := createTestDashboard(t) + + cmd := CreateDashboardSnapshotCommand{ + DashboardCreateCommand: snapshot.DashboardCreateCommand{ + Dashboard: dashboard, + Name: "Test Snapshot", + }, + } + + req, _ := http.NewRequest("POST", "/api/snapshots", nil) + recorder := httptest.NewRecorder() + ctx := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("POST", recorder), + }, + Logger: log.NewNopLogger(), + } + + CreateDashboardSnapshotPublic(ctx, cfg, cmd, mockService) + + assert.Equal(t, http.StatusForbidden, recorder.Code) + + var response map[string]any + err := json.Unmarshal(recorder.Body.Bytes(), &response) + require.NoError(t, err) + assert.Equal(t, "Dashboard Snapshots are disabled", response["message"]) + }) +} + +// TestDeleteExternalDashboardSnapshot tests deletion of external snapshots. +// This function is called in public mode and doesn't require user context. +func TestDeleteExternalDashboardSnapshot(t *testing.T) { + t.Run("should return nil on successful deletion", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "GET", r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.NoError(t, err) + }) + + t.Run("should gracefully handle already deleted snapshot", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + response := map[string]any{ + "message": "Failed to get dashboard snapshot", + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.NoError(t, err) + }) + + t.Run("should return error on unexpected status code", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected response when deleting external snapshot") + assert.Contains(t, err.Error(), "404") + }) + + t.Run("should return error on 500 with different message", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + response := map[string]any{ + "message": "Some other error", + } + _ = json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + err := DeleteExternalDashboardSnapshot(server.URL) + assert.Error(t, err) + assert.Contains(t, err.Error(), "500") + }) }