Alerting: Add FGAC for Silences (#46479)

* add FGAC actions for silences table

* redirect users without permissions

* hide silence button in rules list

* add permissions checks to routes

* add read action for silences page

* add permissions checks to navigation

* add additional access checks for rule viewing

* create authorize component

* add tests for silences

* hide alerting nav for users without access

* nolint: gocyclo

* add permission check to alert details

* add check for external instances

* remove unecessary new lines

* use correct actions for alert details

* fix failing tests

Co-authored-by: Yuriy Tseretyan <yuriy.tseretyan@grafana.com>
This commit is contained in:
Nathan Rodman 2022-03-21 16:54:37 -07:00 committed by GitHub
parent 2ade8b56dd
commit 5a25ada3d0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 169 additions and 37 deletions

View File

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

View File

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

View File

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

View File

@ -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 (
<AlertingPageWrapper pageId="silences">
<AlertManagerPicker disabled={!isRoot} current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
<Authorize actions={[AccessControlAction.AlertingInstancesExternalRead]}>
<AlertManagerPicker disabled={!isRoot} current={alertManagerSourceName} onChange={setAlertManagerSourceName} />
</Authorize>
{error && !loading && (
<Alert severity="error" title="Error loading silences">
{error.message || 'Unknown error.'}

View File

@ -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<Props> = ({ actions, children, fallback = true }) => {
if (actions.some((action) => contextSrv.hasAccess(action, fallback))) {
return <>{children}</>;
} else {
return null;
}
};

View File

@ -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<AmNotificationsAlertDetailsProps> = ({ alert, alertManagerSourceName }) => {
const styles = useStyles2(getStyles);
const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
return (
<>
<div className={styles.actionsRow}>
{alert.status.state === AlertState.Suppressed && (
<LinkButton
href={`${makeAMLink(
'/alerting/silences',
alertManagerSourceName
)}&silenceIds=${alert.status.silencedBy.join(',')}`}
className={styles.button}
icon={'bell'}
size={'sm'}
>
Manage silences
</LinkButton>
)}
{alert.status.state === AlertState.Active && (
<LinkButton
href={makeLabelBasedSilenceLink(alertManagerSourceName, alert.labels)}
className={styles.button}
icon={'bell-slash'}
size={'sm'}
>
Silence
</LinkButton>
)}
{alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source
</LinkButton>
)}
<Authorize
actions={
isExternalAM
? [AccessControlAction.AlertingInstancesExternalWrite]
: [AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingInstanceUpdate]
}
fallback={contextSrv.isEditor}
>
{alert.status.state === AlertState.Suppressed && (
<LinkButton
href={`${makeAMLink(
'/alerting/silences',
alertManagerSourceName
)}&silenceIds=${alert.status.silencedBy.join(',')}`}
className={styles.button}
icon={'bell'}
size={'sm'}
>
Manage silences
</LinkButton>
)}
{alert.status.state === AlertState.Active && (
<LinkButton
href={makeLabelBasedSilenceLink(alertManagerSourceName, alert.labels)}
className={styles.button}
icon={'bell-slash'}
size={'sm'}
>
Silence
</LinkButton>
)}
</Authorize>
<Authorize
actions={isExternalAM ? [AccessControlAction.DataSourcesExplore] : [AccessControlAction.AlertingInstanceRead]}
>
{alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source
</LinkButton>
)}
</Authorize>
</div>
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => (
<AnnotationDetailsField key={annotationKey} annotationKey={annotationKey} value={annotationValue} />

View File

@ -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<Props> = ({ rule, rulesSource }) => {
}
}
if (alertmanagerSourceName) {
if (alertmanagerSourceName && contextSrv.hasAccess(AccessControlAction.AlertingInstanceCreate, contextSrv.isEditor)) {
leftButtons.push(
<LinkButton
className={style.button}

View File

@ -18,6 +18,9 @@ import { useDispatch } from 'react-redux';
import { expireSilenceAction } from '../../state/actions';
import { SilenceDetails } from './SilenceDetails';
import { Stack } from '@grafana/experimental';
import { AccessControlAction } from '../../../../../types';
import { Authorize } from '../Authorize';
import { isGrafanaRulesSource } from '../../utils/datasource';
export interface SilenceTableItem extends Silence {
silencedAlerts: AlertmanagerAlert[];
@ -35,6 +38,7 @@ const SilencesTable: FC<Props> = ({ 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<Props> = ({ silences, alertManagerAlerts, alertManagerSo
{!!silences.length && (
<>
<SilencesFilter />
{contextSrv.isEditor && (
<Authorize
actions={
isExternalAM
? [AccessControlAction.AlertingInstancesExternalWrite]
: [AccessControlAction.AlertingInstanceCreate]
}
fallback={contextSrv.isEditor}
>
<div className={styles.topButtonContainer}>
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
<Button className={styles.addNewSilence} icon="plus">
@ -69,7 +80,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
</Button>
</Link>
</div>
)}
</Authorize>
{!!items.length ? (
<>
<DynamicTable
@ -167,11 +178,15 @@ const getStyles = (theme: GrafanaTheme2) => ({
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;

View File

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