mirror of
https://github.com/grafana/grafana.git
synced 2025-01-08 15:13:30 -06:00
Snapshots: Add RBAC roles for creating and deleting (#96126)
This commit is contained in:
parent
91d7517146
commit
5039725da6
@ -42,7 +42,7 @@ The following list contains role-based access control actions.
|
||||
|
||||
<!-- prettier-ignore-start -->
|
||||
| Action | Applicable scopes | Description |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
|---------------------------------------| ------------------------------------------------------------------------------------------------------------------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `alert.instances.external:read` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | Read alerts and silences in data sources that support alerting. |
|
||||
| `alert.instances.external:write` | <ul><li>`datasources:*`</li><li>`datasources:uid:*`</li></ul> | Manage alerts and silences in data sources that support alerting. |
|
||||
| `alert.instances:create` | None | Create silences in the current organization. |
|
||||
@ -71,7 +71,7 @@ The following list contains role-based access control actions.
|
||||
| `annotations:write` | <ul><li>`annotations:*`</li><li>`annotations:type:*`</li><li>`dashboards:*`</li><li>`dashboards:uid:*`</li><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Update annotations. |
|
||||
| `apikeys:read` | <ul><li>`apikeys:*`</li><li>`apikeys:id:*`</li></ul> | Read API keys. |
|
||||
| `apikeys:delete` | <ul><li>`apikeys:*`</li><li>`apikeys:id:*`</li></ul> | Delete API keys. |
|
||||
| `banners:write` | None | Create [announcement banners](/docs/grafana-cloud/whats-new/2024-09-10-announcement-banner/). |
|
||||
| `banners:write` | None | Create [announcement banners](/docs/grafana-cloud/whats-new/2024-09-10-announcement-banner/). |
|
||||
| `dashboards:create` | <ul><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Create dashboards in one or more folders and their subfolders. |
|
||||
| `dashboards:delete` | <ul><li>`dashboards:*`</li><li>`dashboards:uid:*`</li><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Delete one or more dashboards. |
|
||||
| `dashboards.insights:read` | None | Read dashboard insights data and see presence indicators. |
|
||||
@ -100,8 +100,8 @@ The following list contains role-based access control actions.
|
||||
| `folders:delete` | <ul><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Delete one or more folders and their subfolders. |
|
||||
| `folders:read` | <ul><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Read one or more folders and their subfolders. |
|
||||
| `folders:write` | <ul><li>`folders:*`</li><li>`folders:uid:*`</li></ul> | Update one or more folders and their subfolders. |
|
||||
| `groupsync.mappings:read` | None | List group attribute sync mappings. To use this permission, enable the `groupAttributeSync` feature toggle. |
|
||||
| `groupsync.mappings:write` | None | List, create, update, and delete group attribute sync mappings. To use this permission, enable the `groupAttributeSync` feature toggle. |
|
||||
| `groupsync.mappings:read` | None | List group attribute sync mappings. To use this permission, enable the `groupAttributeSync` feature toggle. |
|
||||
| `groupsync.mappings:write` | None | List, create, update, and delete group attribute sync mappings. To use this permission, enable the `groupAttributeSync` feature toggle. |
|
||||
| `ldap.config:reload` | None | Reload the LDAP configuration. |
|
||||
| `ldap.status:read` | None | Verify the availability of the LDAP server or servers. |
|
||||
| `ldap.user:read` | None | Read users via LDAP. |
|
||||
@ -154,6 +154,9 @@ The following list contains role-based access control actions.
|
||||
| `support.bundles:create` | None | Create support bundles. |
|
||||
| `support.bundles:delete` | None | Delete support bundles. |
|
||||
| `support.bundles:read` | None | List and download support bundles. |
|
||||
| `snapshots:create` | None | Create snapshots. |
|
||||
| `snapshots:delete` | None | Delete snapshots. |
|
||||
| `snapshots:read` | None | List snapshots. |
|
||||
| `status:accesscontrol` | <ul><li>`services:accesscontrol`</li><ul> | Get access-control enabled status. |
|
||||
| `teams.permissions:read` | <ul><li>`teams:*`</li><li>`teams:id:*`</li></ul> | Read members and Team Sync setup for teams. |
|
||||
| `teams.permissions:write` | <ul><li>`teams:*`</li><li>`teams:id:*`</li></ul> | Add, remove and update members and manage Team Sync setup for teams. |
|
||||
|
@ -327,7 +327,9 @@ font_regular = DejaVuSansCondensed.ttf
|
||||
font_bold = DejaVuSansCondensed-Bold.ttf
|
||||
# Name of the TrueType font file with italic style
|
||||
font_italic = DejaVuSansCondensed-Oblique.ttf
|
||||
# Allowed domains to receive reports. Use * to allow all domains. Use a comma-separated list to allow multiple domains. Example: allowed_domains = grafana.com, example.org
|
||||
# Maximum number of panel rendering request retries before returning an error. To disable the retry feature, enter `0`. This is available in public preview and requires the `reportingRetries` feature toggle.
|
||||
max_retries_per_panel = 3
|
||||
# Allowed domains to receive reports. Use an asterisk (`*`) to allow all domains. Use a comma-separated list to allow multiple domains. Example: allowed_domains = grafana.com, example.org
|
||||
allowed_domains = *
|
||||
```
|
||||
|
||||
|
@ -606,6 +606,45 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
Grants: []string{"Admin"},
|
||||
}
|
||||
|
||||
snapshotsCreatorRole := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
Name: "fixed:snapshots:creator",
|
||||
DisplayName: "Creator",
|
||||
Description: "Create snapshots",
|
||||
Group: "Snapshots",
|
||||
Permissions: []ac.Permission{
|
||||
{Action: dashboards.ActionSnapshotsCreate},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleEditor)},
|
||||
}
|
||||
|
||||
snapshotsDeleterRole := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
Name: "fixed:snapshots:deleter",
|
||||
DisplayName: "Deleter",
|
||||
Description: "Delete snapshots",
|
||||
Group: "Snapshots",
|
||||
Permissions: []ac.Permission{
|
||||
{Action: dashboards.ActionSnapshotsDelete},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleEditor)},
|
||||
}
|
||||
|
||||
snapshotsReaderRole := ac.RoleRegistration{
|
||||
Role: ac.RoleDTO{
|
||||
Name: "fixed:snapshots:reader",
|
||||
DisplayName: "Reader",
|
||||
Description: "Read snapshots",
|
||||
Group: "Snapshots",
|
||||
Permissions: []ac.Permission{
|
||||
{Action: dashboards.ActionSnapshotsRead},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleViewer)},
|
||||
}
|
||||
|
||||
roles := []ac.RoleRegistration{provisioningWriterRole, datasourcesReaderRole, builtInDatasourceReader, datasourcesWriterRole,
|
||||
datasourcesIdReaderRole, datasourcesCreatorRole, orgReaderRole, orgWriterRole,
|
||||
orgMaintainerRole, teamsCreatorRole, teamsWriterRole, teamsReaderRole, datasourcesExplorerRole,
|
||||
@ -613,7 +652,8 @@ func (hs *HTTPServer) declareFixedRoles() error {
|
||||
dashboardsCreatorRole, dashboardsReaderRole, dashboardsWriterRole,
|
||||
foldersCreatorRole, foldersReaderRole, generalFolderReaderRole, foldersWriterRole, apikeyReaderRole, apikeyWriterRole,
|
||||
publicDashboardsWriterRole, featuremgmtReaderRole, featuremgmtWriterRole, libraryPanelsCreatorRole,
|
||||
libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole}
|
||||
libraryPanelsReaderRole, libraryPanelsWriterRole, libraryPanelsGeneralReaderRole, libraryPanelsGeneralWriterRole,
|
||||
snapshotsCreatorRole, snapshotsDeleterRole, snapshotsReaderRole}
|
||||
|
||||
if hs.Features.IsEnabled(context.Background(), featuremgmt.FlagAnnotationPermissionUpdate) {
|
||||
allAnnotationsReaderRole := ac.RoleRegistration{
|
||||
|
@ -66,7 +66,8 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
reqGrafanaAdmin := middleware.ReqGrafanaAdmin
|
||||
reqOrgAdmin := middleware.ReqOrgAdmin
|
||||
reqRoleForAppRoute := middleware.RoleAppPluginAuth(hs.AccessControl, hs.pluginStore, hs.Features, hs.log)
|
||||
reqSnapshotPublicModeOrSignedIn := middleware.SnapshotPublicModeOrSignedIn(hs.Cfg)
|
||||
reqSnapshotPublicModeOrCreate := middleware.SnapshotPublicModeOrCreate(hs.Cfg, hs.AccessControl)
|
||||
reqSnapshotPublicModeOrDelete := middleware.SnapshotPublicModeOrDelete(hs.Cfg, hs.AccessControl)
|
||||
redirectFromLegacyPanelEditURL := middleware.RedirectFromLegacyPanelEditURL(hs.Cfg)
|
||||
authorize := ac.Middleware(hs.AccessControl)
|
||||
authorizeInOrg := ac.AuthorizeInOrgMiddleware(hs.AccessControl, hs.authnService)
|
||||
@ -509,7 +510,7 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
|
||||
// Dashboard snapshots
|
||||
apiRoute.Group("/dashboard/snapshots", func(dashboardRoute routing.RouteRegister) {
|
||||
dashboardRoute.Get("/", routing.Wrap(hs.SearchDashboardSnapshots))
|
||||
dashboardRoute.Get("/", authorize(ac.EvalPermission(dashboards.ActionSnapshotsRead)), routing.Wrap(hs.SearchDashboardSnapshots))
|
||||
})
|
||||
|
||||
// Playlist
|
||||
@ -610,11 +611,14 @@ func (hs *HTTPServer) registerRoutes() {
|
||||
r.Get("/avatar/:hash", requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), hs.AvatarCacheServer.Handler)
|
||||
|
||||
// Snapshots
|
||||
r.Post("/api/snapshots/", reqSnapshotPublicModeOrSignedIn, hs.getCreatedSnapshotHandler())
|
||||
r.Get("/api/snapshot/shared-options/", reqSignedIn, hs.GetSharingOptions)
|
||||
|
||||
r.Post("/api/snapshots/", reqSnapshotPublicModeOrCreate, hs.getCreatedSnapshotHandler())
|
||||
r.Get("/api/snapshots/:key", routing.Wrap(hs.GetDashboardSnapshot))
|
||||
r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrSignedIn, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey))
|
||||
r.Delete("/api/snapshots/:key", reqSignedIn, routing.Wrap(hs.DeleteDashboardSnapshot))
|
||||
r.Delete("/api/snapshots/:key", authorize(ac.EvalPermission(dashboards.ActionSnapshotsDelete)), routing.Wrap(hs.DeleteDashboardSnapshot))
|
||||
|
||||
// Snapshots delete for public mode or using the deleteKey
|
||||
r.Get("/api/snapshots-delete/:deleteKey", reqSnapshotPublicModeOrDelete, routing.Wrap(hs.DeleteDashboardSnapshotByDeleteKey))
|
||||
}
|
||||
|
||||
func middlewareUserUIDResolver(userService user.Service, paramName string) web.Handler {
|
||||
|
@ -77,7 +77,7 @@ func (hs *HTTPServer) CreateDashboardSnapshot(c *contextmodel.ReqContext) {
|
||||
|
||||
// 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")))
|
||||
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
|
||||
|
@ -47,11 +47,11 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) {
|
||||
|
||||
allowedUser := userWithPermissions(1, []accesscontrol.Permission{
|
||||
{Action: dashboards.ActionDashboardsWrite, Scope: "dashboards:uid:1"},
|
||||
{Action: dashboards.ActionSnapshotsDelete},
|
||||
})
|
||||
|
||||
t.Run("User should not be able to delete snapshot without permissions", func(t *testing.T) {
|
||||
svc := dashboards.NewFakeDashboardService(t)
|
||||
svc.On("GetDashboard", mock.Anything, mock.Anything).Return(&dashboards.Dashboard{UID: "1"}, nil)
|
||||
server := setup(t, svc, 0, "")
|
||||
|
||||
res, err := server.Send(webtest.RequestWithSignedInUser(
|
||||
@ -116,7 +116,7 @@ func TestHTTPServer_DeleteDashboardSnapshot(t *testing.T) {
|
||||
server := setup(t, svc, 1, "")
|
||||
res, err := server.Send(webtest.RequestWithSignedInUser(
|
||||
server.NewRequest(http.MethodDelete, "/api/snapshots/12345", nil),
|
||||
&user.SignedInUser{UserID: 1, OrgID: 1},
|
||||
allowedUser,
|
||||
))
|
||||
|
||||
require.NoError(t, err)
|
||||
@ -406,7 +406,7 @@ func setUpSnapshotTest(t *testing.T, userId int64, deleteUrl string) dashboardsn
|
||||
res.External = true
|
||||
res.ExternalDeleteURL = deleteUrl
|
||||
}
|
||||
dashSnapSvc.On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).Return(res, nil)
|
||||
dashSnapSvc.On("GetDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.GetDashboardSnapshotQuery")).Return(res, nil).Maybe()
|
||||
dashSnapSvc.On("DeleteDashboardSnapshot", mock.Anything, mock.AnythingOfType("*dashboardsnapshots.DeleteDashboardSnapshotCommand")).Return(nil).Maybe()
|
||||
return dashSnapSvc
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/auth"
|
||||
"github.com/grafana/grafana/pkg/services/authn"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
|
||||
@ -235,9 +236,9 @@ func Auth(options *AuthOptions) web.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotPublicModeOrSignedIn creates a middleware that allows access
|
||||
// if snapshot public mode is enabled or if user is signed in.
|
||||
func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler {
|
||||
// SnapshotPublicModeOrCreate creates a middleware that allows access
|
||||
// if snapshot public mode is enabled or if user has creation permission.
|
||||
func SnapshotPublicModeOrCreate(cfg *setting.Cfg, ac2 ac.AccessControl) web.Handler {
|
||||
return func(c *contextmodel.ReqContext) {
|
||||
if cfg.SnapshotPublicMode {
|
||||
return
|
||||
@ -247,6 +248,25 @@ func SnapshotPublicModeOrSignedIn(cfg *setting.Cfg) web.Handler {
|
||||
notAuthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
ac.Middleware(ac2)(ac.EvalPermission(dashboards.ActionSnapshotsCreate))
|
||||
}
|
||||
}
|
||||
|
||||
// SnapshotPublicModeOrDelete creates a middleware that allows access
|
||||
// if snapshot public mode is enabled or if user has delete permission.
|
||||
func SnapshotPublicModeOrDelete(cfg *setting.Cfg, ac2 ac.AccessControl) web.Handler {
|
||||
return func(c *contextmodel.ReqContext) {
|
||||
if cfg.SnapshotPublicMode {
|
||||
return
|
||||
}
|
||||
|
||||
if !c.IsSignedIn {
|
||||
notAuthorized(c)
|
||||
return
|
||||
}
|
||||
|
||||
ac.Middleware(ac2)(ac.EvalPermission(dashboards.ActionSnapshotsDelete))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -37,6 +37,8 @@ func setupAuthMiddlewareTest(t *testing.T, identity *authn.Identity, authErr err
|
||||
}
|
||||
|
||||
func TestAuth_Middleware(t *testing.T) {
|
||||
ac := &actest.FakeAccessControl{}
|
||||
|
||||
type testCase struct {
|
||||
desc string
|
||||
identity *authn.Identity
|
||||
@ -105,7 +107,7 @@ func TestAuth_Middleware(t *testing.T) {
|
||||
{
|
||||
desc: "snapshot public mode disabled should return 200 for authenticated user",
|
||||
path: "/api/secure",
|
||||
authMiddleware: SnapshotPublicModeOrSignedIn(&setting.Cfg{SnapshotPublicMode: false}),
|
||||
authMiddleware: SnapshotPublicModeOrCreate(&setting.Cfg{SnapshotPublicMode: false}, ac),
|
||||
identity: &authn.Identity{ID: "1", Type: claims.TypeUser},
|
||||
expecedReached: true,
|
||||
expectedCode: http.StatusOK,
|
||||
@ -113,7 +115,7 @@ func TestAuth_Middleware(t *testing.T) {
|
||||
{
|
||||
desc: "snapshot public mode disabled should return 401 for unauthenticated request",
|
||||
path: "/api/secure",
|
||||
authMiddleware: SnapshotPublicModeOrSignedIn(&setting.Cfg{SnapshotPublicMode: false}),
|
||||
authMiddleware: SnapshotPublicModeOrCreate(&setting.Cfg{SnapshotPublicMode: false}, ac),
|
||||
authErr: errors.New("no auth"),
|
||||
expecedReached: false,
|
||||
expectedCode: http.StatusUnauthorized,
|
||||
@ -121,7 +123,7 @@ func TestAuth_Middleware(t *testing.T) {
|
||||
{
|
||||
desc: "snapshot public mode enabled should return 200 for unauthenticated request",
|
||||
path: "/api/secure",
|
||||
authMiddleware: SnapshotPublicModeOrSignedIn(&setting.Cfg{SnapshotPublicMode: true}),
|
||||
authMiddleware: SnapshotPublicModeOrCreate(&setting.Cfg{SnapshotPublicMode: true}, ac),
|
||||
authErr: errors.New("no auth"),
|
||||
expecedReached: true,
|
||||
expectedCode: http.StatusOK,
|
||||
|
@ -32,6 +32,9 @@ const (
|
||||
ActionDashboardsPermissionsRead = "dashboards.permissions:read"
|
||||
ActionDashboardsPermissionsWrite = "dashboards.permissions:write"
|
||||
ActionDashboardsPublicWrite = "dashboards.public:write"
|
||||
ActionSnapshotsCreate = "snapshots:create"
|
||||
ActionSnapshotsDelete = "snapshots:delete"
|
||||
ActionSnapshotsRead = "snapshots:read"
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -362,7 +362,7 @@ func (s *ServiceImpl) buildDashboardNavLinks(c *contextmodel.ReqContext) []*navt
|
||||
})
|
||||
}
|
||||
|
||||
if s.cfg.SnapshotEnabled {
|
||||
if s.cfg.SnapshotEnabled && hasAccess(ac.EvalPermission(dashboards.ActionSnapshotsRead)) {
|
||||
dashboardChildNavs = append(dashboardChildNavs, &navtree.NavLink{
|
||||
Text: "Snapshots",
|
||||
SubTitle: "Interactive, publicly available, point-in-time representations of dashboards",
|
||||
|
@ -25,6 +25,7 @@ import { getScenePanelLinksSupplier } from 'app/features/panel/panellinks/linkSu
|
||||
import { createExtensionSubMenu } from 'app/features/plugins/extensions/utils';
|
||||
import { addDataTrailPanelAction } from 'app/features/trails/Integrations/dashboardIntegration';
|
||||
import { dispatch } from 'app/store/store';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
import { ShowConfirmModalEvent } from 'app/types/events';
|
||||
|
||||
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
|
||||
@ -111,7 +112,11 @@ export function panelMenuBehavior(menu: VizPanelMenu, isRepeat = false) {
|
||||
},
|
||||
});
|
||||
|
||||
if (contextSrv.isSignedIn && config.snapshotEnabled && dashboard.canEditDashboard()) {
|
||||
if (
|
||||
contextSrv.isSignedIn &&
|
||||
config.snapshotEnabled &&
|
||||
contextSrv.hasPermission(AccessControlAction.SnapshotsCreate)
|
||||
) {
|
||||
subMenu.push({
|
||||
text: t('share-panel.menu.share-snapshot-title', 'Share snapshot'),
|
||||
iconClassName: 'camera',
|
||||
|
@ -4,6 +4,7 @@ import { sceneGraph, VizPanel } from '@grafana/scenes';
|
||||
import appEvents from 'app/core/app_events';
|
||||
import { KeybindingSet } from 'app/core/services/KeybindingSet';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { shareDashboardType } from '../../dashboard/components/ShareModal/utils';
|
||||
import { ShareDrawer } from '../sharing/ShareDrawer/ShareDrawer';
|
||||
@ -74,7 +75,11 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
|
||||
}),
|
||||
});
|
||||
|
||||
if (contextSrv.isSignedIn && config.snapshotEnabled && scene.canEditDashboard()) {
|
||||
if (
|
||||
contextSrv.isSignedIn &&
|
||||
config.snapshotEnabled &&
|
||||
contextSrv.hasPermission(AccessControlAction.SnapshotsCreate)
|
||||
) {
|
||||
keybindings.addBinding({
|
||||
key: 'p s',
|
||||
onTrigger: withFocusedPanel(scene, async (vizPanel: VizPanel) => {
|
||||
|
@ -5,6 +5,8 @@ import { SceneTimeRange, VizPanel } from '@grafana/scenes';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
import { config } from '../../../../core/config';
|
||||
import { AccessControlAction } from '../../../../types';
|
||||
import { grantUserPermissions } from '../../../alerting/unified/mocks';
|
||||
import { DashboardScene, DashboardSceneState } from '../../scene/DashboardScene';
|
||||
import { DefaultGridLayoutManager } from '../../scene/layout-default/DefaultGridLayoutManager';
|
||||
|
||||
@ -22,12 +24,15 @@ describe('ShareMenu', () => {
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should render menu items', async () => {
|
||||
Object.defineProperty(contextSrv, 'isSignedIn', {
|
||||
value: true,
|
||||
});
|
||||
grantUserPermissions([AccessControlAction.SnapshotsCreate]);
|
||||
|
||||
config.publicDashboardsEnabled = true;
|
||||
config.snapshotEnabled = true;
|
||||
setup({ meta: { canEdit: true } });
|
||||
@ -36,6 +41,7 @@ describe('ShareMenu', () => {
|
||||
expect(await screen.findByTestId(selector.shareExternally)).toBeInTheDocument();
|
||||
expect(await screen.findByTestId(selector.shareSnapshot)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not share externally when public dashboard is disabled', async () => {
|
||||
config.publicDashboardsEnabled = false;
|
||||
setup();
|
||||
@ -62,7 +68,7 @@ describe('ShareMenu', () => {
|
||||
|
||||
expect(screen.queryByTestId(selector.shareSnapshot)).not.toBeInTheDocument();
|
||||
});
|
||||
it('should not share snapshot when dashboard cannot edit', async () => {
|
||||
it('should not share snapshot without permissions', async () => {
|
||||
Object.defineProperty(contextSrv, 'isSignedIn', {
|
||||
value: true,
|
||||
});
|
||||
|
@ -6,6 +6,7 @@ import { VizPanel } from '@grafana/scenes';
|
||||
import { IconName, Menu } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { isPublicDashboardsEnabled } from '../../../dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { getTrackingSource, shareDashboardType } from '../../../dashboard/components/ShareModal/utils';
|
||||
@ -69,14 +70,17 @@ export default function ShareMenu({ dashboard, panel }: { dashboard: DashboardSc
|
||||
testId: newShareButtonSelector.shareSnapshot,
|
||||
icon: 'camera',
|
||||
label: t('share-dashboard.menu.share-snapshot-title', 'Share snapshot'),
|
||||
renderCondition: contextSrv.isSignedIn && config.snapshotEnabled && dashboard.canEditDashboard(),
|
||||
renderCondition:
|
||||
contextSrv.isSignedIn &&
|
||||
config.snapshotEnabled &&
|
||||
contextSrv.hasPermission(AccessControlAction.SnapshotsCreate),
|
||||
onClick: () => {
|
||||
onMenuItemClick(shareDashboardType.snapshot);
|
||||
},
|
||||
});
|
||||
|
||||
return menuItems.filter((item) => item.renderCondition);
|
||||
}, [panel, dashboard]);
|
||||
}, [panel]);
|
||||
|
||||
const onClick = (item: ShareDrawerMenuItem) => {
|
||||
DashboardInteractions.sharingCategoryClicked({
|
||||
|
@ -5,6 +5,8 @@ import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { SceneComponentProps } from '@grafana/scenes';
|
||||
import { Alert, Button, ClipboardButton, Spinner, Stack, TextLink } from '@grafana/ui';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { SnapshotSharingOptions } from '../../../../dashboard/services/SnapshotSrv';
|
||||
import { ShareDrawerConfirmAction } from '../../ShareDrawer/ShareDrawerConfirmAction';
|
||||
@ -157,22 +159,36 @@ const UpsertSnapshotActions = ({
|
||||
url: string;
|
||||
onDeleteClick: () => void;
|
||||
onNewSnapshotClick: () => void;
|
||||
}) => (
|
||||
<Stack justifyContent="flex-start" gap={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<ClipboardButton
|
||||
icon="link"
|
||||
variant="primary"
|
||||
fill="outline"
|
||||
getText={() => url}
|
||||
data-testid={selectors.copyUrlButton}
|
||||
>
|
||||
<Trans i18nKey="snapshot.share.copy-link-button">Copy link</Trans>
|
||||
</ClipboardButton>
|
||||
<Button icon="trash-alt" variant="destructive" fill="outline" onClick={onDeleteClick}>
|
||||
<Trans i18nKey="snapshot.share.delete-button">Delete snapshot</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" fill="solid" onClick={onNewSnapshotClick}>
|
||||
<Trans i18nKey="snapshot.share.new-snapshot-button">New snapshot</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}) => {
|
||||
const hasDeletePermission = contextSrv.hasPermission(AccessControlAction.SnapshotsDelete);
|
||||
const deleteTooltip = hasDeletePermission
|
||||
? ''
|
||||
: t('snapshot.share.delete-permission-tooltip', "You don't have permission to delete snapshots");
|
||||
|
||||
return (
|
||||
<Stack justifyContent="flex-start" gap={1} direction={{ xs: 'column', sm: 'row' }}>
|
||||
<ClipboardButton
|
||||
icon="link"
|
||||
variant="primary"
|
||||
fill="outline"
|
||||
getText={() => url}
|
||||
data-testid={selectors.copyUrlButton}
|
||||
>
|
||||
<Trans i18nKey="snapshot.share.copy-link-button">Copy link</Trans>
|
||||
</ClipboardButton>
|
||||
<Button
|
||||
icon="trash-alt"
|
||||
variant="destructive"
|
||||
fill="outline"
|
||||
onClick={onDeleteClick}
|
||||
disabled={!hasDeletePermission}
|
||||
tooltip={deleteTooltip}
|
||||
>
|
||||
<Trans i18nKey="snapshot.share.delete-button">Delete snapshot</Trans>
|
||||
</Button>
|
||||
<Button variant="secondary" fill="solid" onClick={onNewSnapshotClick}>
|
||||
<Trans i18nKey="snapshot.share.new-snapshot-button">New snapshot</Trans>
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
@ -6,6 +6,7 @@ import { Modal, ModalTabsHeader, TabContent } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { t } from 'app/core/internationalization';
|
||||
import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { getTrackingSource } from '../../dashboard/components/ShareModal/utils';
|
||||
import { DashboardInteractions } from '../utils/interactions';
|
||||
@ -58,7 +59,11 @@ export class ShareModal extends SceneObjectBase<ShareModalState> implements Moda
|
||||
tabs.push(new ShareExportTab({ modalRef }));
|
||||
}
|
||||
|
||||
if (contextSrv.isSignedIn && config.snapshotEnabled && dashboard.canEditDashboard()) {
|
||||
if (
|
||||
contextSrv.isSignedIn &&
|
||||
config.snapshotEnabled &&
|
||||
contextSrv.hasPermission(AccessControlAction.SnapshotsCreate)
|
||||
) {
|
||||
tabs.push(new ShareSnapshotTab({ panelRef, dashboardRef: dashboard.getRef(), modalRef }));
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ import { isPublicDashboardsEnabled } from 'app/features/dashboard/components/Sha
|
||||
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
|
||||
import { DashboardInteractions } from 'app/features/dashboard-scene/utils/interactions';
|
||||
import { isPanelModelLibraryPanel } from 'app/features/library-panels/guard';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { ShareEmbed } from './ShareEmbed';
|
||||
import { ShareExport } from './ShareExport';
|
||||
@ -33,7 +34,11 @@ function getTabs(canEditDashboard: boolean, panel?: PanelModel, activeTab?: stri
|
||||
const linkLabel = t('share-modal.tab-title.link', 'Link');
|
||||
const tabs: ShareModalTabModel[] = [{ label: linkLabel, value: shareDashboardType.link, component: ShareLink }];
|
||||
|
||||
if (contextSrv.isSignedIn && config.snapshotEnabled && canEditDashboard) {
|
||||
if (
|
||||
contextSrv.isSignedIn &&
|
||||
config.snapshotEnabled &&
|
||||
contextSrv.hasPermission(AccessControlAction.SnapshotsCreate)
|
||||
) {
|
||||
const snapshotLabel = t('share-modal.tab-title.snapshot', 'Snapshot');
|
||||
tabs.push({ label: snapshotLabel, value: shareDashboardType.snapshot, component: ShareSnapshot });
|
||||
}
|
||||
|
@ -1,12 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { grantUserPermissions } from 'app/features/alerting/unified/mocks';
|
||||
import { Snapshot } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
import { SnapshotListTableRow } from './SnapshotListTableRow';
|
||||
|
||||
jest.mock('app/core/services/context_srv');
|
||||
const mockContextSrv = jest.mocked(contextSrv);
|
||||
const grantAllPermissions = () => {
|
||||
grantUserPermissions([AccessControlAction.SnapshotsDelete]);
|
||||
mockContextSrv.hasPermissionInMetadata.mockImplementation(() => true);
|
||||
mockContextSrv.hasPermission.mockImplementation(() => true);
|
||||
};
|
||||
const grantNoPermissions = () => {
|
||||
grantUserPermissions([]);
|
||||
mockContextSrv.hasPermissionInMetadata.mockImplementation(() => false);
|
||||
mockContextSrv.hasPermission.mockImplementation(() => false);
|
||||
};
|
||||
|
||||
describe('SnapshotListTableRow', () => {
|
||||
const mockOnRemove = jest.fn();
|
||||
const mockSnapshot = {
|
||||
key: 'test',
|
||||
name: 'Test Snapshot',
|
||||
@ -15,6 +30,8 @@ describe('SnapshotListTableRow', () => {
|
||||
};
|
||||
|
||||
it('renders correctly', () => {
|
||||
const mockOnRemove = jest.fn();
|
||||
grantAllPermissions();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
@ -29,6 +46,8 @@ describe('SnapshotListTableRow', () => {
|
||||
});
|
||||
|
||||
it('adds the correct href to the name, url and view buttons', () => {
|
||||
const mockOnRemove = jest.fn();
|
||||
grantAllPermissions();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
@ -46,6 +65,8 @@ describe('SnapshotListTableRow', () => {
|
||||
});
|
||||
|
||||
it('calls onRemove when delete button is clicked', async () => {
|
||||
const mockOnRemove = jest.fn();
|
||||
grantAllPermissions();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
@ -57,8 +78,26 @@ describe('SnapshotListTableRow', () => {
|
||||
expect(mockOnRemove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delete button should be disabled when no permissions', async () => {
|
||||
const mockOnRemove = jest.fn();
|
||||
grantNoPermissions();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<SnapshotListTableRow snapshot={mockSnapshot} onRemove={mockOnRemove} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
const deleteButton = screen.getByRole('button');
|
||||
expect(deleteButton).toHaveAttribute('aria-disabled', 'true');
|
||||
await userEvent.click(deleteButton);
|
||||
expect(mockOnRemove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('for an external snapshot', () => {
|
||||
let mockSnapshotWithExternal: Snapshot;
|
||||
const mockOnRemove = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockSnapshotWithExternal = {
|
||||
|
@ -3,8 +3,10 @@ import Skeleton from 'react-loading-skeleton';
|
||||
|
||||
import { Button, LinkButton, useStyles2 } from '@grafana/ui';
|
||||
import { SkeletonComponent, attachSkeleton } from '@grafana/ui/src/unstable';
|
||||
import { Trans } from 'app/core/internationalization';
|
||||
import { t, Trans } from 'app/core/internationalization';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { Snapshot } from 'app/features/dashboard/services/SnapshotSrv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
export interface Props {
|
||||
snapshot: Snapshot;
|
||||
@ -13,6 +15,10 @@ export interface Props {
|
||||
|
||||
const SnapshotListTableRowComponent = ({ snapshot, onRemove }: Props) => {
|
||||
const url = snapshot.externalUrl || snapshot.url;
|
||||
const hasDeletePermission = contextSrv.hasPermission(AccessControlAction.SnapshotsDelete);
|
||||
const deleteTooltip = hasDeletePermission
|
||||
? ''
|
||||
: t('snapshot.share.delete-permission-tooltip', "You don't have permission to delete snapshots");
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
@ -34,7 +40,14 @@ const SnapshotListTableRowComponent = ({ snapshot, onRemove }: Props) => {
|
||||
</LinkButton>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<Button variant="destructive" size="sm" icon="times" onClick={onRemove} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
icon="times"
|
||||
onClick={onRemove}
|
||||
disabled={!hasDeletePermission}
|
||||
tooltip={deleteTooltip}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
@ -437,6 +437,7 @@ export function getAppRoutes(): RouteDescriptor[] {
|
||||
},
|
||||
{
|
||||
path: '/dashboard/snapshots',
|
||||
roles: () => contextSrv.evaluatePermission([AccessControlAction.SnapshotsRead]),
|
||||
component: SafeDynamicImport(
|
||||
() => import(/* webpackChunkName: "SnapshotListPage" */ 'app/features/manage-dashboards/SnapshotListPage')
|
||||
),
|
||||
|
@ -77,6 +77,9 @@ export enum AccessControlAction {
|
||||
DashboardsPermissionsRead = 'dashboards.permissions:read',
|
||||
DashboardsPermissionsWrite = 'dashboards.permissions:write',
|
||||
DashboardsPublicWrite = 'dashboards.public:write',
|
||||
SnapshotsCreate = 'snapshots:create',
|
||||
SnapshotsDelete = 'snapshots:delete',
|
||||
SnapshotsRead = 'snapshots:read',
|
||||
|
||||
FoldersRead = 'folders:read',
|
||||
FoldersWrite = 'folders:write',
|
||||
|
@ -3092,6 +3092,7 @@
|
||||
"copy-link-button": "Copy link",
|
||||
"delete-button": "Delete snapshot",
|
||||
"delete-description": "Are you sure you want to delete this snapshot?",
|
||||
"delete-permission-tooltip": "You don't have permission to delete snapshots",
|
||||
"delete-title": "Delete snapshot",
|
||||
"deleted-alert": "Snapshot deleted. It could take an hour to be cleared from CDN caches.",
|
||||
"expiration-label": "Expires in",
|
||||
|
@ -3092,6 +3092,7 @@
|
||||
"copy-link-button": "Cőpy ľįʼnĸ",
|
||||
"delete-button": "Đęľęŧę şʼnäpşĥőŧ",
|
||||
"delete-description": "Åřę yőū şūřę yőū ŵäʼnŧ ŧő đęľęŧę ŧĥįş şʼnäpşĥőŧ?",
|
||||
"delete-permission-tooltip": "Ÿőū đőʼn'ŧ ĥävę pęřmįşşįőʼn ŧő đęľęŧę şʼnäpşĥőŧş",
|
||||
"delete-title": "Đęľęŧę şʼnäpşĥőŧ",
|
||||
"deleted-alert": "Ŝʼnäpşĥőŧ đęľęŧęđ. Ĩŧ čőūľđ ŧäĸę äʼn ĥőūř ŧő þę čľęäřęđ ƒřőm CĐŃ čäčĥęş.",
|
||||
"expiration-label": "Ēχpįřęş įʼn",
|
||||
|
Loading…
Reference in New Issue
Block a user