diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 1e64912cc57..ea93183aa40 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -6,6 +6,7 @@ import { RouteDescriptor } from 'app/core/navigation/types'; import { uniq } from 'lodash'; import { contextSrv } from 'app/core/core'; import { AccessControlAction } from 'app/types'; +import { OrgRole } from '@grafana/data'; import { evaluateAccess } from './unified/utils/access-control'; const commonRoutes: RouteDescriptor[] = [ @@ -90,6 +91,10 @@ const unifiedRoutes: RouteDescriptor[] = [ ...commonRoutes, { path: '/alerting/list', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertRuleListIndex" */ 'app/features/alerting/unified/RuleList') ), @@ -214,6 +219,10 @@ const unifiedRoutes: RouteDescriptor[] = [ }, { path: '/alerting/groups/', + roles: evaluateAccess( + [AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstancesExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertGroups" */ 'app/features/alerting/unified/AlertGroups') ), @@ -221,6 +230,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/new', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleCreate, AccessControlAction.AlertingRuleExternalWrite], + [OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') ), @@ -228,6 +241,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/:id/edit', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleExternalWrite], + [OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRuleForm"*/ 'app/features/alerting/unified/RuleEditor') ), @@ -235,6 +252,10 @@ const unifiedRoutes: RouteDescriptor[] = [ { path: '/alerting/:sourceName/:id/view', pageClass: 'page-alerting', + roles: evaluateAccess( + [AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingRuleExternalRead], + [OrgRole.Viewer, OrgRole.Editor, OrgRole.Admin] + ), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertingRule"*/ 'app/features/alerting/unified/RuleViewer') ), diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx index ec9704c7ede..765af678612 100644 --- a/public/app/features/alerting/unified/AlertGroups.test.tsx +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -16,6 +16,7 @@ jest.mock('app/core/services/context_srv', () => ({ contextSrv: { isEditor: true, hasAccess: () => true, + hasPermission: () => true, }, })); const mocks = { diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx index 53818045141..878620f5fd5 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx @@ -3,11 +3,13 @@ import { GrafanaTheme2 } from '@grafana/data'; import { LinkButton, useStyles2 } from '@grafana/ui'; import { contextSrv } from 'app/core/services/context_srv'; import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types'; +import { AccessControlAction } from 'app/types'; import React, { FC } from 'react'; +import { getInstancesPermissions } from '../../utils/access-control'; +import { isGrafanaRulesSource } from '../../utils/datasource'; import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc'; import { AnnotationDetailsField } from '../AnnotationDetailsField'; import { Authorize } from '../Authorize'; -import { getInstancesPermissions } from '../../utils/access-control'; interface AmNotificationsAlertDetailsProps { alertManagerSourceName: string; @@ -16,11 +18,19 @@ interface AmNotificationsAlertDetailsProps { export const AlertDetails: FC = ({ alert, alertManagerSourceName }) => { const styles = useStyles2(getStyles); - const permissions = getInstancesPermissions(alertManagerSourceName); + const instancePermissions = getInstancesPermissions(alertManagerSourceName); + + // For Grafana Managed alerts the Generator URL redirects to the alert rule edit page, so update permission is required + // For external alert manager the Generator URL redirects to an external service which we don't control + const isGrafanaSource = isGrafanaRulesSource(alertManagerSourceName); + const isSeeSourceButtonEnabled = isGrafanaSource + ? contextSrv.hasPermission(AccessControlAction.AlertingRuleUpdate) + : true; + return ( <>
- + {alert.status.state === AlertState.Suppressed && ( = ({ alert, aler )} - - {alert.generatorURL && ( - - See source - - )} - + {isSeeSourceButtonEnabled && alert.generatorURL && ( + + See source + + )}
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => ( diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx index 26eb3ed2fd0..5d419399fcc 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewerVisualization.tsx @@ -9,6 +9,8 @@ import { AlertQuery } from 'app/types/unified-alerting-dto'; import AutoSizer from 'react-virtualized-auto-sizer'; import { PanelPluginsButtonGroup, SupportedPanelPlugins } from '../PanelPluginsButtonGroup'; import { TABLE, TIMESERIES } from '../../utils/constants'; +import { Authorize } from '../Authorize'; +import { AccessControlAction } from 'app/types'; type RuleViewerVisualizationProps = { data?: PanelData; @@ -91,20 +93,22 @@ export function RuleViewerVisualization(props: RuleViewerVisualizationProps): JS /> ) : null} - {!isExpressionQuery(query.model) && ( - <> -
- - View in Explore - - - )} + + {!isExpressionQuery(query.model) && ( + <> +
+ + View in Explore + + + )} +
{ + mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); + + describe('Grafana rules action buttons', () => { + const grafanaRule = getGrafanaRule({ name: 'Grafana' }); + it('Should not render Edit button for users without the update permission', () => { + // Arrange + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + }); + + it('Should not render Delete button for users without the delete permission', () => { + // Arrange + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + }); + + it('Should render Edit button for users with the update permission', () => { + // Arrange + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingRuleUpdate); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.edit.query()).toBeInTheDocument(); + }); + + it('Should render Delete button for users with the delete permission', () => { + // Arrange + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingRuleDelete); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.delete.query()).toBeInTheDocument(); + }); + + it('Should not render Silence button for users wihout the instance create permission', () => { + // Arrange + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.silence.query()).not.toBeInTheDocument(); + }); + + it('Should render Silence button for users with the instance create permissions', () => { + // Arrange + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingInstanceCreate); + + // Act + renderRuleDetails(grafanaRule); + + // Assert + expect(ui.actionButtons.silence.query()).toBeInTheDocument(); + }); + }); + + describe('Cloud rules action buttons', () => { + const cloudRule = getCloudRule({ name: 'Cloud' }); + it('Should not render Edit button for users without the update permission', () => { + // Arrange + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); + }); + + it('Should not render Delete button for users without the delete permission', () => { + // Arrange + jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.delete.query()).not.toBeInTheDocument(); + }); + + it('Should render Edit button for users with the update permission', () => { + // Arrange + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalWrite); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.edit.query()).toBeInTheDocument(); + }); + + it('Should render Delete button for users with the delete permission', () => { + // Arrange + jest + .spyOn(contextSrv, 'hasPermission') + .mockImplementation((action) => action === AccessControlAction.AlertingRuleExternalWrite); + + // Act + renderRuleDetails(cloudRule); + + // Assert + expect(ui.actionButtons.delete.query()).toBeInTheDocument(); + }); + }); +}); + +function renderRuleDetails(rule: CombinedRule) { + const store = configureStore(); + + render( + + + + + + ); +} + +function getGrafanaRule(override?: Partial) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Grafana', + rulesSource: 'grafana', + }, + ...override, + }); +} + +function getCloudRule(override?: Partial) { + return mockCombinedRule({ + namespace: { + groups: [], + name: 'Cortex', + rulesSource: mockDataSource(), + }, + promRule: mockPromAlertingRule(), + rulerRule: mockRulerAlertingRule(), + ...override, + }); +} diff --git a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx index af21b4d4ab2..db8a5fc8e8c 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetails.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetails.tsx @@ -1,15 +1,15 @@ -import { CombinedRule } from 'app/types/unified-alerting'; -import React, { FC } from 'react'; -import { useStyles2 } from '@grafana/ui'; import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; +import { CombinedRule } from 'app/types/unified-alerting'; +import React, { FC } from 'react'; import { AlertLabels } from '../AlertLabels'; import { DetailsField } from '../DetailsField'; import { RuleDetailsActionButtons } from './RuleDetailsActionButtons'; -import { RuleDetailsDataSources } from './RuleDetailsDataSources'; -import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances'; -import { RuleDetailsExpression } from './RuleDetailsExpression'; import { RuleDetailsAnnotations } from './RuleDetailsAnnotations'; +import { RuleDetailsDataSources } from './RuleDetailsDataSources'; +import { RuleDetailsExpression } from './RuleDetailsExpression'; +import { RuleDetailsMatchingInstances } from './RuleDetailsMatchingInstances'; interface Props { rule: CombinedRule; diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index 3e6640a24ef..7f26d28ba87 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -1,24 +1,25 @@ -import React, { FC, Fragment, useState } from 'react'; -import { useDispatch } from 'react-redux'; -import { useLocation } from 'react-router-dom'; import { css } from '@emotion/css'; import { GrafanaTheme2, urlUtil } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { Button, ConfirmModal, ClipboardButton, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/services/context_srv'; +import { Button, ClipboardButton, ConfirmModal, HorizontalGroup, LinkButton, useStyles2 } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; +import { contextSrv } from 'app/core/services/context_srv'; +import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control'; +import { AccessControlAction } from 'app/types'; +import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; +import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; +import React, { FC, Fragment, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useLocation } from 'react-router-dom'; import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; +import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; +import { deleteRuleAction } from '../../state/actions'; +import { getAlertmanagerByUid } from '../../utils/alertmanager'; import { Annotation } from '../../utils/constants'; import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; import { createExploreLink, createViewLink, makeRuleBasedSilenceLink } from '../../utils/misc'; import * as ruleId from '../../utils/rule-id'; -import { deleteRuleAction } from '../../state/actions'; -import { CombinedRule, RulesSource } from 'app/types/unified-alerting'; -import { getAlertmanagerByUid } from '../../utils/alertmanager'; -import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; -import { RulerGrafanaRuleDTO, RulerRuleDTO } from 'app/types/unified-alerting-dto'; import { isFederatedRuleGroup } from '../../utils/rules'; -import { AccessControlAction } from 'app/types'; interface Props { rule: CombinedRule; @@ -38,12 +39,18 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { const alertmanagerSourceName = isGrafanaRulesSource(rulesSource) ? rulesSource : getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name; + const rulesSourceName = getRulesSourceName(rulesSource); + + const rulesPermissions = getRulesPermissions(rulesSourceName); + const hasEditPermission = contextSrv.hasPermission(rulesPermissions.update); + const hasDeletePermission = contextSrv.hasPermission(rulesPermissions.delete); + const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); const leftButtons: JSX.Element[] = []; const rightButtons: JSX.Element[] = []; const isFederated = isFederatedRuleGroup(group); - const { isEditable } = useIsRuleEditable(getRulesSourceName(rulesSource), rulerRule); + const { isEditable } = useIsRuleEditable(rulesSourceName, rulerRule); const returnTo = location.pathname + location.search; const isViewMode = inViewMode(location.pathname); @@ -74,7 +81,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { // explore does not support grafana rule queries atm // neither do "federated rules" - if (isCloudRulesSource(rulesSource) && contextSrv.isEditor && !isFederated) { + if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) { leftButtons.push( = ({ rule, rulesSource }) => { ); } + // TODO Maybe there is a way to unify isEditable with FGAC permissions if (isEditable && rulerRule && !isFederated) { const sourceName = getRulesSourceName(rulesSource); const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); @@ -210,22 +218,28 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { ); } - rightButtons.push( - - Edit - , - - ); + if (hasEditPermission) { + rightButtons.push( + + Edit + + ); + } + if (hasDeletePermission) { + rightButtons.push( + + ); + } } if (leftButtons.length || rightButtons.length) { return ( diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index b534a3e8592..ef668398c0c 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -1,7 +1,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { Badge, ConfirmModal, HorizontalGroup, Icon, Spinner, Tooltip, useStyles2 } from '@grafana/ui'; +import { contextSrv } from 'app/core/services/context_srv'; import kbn from 'app/core/utils/kbn'; +import { AccessControlAction } from 'app/types'; import { CombinedRuleGroup, CombinedRuleNamespace } from 'app/types/unified-alerting'; import pluralize from 'pluralize'; import React, { FC, useEffect, useState } from 'react'; @@ -32,6 +34,8 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } const [isDeletingGroup, setIsDeletingGroup] = useState(false); const [isCollapsed, setIsCollapsed] = useState(!expandAll); + const canEditCloudRules = contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalWrite); + useEffect(() => { setIsCollapsed(!expandAll); }, [expandAll]); @@ -88,7 +92,7 @@ export const RulesGroup: FC = React.memo(({ group, namespace, expandAll } ); } } - } else if (hasRuler(rulesSource)) { + } else if (canEditCloudRules && hasRuler(rulesSource)) { if (!isFederated) { actionIcons.push(