Snapshots: Viewers can not create a Snapshot (#84952)

This commit is contained in:
Ezequiel Victorero 2024-03-22 14:31:01 -03:00 committed by GitHub
parent 629a8808e0
commit c57c033522
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 55 additions and 28 deletions

View File

@ -11,6 +11,7 @@ import (
dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1" dashboardsnapshot "github.com/grafana/grafana/pkg/apis/dashboardsnapshot/v0alpha1"
"github.com/grafana/grafana/pkg/infra/appcontext" "github.com/grafana/grafana/pkg/infra/appcontext"
"github.com/grafana/grafana/pkg/infra/metrics" "github.com/grafana/grafana/pkg/infra/metrics"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/dashboards"
@ -68,12 +69,27 @@ func (hs *HTTPServer) GetSharingOptions(c *contextmodel.ReqContext) {
// 403: forbiddenError // 403: forbiddenError
// 500: internalServerError // 500: internalServerError
func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) { 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{ dashboardsnapshots.CreateDashboardSnapshot(c, dashboardsnapshot.SnapshotSharingOptions{
SnapshotsEnabled: hs.Cfg.SnapshotEnabled, SnapshotsEnabled: hs.Cfg.SnapshotEnabled,
ExternalEnabled: hs.Cfg.ExternalEnabled, ExternalEnabled: hs.Cfg.ExternalEnabled,
ExternalSnapshotName: hs.Cfg.ExternalSnapshotName, ExternalSnapshotName: hs.Cfg.ExternalSnapshotName,
ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl, ExternalSnapshotURL: hs.Cfg.ExternalSnapshotUrl,
}, hs.dashboardsnapshotsService) }, cmd, hs.dashboardsnapshotsService)
} }
// GET /api/snapshots/:key // GET /api/snapshots/:key

View File

