diff --git a/pkg/api/dashboard_snapshot.go b/pkg/api/dashboard_snapshot.go index 561b4158d3e..de672ba51aa 100644 --- a/pkg/api/dashboard_snapshot.go +++ b/pkg/api/dashboard_snapshot.go @@ -11,6 +11,7 @@ import ( dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" "github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/metrics" + ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/services/dashboards" @@ -68,12 +69,27 @@ func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) { // 403: forbiddenError // 500: internalServerError func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) { + cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{} + if err := web.Bind(c.Req, &cmd); err != nil { + c.JsonApiErr(http.StatusBadRequest, "bad request data", err) + return + } + + // Do not check permissions when the instance snapshot public mode is enabled + if !hs.Cfg.SnapshotPublicMode { + evaluator := ac.EvalPermission(dashboards.ActionDashboardsWrite, 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, dashboardsnapshot.SnapshotSharingOptions{ SnapshotsEnabled: hs.Cfg.SnapshotEnabled, ExternalEnabled: hs.Cfg.ExternalEnabled, ExternalSnapshotName: hs.Cfg.ExternalSnapshotName, ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl, - }, hs.dashboardsnapshotsService) + }, cmd, hs.dashboardsnapshotsService) } // GET /api/snapshots/:key diff --git a/pkg/registry/apis/dashboardsnapshot/register.go b/pkg/registry/apis/dashboardsnapshot/register.go index 0034bb2b9ed..4f74c4f5fba 100644 --- a/pkg/registry/apis/dashboardsnapshot/register.go +++ b/pkg/registry/apis/dashboardsnapshot/register.go @@ -269,6 +269,13 @@ func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes { fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.OrgID), nil) return } + + cmd := dashboardsnapshots.CreateDashboardSnapshotCommand{} + if err := web.Bind(wrap.Req, &cmd); err != nil { + wrap.JsonApiErr(http.StatusBadRequest, "bad request data", err) + return + } + opts, err := b.options(info.Value) if err != nil { wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err) @@ -276,7 +283,7 @@ func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes { } // Use the existing snapshot service - dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, b.service) + dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, cmd, b.service) }, }, { diff --git a/pkg/services/dashboardsnapshots/service.go b/pkg/services/dashboardsnapshots/service.go index 07ae3423c6a..3fcf4d42d45 100644 --- a/pkg/services/dashboardsnapshots/service.go +++ b/pkg/services/dashboardsnapshots/service.go @@ -17,7 +17,6 @@ import ( contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" - "github.com/grafana/grafana/pkg/web" ) //go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go @@ -34,19 +33,14 @@ var client = &http.Client{ Transport: &http.Transport{Proxy: http.ProxyFromEnvironment}, } -func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.SnapshotSharingOptions, svc Service) { +func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.SnapshotSharingOptions, cmd CreateDashboardSnapshotCommand, svc Service) { if !cfg.SnapshotsEnabled { c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) return } - cmd := CreateDashboardSnapshotCommand{} - if err := web.Bind(c.Req, &cmd); err != nil { - c.JsonApiErr(http.StatusBadRequest, "bad request data", err) - return - } - if cmd.Name == "" { - cmd.Name = "Unnamed snapshot" + if cmd.DashboardCreateCommand.Name == "" { + cmd.DashboardCreateCommand.Name = "Unnamed snapshot" } userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) @@ -66,7 +60,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.S return } - if cmd.External { + if cmd.DashboardCreateCommand.External { if !cfg.ExternalEnabled { c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) return @@ -83,11 +77,11 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.S cmd.DeleteKey = resp.DeleteKey cmd.ExternalURL = resp.Url cmd.ExternalDeleteURL = resp.DeleteUrl - cmd.Dashboard = &common.Unstructured{} + cmd.DashboardCreateCommand.Dashboard = &common.Unstructured{} metrics.MApiDashboardSnapshotExternal.Inc() } else { - cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") + cmd.DashboardCreateCommand.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") if cmd.Key == "" { var err error diff --git a/pkg/services/dashboardsnapshots/service_mock.go b/pkg/services/dashboardsnapshots/service_mock.go index 95ae9708c92..8773fbd3a68 100644 --- a/pkg/services/dashboardsnapshots/service_mock.go +++ b/pkg/services/dashboardsnapshots/service_mock.go @@ -1,4 +1,4 @@ -// Code generated by mockery v2.16.0. DO NOT EDIT. +// Code generated by mockery v2.32.0. DO NOT EDIT. package dashboardsnapshots @@ -18,6 +18,10 @@ func (_m *MockService) CreateDashboardSnapshot(_a0 context.Context, _a1 *CreateD ret := _m.Called(_a0, _a1) var r0 *DashboardSnapshot + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *CreateDashboardSnapshotCommand) (*DashboardSnapshot, error)); ok { + return rf(_a0, _a1) + } if rf, ok := ret.Get(0).(func(context.Context, *CreateDashboardSnapshotCommand) *DashboardSnapshot); ok { r0 = rf(_a0, _a1) } else { @@ -26,7 +30,6 @@ func (_m *MockService) CreateDashboardSnapshot(_a0 context.Context, _a1 *CreateD } } - var r1 error if rf, ok := ret.Get(1).(func(context.Context, *CreateDashboardSnapshotCommand) error); ok { r1 = rf(_a0, _a1) } else { @@ -69,6 +72,10 @@ func (_m *MockService) GetDashboardSnapshot(_a0 context.Context, _a1 *GetDashboa ret := _m.Called(_a0, _a1) var r0 *DashboardSnapshot + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotQuery) (*DashboardSnapshot, error)); ok { + return rf(_a0, _a1) + } if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotQuery) *DashboardSnapshot); ok { r0 = rf(_a0, _a1) } else { @@ -77,7 +84,6 @@ func (_m *MockService) GetDashboardSnapshot(_a0 context.Context, _a1 *GetDashboa } } - var r1 error if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardSnapshotQuery) error); ok { r1 = rf(_a0, _a1) } else { @@ -92,6 +98,10 @@ func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDas ret := _m.Called(_a0, _a1) var r0 DashboardSnapshotsList + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotsQuery) (DashboardSnapshotsList, error)); ok { + return rf(_a0, _a1) + } if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotsQuery) DashboardSnapshotsList); ok { r0 = rf(_a0, _a1) } else { @@ -100,7 +110,6 @@ func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDas } } - var r1 error if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardSnapshotsQuery) error); ok { r1 = rf(_a0, _a1) } else { @@ -110,13 +119,12 @@ func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDas return r0, r1 } -type mockConstructorTestingTNewMockService interface { +// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockService(t interface { mock.TestingT Cleanup(func()) -} - -// NewMockService creates a new instance of MockService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewMockService(t mockConstructorTestingTNewMockService) *MockService { +}) *MockService { mock := &MockService{} mock.Mock.Test(t) diff --git a/public/app/features/dashboard-scene/sharing/ShareModal.tsx b/public/app/features/dashboard-scene/sharing/ShareModal.tsx index 51f996f529f..b1046d45dcd 100644 --- a/public/app/features/dashboard-scene/sharing/ShareModal.tsx +++ b/public/app/features/dashboard-scene/sharing/ShareModal.tsx @@ -46,12 +46,13 @@ export class ShareModal extends SceneObjectBase implements Moda const { dashboardRef, panelRef } = this.state; const tabs: SceneShareTab[] = [new ShareLinkTab({ dashboardRef, panelRef, modalRef: this.getRef() })]; + const dashboard = getDashboardSceneFor(this); if (!panelRef) { tabs.push(new ShareExportTab({ dashboardRef, modalRef: this.getRef() })); } - if (contextSrv.isSignedIn && config.snapshotEnabled) { + if (contextSrv.isSignedIn && config.snapshotEnabled && dashboard.canEditDashboard()) { tabs.push(new ShareSnapshotTab({ panelRef, dashboardRef, modalRef: this.getRef() })); } diff --git a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx index 18be25f3889..50d31b57025 100644 --- a/public/app/features/dashboard/components/ShareModal/ShareModal.tsx +++ b/public/app/features/dashboard/components/ShareModal/ShareModal.tsx @@ -29,11 +29,11 @@ export function addPanelShareTab(tab: ShareModalTabModel) { customPanelTabs.push(tab); } -function getTabs(panel?: PanelModel, activeTab?: string) { +function getTabs(canEditDashboard: boolean, panel?: PanelModel, activeTab?: string) { const linkLabel = t('share-modal.tab-title.link', 'Link'); const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: shareDashboardType.link, component: ShareLink }]; - if (contextSrv.isSignedIn && config.snapshotEnabled) { + if (contextSrv.isSignedIn && config.snapshotEnabled && canEditDashboard) { const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot'); tabs.push({ label: snapshotLabel, value: shareDashboardType.snapshot, component: ShareSnapshot }); } @@ -86,7 +86,7 @@ interface State { } function getInitialState(props: Props): State { - const { tabs, activeTab } = getTabs(props.panel, props.activeTab); + const { tabs, activeTab } = getTabs(props.dashboard.canEditDashboard(), props.panel, props.activeTab); return { tabs, @@ -116,7 +116,8 @@ class UnthemedShareModal extends React.Component { const { panel } = this.props; const { activeTab } = this.state; const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); - const tabs = getTabs(this.props.panel, this.state.activeTab).tabs; + const canEditDashboard = this.props.dashboard.canEditDashboard(); + const tabs = getTabs(canEditDashboard, this.props.panel, this.state.activeTab).tabs; return (