Alerting: FGAC for alert rules page (#47418)

* Apply FGAC on the alert rules list page

* Add tests for edit, delete and silence buttons

* Unify access-control helpers

* Fix import

* Add route permissions for alert groups, unify access control helpers

* Improve buttons with data source explore permission

* Fix test
This commit is contained in:
Konrad Lalik 2022-04-08 13:34:02 +02:00 committed by GitHub
parent f1a1070d41
commit 87383b1c8b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 351 additions and 78 deletions

View File

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

View File

@ -16,6 +16,7 @@ jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
isEditor: true,
hasAccess: () => true,
hasPermission: () => true,
},
}));
const mocks = {

View File

@ -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<AmNotificationsAlertDetailsProps> = ({ 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 (
<>
<div className={styles.actionsRow}>
<Authorize actions={[permissions.update, permissions.create]} fallback={contextSrv.isEditor}>
<Authorize actions={[instancePermissions.update, instancePermissions.create]} fallback={contextSrv.isEditor}>
{alert.status.state === AlertState.Suppressed && (
<LinkButton
href={`${makeAMLink(
@ -45,13 +55,11 @@ export const AlertDetails: FC<AmNotificationsAlertDetailsProps> = ({ alert, aler
</LinkButton>
)}
</Authorize>
<Authorize actions={[permissions.viewSource]}>
{alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source
</LinkButton>
)}
</Authorize>
{isSeeSourceButtonEnabled && alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source
</LinkButton>
)}
</div>
{Object.entries(alert.annotations).map(([annotationKey, annotationValue]) => (
<AnnotationDetailsField key={annotationKey} annotationKey={annotationKey} value={annotationValue} />

View File

@ -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}
<PanelPluginsButtonGroup onChange={setPanel} value={panel} size="md" />
{!isExpressionQuery(query.model) && (
<>
<div className={styles.spacing} />
<LinkButton
size="md"
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, query)}
>
View in Explore
</LinkButton>
</>
)}
<Authorize actions={[AccessControlAction.DataSourcesExplore]}>
{!isExpressionQuery(query.model) && (
<>
<div className={styles.spacing} />
<LinkButton
size="md"
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, query)}
>
View in Explore
</LinkButton>
</>
)}
</Authorize>
</div>
</div>
<PanelRenderer

View File

@ -0,0 +1,194 @@
import { render } from '@testing-library/react';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { CombinedRule } from 'app/types/unified-alerting';
import React from 'react';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { byRole } from 'testing-library-selector';
import { mockCombinedRule, mockDataSource, mockPromAlertingRule, mockRulerAlertingRule } from '../../mocks';
import { RuleDetails } from './RuleDetails';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
jest.mock('../../hooks/useIsRuleEditable');
const mocks = {
useIsRuleEditable: jest.mocked(useIsRuleEditable),
};
const ui = {
actionButtons: {
edit: byRole('link', { name: 'Edit' }),
delete: byRole('button', { name: 'Delete' }),
silence: byRole('link', { name: 'Silence' }),
},
};
jest.spyOn(contextSrv, 'accessControlEnabled').mockReturnValue(true);
describe('RuleDetails FGAC', () => {
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(
<Provider store={store}>
<MemoryRouter>
<RuleDetails rule={rule} />
</MemoryRouter>
</Provider>
);
}
function getGrafanaRule(override?: Partial<CombinedRule>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Grafana',
rulesSource: 'grafana',
},
...override,
});
}
function getCloudRule(override?: Partial<CombinedRule>) {
return mockCombinedRule({
namespace: {
groups: [],
name: 'Cortex',
rulesSource: mockDataSource(),
},
promRule: mockPromAlertingRule(),
rulerRule: mockRulerAlertingRule(),
...override,
});
}

View File

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

View File

@ -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<Props> = ({ 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<Props> = ({ 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(
<LinkButton
className={style.button}
@ -180,6 +187,7 @@ export const RuleDetailsActionButtons: FC<Props> = ({ 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<Props> = ({ rule, rulesSource }) => {
);
}
rightButtons.push(
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>,
<Button
className={style.button}
size="xs"
type="button"
key="delete"
variant="secondary"
icon="trash-alt"
onClick={() => setRuleToDelete(rule)}
>
Delete
</Button>
);
if (hasEditPermission) {
rightButtons.push(
<LinkButton className={style.button} size="xs" key="edit" variant="secondary" icon="pen" href={editURL}>
Edit
</LinkButton>
);
}
if (hasDeletePermission) {
rightButtons.push(
<Button
className={style.button}
size="xs"
type="button"
key="delete"
variant="secondary"
icon="trash-alt"
onClick={() => setRuleToDelete(rule)}
>
Delete
</Button>
);
}
}
if (leftButtons.length || rightButtons.length) {
return (

View File

@ -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<Props> = 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<Props> = React.memo(({ group, namespace, expandAll }
);
}
}
} else if (hasRuler(rulesSource)) {
} else if (canEditCloudRules && hasRuler(rulesSource)) {
if (!isFederated) {
actionIcons.push(
<ActionIcon

View File

@ -2,12 +2,14 @@ import { AccessControlAction } from 'app/types';
import { isGrafanaRulesSource } from './datasource';
import { contextSrv } from 'app/core/services/context_srv';
function getAMversion(alertManagerSourceName: string) {
type RulesSourceType = 'grafana' | 'external';
function getRulesSourceType(alertManagerSourceName: string): RulesSourceType {
return isGrafanaRulesSource(alertManagerSourceName) ? 'grafana' : 'external';
}
export function getInstancesPermissions(alertManagerSourceName: string) {
const amVersion = getAMversion(alertManagerSourceName);
export function getInstancesPermissions(rulesSourceName: string) {
const sourceType = getRulesSourceType(rulesSourceName);
const permissions = {
read: {
@ -26,23 +28,18 @@ export function getInstancesPermissions(alertManagerSourceName: string) {
grafana: AccessControlAction.AlertingInstanceUpdate,
external: AccessControlAction.AlertingInstancesExternalWrite,
},
viewSource: {
grafana: AccessControlAction.AlertingInstanceRead,
external: AccessControlAction.DataSourcesExplore,
},
};
return {
read: permissions.read[amVersion],
create: permissions.create[amVersion],
update: permissions.update[amVersion],
delete: permissions.delete[amVersion],
viewSource: permissions.viewSource[amVersion],
read: permissions.read[sourceType],
create: permissions.create[sourceType],
update: permissions.update[sourceType],
delete: permissions.delete[sourceType],
};
}
export function getNotificationsPermissions(alertManagerSourceName: string) {
const amVersion = getAMversion(alertManagerSourceName);
export function getNotificationsPermissions(rulesSourceName: string) {
const sourceType = getRulesSourceType(rulesSourceName);
const permissions = {
read: {
@ -64,10 +61,40 @@ export function getNotificationsPermissions(alertManagerSourceName: string) {
};
return {
read: permissions.read[amVersion],
create: permissions.create[amVersion],
update: permissions.update[amVersion],
delete: permissions.delete[amVersion],
read: permissions.read[sourceType],
create: permissions.create[sourceType],
update: permissions.update[sourceType],
delete: permissions.delete[sourceType],
};
}
export function getRulesPermissions(rulesSourceName: string) {
const sourceType = getRulesSourceType(rulesSourceName);
const permissions = {
read: {
grafana: AccessControlAction.AlertingRuleRead,
external: AccessControlAction.AlertingRuleExternalRead,
},
create: {
grafana: AccessControlAction.AlertingRuleCreate,
external: AccessControlAction.AlertingRuleExternalWrite,
},
update: {
grafana: AccessControlAction.AlertingRuleUpdate,
external: AccessControlAction.AlertingRuleExternalWrite,
},
delete: {
grafana: AccessControlAction.AlertingRuleDelete,
external: AccessControlAction.AlertingRuleExternalWrite,
},
};
return {
read: permissions.read[sourceType],
create: permissions.create[sourceType],
update: permissions.update[sourceType],
delete: permissions.delete[sourceType],
};
}