Snapshots: Add RBAC roles for creating and deleting (#96126)

This commit is contained in:
Ezequiel Victorero 2024-11-26 09:13:17 -03:00 committed by GitHub
parent 91d7517146
commit 5039725da6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 229 additions and 51 deletions

View File

@ -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. |

View File

@ -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 = *
```

View File

@ -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{

View File

@ -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 {

View File

@ -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

View File

@ -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
}

View File

@ -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))
}
}

View File

@ -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,

View File

@ -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 (

View File

@ -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",

View File

@ -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',

View File

@ -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) => {

View File

@ -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,
});

View File

@ -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({

View File

@ -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>
);
};

View File

@ -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 }));
}

View File

@ -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 });
}

View File

@ -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 = {

View File

@ -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>
);

View File

@ -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')
),

View File

@ -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',

View File

@ -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",

View File

@ -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",