diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 3d7cf8a7e1f..b7a90c80d4a 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -4,6 +4,8 @@ import { SafeDynamicImport } from 'app/core/components/DynamicImports/SafeDynami import { config } from 'app/core/config'; import { RouteDescriptor } from 'app/core/navigation/types'; import { uniq } from 'lodash'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; const commonRoutes: RouteDescriptor[] = [ { @@ -114,20 +116,21 @@ const unifiedRoutes: RouteDescriptor[] = [ }, { path: '/alerting/silences', + roles: () => contextSrv.evaluatePermission(() => [], [AccessControlAction.AlertingInstanceRead]), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') ), }, { path: '/alerting/silence/new', - roles: () => ['Editor', 'Admin'], + roles: () => contextSrv.evaluatePermission(() => ['Editor', 'Admin'], [AccessControlAction.AlertingInstanceCreate]), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') ), }, { path: '/alerting/silence/:id/edit', - roles: () => ['Editor', 'Admin'], + roles: () => contextSrv.evaluatePermission(() => ['Editor', 'Admin'], [AccessControlAction.AlertingInstanceUpdate]), component: SafeDynamicImport( () => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences') ), diff --git a/public/app/features/alerting/unified/AlertGroups.test.tsx b/public/app/features/alerting/unified/AlertGroups.test.tsx index 890aa4aef50..ec9704c7ede 100644 --- a/public/app/features/alerting/unified/AlertGroups.test.tsx +++ b/public/app/features/alerting/unified/AlertGroups.test.tsx @@ -12,7 +12,12 @@ import { DataSourceType } from './utils/datasource'; import userEvent from '@testing-library/user-event'; jest.mock('./api/alertmanager'); - +jest.mock('app/core/services/context_srv', () => ({ + contextSrv: { + isEditor: true, + hasAccess: () => true, + }, +})); const mocks = { api: { fetchAlertGroups: jest.mocked(fetchAlertGroups), diff --git a/public/app/features/alerting/unified/Silences.test.tsx b/public/app/features/alerting/unified/Silences.test.tsx index 689f620d1cf..13d91f7449e 100644 --- a/public/app/features/alerting/unified/Silences.test.tsx +++ b/public/app/features/alerting/unified/Silences.test.tsx @@ -13,8 +13,11 @@ import { parseMatchers } from './utils/alertmanager'; import { AlertState, MatcherOperator } from 'app/plugins/datasource/alertmanager/types'; import { byLabelText, byPlaceholderText, byRole, byTestId, byText } from 'testing-library-selector'; import userEvent from '@testing-library/user-event'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; jest.mock('./api/alertmanager'); +jest.mock('app/core/services/context_srv'); const TEST_TIMEOUT = 60000; @@ -24,6 +27,7 @@ const mocks = { fetchAlerts: jest.mocked(fetchAlerts), createOrUpdateSilence: jest.mocked(createOrUpdateSilence), }, + contextSrv: jest.mocked(contextSrv), }; const renderSilences = (location = '/alerting/silences/') => { @@ -50,6 +54,7 @@ const ui = { silencesTable: byTestId('dynamic-table'), silenceRow: byTestId('row'), silencedAlertCell: byTestId('alerts'), + addSilenceButton: byRole('button', { name: /new silence/i }), queryBar: byPlaceholderText('Search'), editor: { timeRange: byLabelText('Timepicker', { exact: false }), @@ -89,6 +94,20 @@ const resetMocks = () => { }); mocks.api.createOrUpdateSilence.mockResolvedValue(mockSilence()); + + mocks.contextSrv.evaluatePermission.mockImplementation(() => []); + mocks.contextSrv.hasPermission.mockImplementation((action) => { + const permissions = [ + AccessControlAction.AlertingInstanceRead, + AccessControlAction.AlertingInstanceCreate, + AccessControlAction.AlertingInstanceUpdate, + AccessControlAction.AlertingInstancesExternalRead, + AccessControlAction.AlertingInstancesExternalWrite, + ]; + return permissions.includes(action as AccessControlAction); + }); + + mocks.contextSrv.hasAccess.mockImplementation(() => true); }; describe('Silences', () => { @@ -158,6 +177,28 @@ describe('Silences', () => { }, TEST_TIMEOUT ); + + it('shows creating a silence button for users with access', async () => { + renderSilences(); + + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + expect(ui.addSilenceButton.get()).toBeInTheDocument(); + }); + + it('hides actions for creating a silence for users without access', async () => { + mocks.contextSrv.hasAccess.mockImplementation((action) => { + const permissions = [AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstancesExternalRead]; + return permissions.includes(action as AccessControlAction); + }); + + renderSilences(); + await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled()); + await waitFor(() => expect(mocks.api.fetchAlerts).toHaveBeenCalled()); + + expect(ui.addSilenceButton.query()).not.toBeInTheDocument(); + }); }); describe('Silence edit', () => { diff --git a/public/app/features/alerting/unified/Silences.tsx b/public/app/features/alerting/unified/Silences.tsx index 3cb5544d1d4..475e919c3f8 100644 --- a/public/app/features/alerting/unified/Silences.tsx +++ b/public/app/features/alerting/unified/Silences.tsx @@ -13,6 +13,8 @@ import { AsyncRequestState, initialAsyncRequestState } from './utils/redux'; import SilencesEditor from './components/silences/SilencesEditor'; import { AlertManagerPicker } from './components/AlertManagerPicker'; import { Silence } from 'app/plugins/datasource/alertmanager/types'; +import { AccessControlAction } from 'app/types'; +import { Authorize } from './components/Authorize'; const Silences: FC = () => { const [alertManagerSourceName, setAlertManagerSourceName] = useAlertManagerSourceName(); @@ -51,7 +53,9 @@ const Silences: FC = () => { return ( - + + + {error && !loading && ( {error.message || 'Unknown error.'} diff --git a/public/app/features/alerting/unified/components/Authorize.tsx b/public/app/features/alerting/unified/components/Authorize.tsx new file mode 100644 index 00000000000..d283a0454e8 --- /dev/null +++ b/public/app/features/alerting/unified/components/Authorize.tsx @@ -0,0 +1,16 @@ +import React, { FC } from 'react'; +import { AccessControlAction } from 'app/types'; +import { contextSrv } from 'app/core/services/context_srv'; + +type Props = { + actions: AccessControlAction[]; + fallback?: boolean; +}; + +export const Authorize: FC = ({ actions, children, fallback = true }) => { + if (actions.some((action) => contextSrv.hasAccess(action, fallback))) { + return <>{children}; + } else { + return null; + } +}; 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 77e83600c5b..e7867add2ba 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx @@ -1,10 +1,14 @@ import { css } from '@emotion/css'; 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 { isGrafanaRulesSource } from '../../utils/datasource'; import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc'; import { AnnotationDetailsField } from '../AnnotationDetailsField'; +import { Authorize } from '../Authorize'; interface AmNotificationsAlertDetailsProps { alertManagerSourceName: string; @@ -13,37 +17,51 @@ interface AmNotificationsAlertDetailsProps { export const AlertDetails: FC = ({ alert, alertManagerSourceName }) => { const styles = useStyles2(getStyles); + const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName); return ( <>
- {alert.status.state === AlertState.Suppressed && ( - - Manage silences - - )} - {alert.status.state === AlertState.Active && ( - - Silence - - )} - {alert.generatorURL && ( - - See source - - )} + + {alert.status.state === AlertState.Suppressed && ( + + Manage silences + + )} + {alert.status.state === AlertState.Active && ( + + Silence + + )} + + + {alert.generatorURL && ( + + See source + + )} +
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => ( diff --git a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx index aeca2d94e40..3e6640a24ef 100644 --- a/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx +++ b/public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx @@ -18,6 +18,7 @@ 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; @@ -138,7 +139,7 @@ export const RuleDetailsActionButtons: FC = ({ rule, rulesSource }) => { } } - if (alertmanagerSourceName) { + if (alertmanagerSourceName && contextSrv.hasAccess(AccessControlAction.AlertingInstanceCreate, contextSrv.isEditor)) { leftButtons.push( = ({ silences, alertManagerAlerts, alertManagerSo const styles = useStyles2(getStyles); const [queryParams] = useQueryParams(); const filteredSilences = useFilteredSilences(silences); + const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName); const { silenceState } = getSilenceFiltersFromUrlParams(queryParams); @@ -61,7 +65,14 @@ const SilencesTable: FC = ({ silences, alertManagerAlerts, alertManagerSo {!!silences.length && ( <> - {contextSrv.isEditor && ( +
- )} +
{!!items.length ? ( <> ({ function useColumns(alertManagerSourceName: string) { const dispatch = useDispatch(); const styles = useStyles2(getStyles); + const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName); return useMemo((): SilenceTableColumnProps[] => { const handleExpireSilenceClick = (id: string) => { dispatch(expireSilenceAction(alertManagerSourceName, id)); }; - const showActions = contextSrv.isEditor; + const showActions = contextSrv.hasAccess( + isExternalAM ? AccessControlAction.AlertingInstancesExternalWrite : AccessControlAction.AlertingInstanceUpdate, + contextSrv.isEditor + ); const columns: SilenceTableColumnProps[] = [ { id: 'state', @@ -247,7 +262,7 @@ function useColumns(alertManagerSourceName: string) { }); } return columns; - }, [alertManagerSourceName, dispatch, styles]); + }, [alertManagerSourceName, dispatch, styles, isExternalAM]); } export default SilencesTable; diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index 7c90bce0968..75ebf239edd 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -80,6 +80,35 @@ export enum AccessControlAction { FoldersCreate = 'folders:create', FoldersPermissionsRead = 'folders.permissions:read', FoldersPermissionsWrite = 'folders.permissions:read', + + // Alerting rules + AlertingRuleCreate = 'alert.rules:create', + AlertingRuleRead = 'alert.rules:read', + AlertingRuleUpdate = 'alert.rules:update', + AlertingRuleDelete = 'alert.rules:delete', + + // Alerting instances (+silences) + AlertingInstanceCreate = 'alert.instances:create', + AlertingInstanceUpdate = 'alert.instances:update', + AlertingInstanceRead = 'alert.instances:read', + + // Alerting Notification policies + AlertingNotificationsCreate = 'alert.notifications:create', + AlertingNotificationsRead = 'alert.notifications:read', + AlertingNotificationsUpdate = 'alert.notifications:update', + AlertingNotificationsDelete = 'alert.notifications:delete', + + // External alerting rule actions. + AlertingRuleExternalWrite = 'alert.rules.external:write', + AlertingRuleExternalRead = 'alert.rules.external:read', + + // External alerting instances actions. + AlertingInstancesExternalWrite = 'alert.instances.external:write', + AlertingInstancesExternalRead = 'alert.instances.external:read', + + // External alerting notifications actions. + AlertingNotificationsExternalWrite = 'alert.notifications.external:write', + AlertingNotificationsExternalRead = 'alert.notifications.external:read', } export interface Role {