@ -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) fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.OrgID), nil)
return 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) opts, err := b.options(info.Value)
if err != nil { if err != nil {
wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err) wrap.JsonApiErr(http.StatusBadRequest, "error getting options", err)
@ -276,7 +283,7 @@ func (b *SnapshotsAPIBuilder) GetAPIRoutes() *builder.APIRoutes {
} }
// Use the existing snapshot service // Use the existing snapshot service
dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, b.service) dashboardsnapshots.CreateDashboardSnapshot(wrap, opts.Spec, cmd, b.service)
}, },
}, },
{ {

View File

@ -17,7 +17,6 @@ import (
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util" "github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
) )
//go:generate mockery --name Service --structname MockService --inpackage --filename service_mock.go //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}, 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 { if !cfg.SnapshotsEnabled {
c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil) c.JsonApiErr(http.StatusForbidden, "Dashboard Snapshots are disabled", nil)
return return
} }
cmd := CreateDashboardSnapshotCommand{} if cmd.DashboardCreateCommand.Name == "" {
if err := web.Bind(c.Req, &cmd); err != nil { cmd.DashboardCreateCommand.Name = "Unnamed snapshot"
c.JsonApiErr(http.StatusBadRequest, "bad request data", err)
return
}
if cmd.Name == "" {
cmd.Name = "Unnamed snapshot"
} }
userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID()) userID, err := identity.UserIdentifier(c.SignedInUser.GetNamespacedID())
@ -66,7 +60,7 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.S
return return
} }
if cmd.External { if cmd.DashboardCreateCommand.External {
if !cfg.ExternalEnabled { if !cfg.ExternalEnabled {
c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil) c.JsonApiErr(http.StatusForbidden, "External dashboard creation is disabled", nil)
return return
@ -83,11 +77,11 @@ func CreateDashboardSnapshot(c *contextmodel.ReqContext, cfg dashboardsnapshot.S
cmd.DeleteKey = resp.DeleteKey cmd.DeleteKey = resp.DeleteKey
cmd.ExternalURL = resp.Url cmd.ExternalURL = resp.Url
cmd.ExternalDeleteURL = resp.DeleteUrl cmd.ExternalDeleteURL = resp.DeleteUrl
cmd.Dashboard = &common.Unstructured{} cmd.DashboardCreateCommand.Dashboard = &common.Unstructured{}
metrics.MApiDashboardSnapshotExternal.Inc() metrics.MApiDashboardSnapshotExternal.Inc()
} else { } else {
cmd.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl") cmd.DashboardCreateCommand.Dashboard.SetNestedField(originalDashboardURL, "snapshot", "originalUrl")
if cmd.Key == "" { if cmd.Key == "" {
var err error var err error

View File

@ -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 package dashboardsnapshots
@ -18,6 +18,10 @@ func (_m *MockService) CreateDashboardSnapshot(_a0 context.Context, _a1 *CreateD
ret := _m.Called(_a0, _a1) ret := _m.Called(_a0, _a1)
var r0 *DashboardSnapshot 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 { if rf, ok := ret.Get(0).(func(context.Context, *CreateDashboardSnapshotCommand) *DashboardSnapshot); ok {
r0 = rf(_a0, _a1) r0 = rf(_a0, _a1)
} else { } 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 { if rf, ok := ret.Get(1).(func(context.Context, *CreateDashboardSnapshotCommand) error); ok {
r1 = rf(_a0, _a1) r1 = rf(_a0, _a1)
} else { } else {
@ -69,6 +72,10 @@ func (_m *MockService) GetDashboardSnapshot(_a0 context.Context, _a1 *GetDashboa
ret := _m.Called(_a0, _a1) ret := _m.Called(_a0, _a1)
var r0 *DashboardSnapshot 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 { if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotQuery) *DashboardSnapshot); ok {
r0 = rf(_a0, _a1) r0 = rf(_a0, _a1)
} else { } 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 { if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardSnapshotQuery) error); ok {
r1 = rf(_a0, _a1) r1 = rf(_a0, _a1)
} else { } else {
@ -92,6 +98,10 @@ func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDas
ret := _m.Called(_a0, _a1) ret := _m.Called(_a0, _a1)
var r0 DashboardSnapshotsList 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 { if rf, ok := ret.Get(0).(func(context.Context, *GetDashboardSnapshotsQuery) DashboardSnapshotsList); ok {
r0 = rf(_a0, _a1) r0 = rf(_a0, _a1)
} else { } 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 { if rf, ok := ret.Get(1).(func(context.Context, *GetDashboardSnapshotsQuery) error); ok {
r1 = rf(_a0, _a1) r1 = rf(_a0, _a1)
} else { } else {
@ -110,13 +119,12 @@ func (_m *MockService) SearchDashboardSnapshots(_a0 context.Context, _a1 *GetDas
return r0, r1 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 mock.TestingT
Cleanup(func()) Cleanup(func())
} }) *MockService {
// 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 {
mock := &MockService{} mock := &MockService{}
mock.Mock.Test(t) mock.Mock.Test(t)

View File

@ -46,12 +46,13 @@ export class ShareModal extends SceneObjectBase<ShareModalState> implements Moda
const { dashboardRef, panelRef } = this.state; const { dashboardRef, panelRef } = this.state;
const tabs: SceneShareTab[] = [new ShareLinkTab({ dashboardRef, panelRef, modalRef: this.getRef() })]; const tabs: SceneShareTab[] = [new ShareLinkTab({ dashboardRef, panelRef, modalRef: this.getRef() })];
const dashboard = getDashboardSceneFor(this);
if (!panelRef) { if (!panelRef) {
tabs.push(new ShareExportTab({ dashboardRef, modalRef: this.getRef() })); 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() })); tabs.push(new ShareSnapshotTab({ panelRef, dashboardRef, modalRef: this.getRef() }));
} }

View File

@ -29,11 +29,11 @@ export function addPanelShareTab(tab: ShareModalTabModel) {
customPanelTabs.push(tab); 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 linkLabel = t('share-modal.tab-title.link', 'Link');
const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: shareDashboardType.link, component: ShareLink }]; 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'); const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot');
tabs.push({ label: snapshotLabel, value: shareDashboardType.snapshot, component: ShareSnapshot }); tabs.push({ label: snapshotLabel, value: shareDashboardType.snapshot, component: ShareSnapshot });
} }
@ -86,7 +86,7 @@ interface State {
} }
function getInitialState(props: Props): 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 { return {
tabs, tabs,
@ -116,7 +116,8 @@ class UnthemedShareModal extends React.Component<Props, State> {
const { panel } = this.props; const { panel } = this.props;
const { activeTab } = this.state; const { activeTab } = this.state;
const title = panel ? t('share-modal.panel.title', 'Share Panel') : t('share-modal.dashboard.title', 'Share'); 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 ( return (
<ModalTabsHeader <ModalTabsHeader