mirror of
https://github.com/grafana/grafana.git
synced 2025-02-11 08:05:43 -06:00
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:
parent
2ade8b56dd
commit
5a25ada3d0
@ -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')
|
||||
),
|
||||
|
@ -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),
|
||||
|
@ -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', () => {
|
||||
|
@ -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.'}
|
||||
|
@ -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;
|
||||
}
|
||||
};
|
@ -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} />
|
||||
|
@ -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}
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user