Alerting: useAbility hook for alert rules (#78231)

pull/78724/head
Gilles De Mey 2 years ago committed by GitHub
parent add096ac8c
commit 7dbbdc16a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 65
      public/app/features/alerting/unified/MoreActionsRuleButtons.tsx
  2. 2
      public/app/features/alerting/unified/RuleList.test.tsx
  3. 18
      public/app/features/alerting/unified/components/Authorize.tsx
  4. 53
      public/app/features/alerting/unified/components/rule-viewer/RuleViewer.v1.test.tsx
  5. 106
      public/app/features/alerting/unified/components/rules/RuleActionsButtons.tsx
  6. 58
      public/app/features/alerting/unified/components/rules/RuleDetailsActionButtons.tsx
  7. 6
      public/app/features/alerting/unified/components/rules/RuleListGroupView.tsx
  8. 53
      public/app/features/alerting/unified/components/rules/RulesTable.test.tsx
  9. 44
      public/app/features/alerting/unified/hooks/useAbilities.test.tsx
  10. 259
      public/app/features/alerting/unified/hooks/useAbilities.ts
  11. 4
      public/app/features/alerting/unified/hooks/useIsRuleEditable.ts
  12. 2
      public/app/features/alerting/unified/mocks.ts

@ -1,3 +1,4 @@
import { isEmpty } from 'lodash';
import React from 'react'; import React from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useToggle } from 'react-use'; import { useToggle } from 'react-use';
@ -7,35 +8,41 @@ import { Button, Dropdown, Icon, LinkButton, Menu, MenuItem } from '@grafana/ui'
import { logInfo, LogMessages } from './Analytics'; import { logInfo, LogMessages } from './Analytics';
import { GrafanaRulesExporter } from './components/export/GrafanaRulesExporter'; import { GrafanaRulesExporter } from './components/export/GrafanaRulesExporter';
import { AlertSourceAction, useAlertSourceAbility } from './hooks/useAbilities'; import { AlertingAction, useAlertingAbility } from './hooks/useAbilities';
interface Props {} interface Props {}
export function MoreActionsRuleButtons({}: Props) { export function MoreActionsRuleButtons({}: Props) {
const [_, viewRuleAllowed] = useAlertSourceAbility(AlertSourceAction.ViewAlertRule); const [createRuleSupported, createRuleAllowed] = useAlertingAbility(AlertingAction.CreateAlertRule);
const [createRuleSupported, createRuleAllowed] = useAlertSourceAbility(AlertSourceAction.CreateAlertRule); const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertingAbility(AlertingAction.CreateExternalAlertRule);
const [createCloudRuleSupported, createCloudRuleAllowed] = useAlertSourceAbility( const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
AlertSourceAction.CreateExternalAlertRule
); const location = useLocation();
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const canCreateGrafanaRules = createRuleSupported && createRuleAllowed; const canCreateGrafanaRules = createRuleSupported && createRuleAllowed;
const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed; const canCreateCloudRules = createCloudRuleSupported && createCloudRuleAllowed;
const canExportRules = exportRulesSupported && exportRulesAllowed;
const location = useLocation(); const menuItems: JSX.Element[] = [];
const [showExportDrawer, toggleShowExportDrawer] = useToggle(false);
const newMenu = ( if (canCreateGrafanaRules || canCreateCloudRules) {
<Menu> menuItems.push(
{(canCreateGrafanaRules || canCreateCloudRules) && ( <MenuItem
<MenuItem label="New recording rule"
url={urlUtil.renderUrl(`alerting/new/recording`, { key="new-recording-rule"
returnTo: location.pathname + location.search, url={urlUtil.renderUrl(`alerting/new/recording`, {
})} returnTo: location.pathname + location.search,
label="New recording rule" })}
/> />
)} );
{viewRuleAllowed && <MenuItem onClick={toggleShowExportDrawer} label="Export all Grafana-managed rules" />} }
</Menu>
); if (canExportRules) {
menuItems.push(
<MenuItem label="Export all Grafana-managed rules" key="export-all-rules" onClick={toggleShowExportDrawer} />
);
}
return ( return (
<> <>
@ -49,13 +56,15 @@ export function MoreActionsRuleButtons({}: Props) {
</LinkButton> </LinkButton>
)} )}
<Dropdown overlay={newMenu}> {!isEmpty(menuItems) && (
<Button variant="secondary"> <Dropdown overlay={<Menu>{menuItems}</Menu>}>
More <Button variant="secondary">
<Icon name="angle-down" /> More
</Button> <Icon name="angle-down" />
</Dropdown> </Button>
{showExportDrawer && <GrafanaRulesExporter onClose={toggleShowExportDrawer} />} </Dropdown>
)}
{canExportRules && showExportDrawer && <GrafanaRulesExporter onClose={toggleShowExportDrawer} />}
</> </>
); );
} }

@ -704,7 +704,7 @@ describe('RuleList', () => {
describe('RBAC Enabled', () => { describe('RBAC Enabled', () => {
describe('Export button', () => { describe('Export button', () => {
it('Export button should be visible when the user has alert read permissions', async () => { it('Export button should be visible when the user has alert read permissions', async () => {
grantUserPermissions([AccessControlAction.AlertingRuleRead, AccessControlAction.FoldersRead]); grantUserPermissions([AccessControlAction.AlertingRuleRead]);
mocks.getAllDataSourcesMock.mockReturnValue([]); mocks.getAllDataSourcesMock.mockReturnValue([]);
setDataSourceSrv(new MockDataSourceSrv({})); setDataSourceSrv(new MockDataSourceSrv({}));

@ -4,19 +4,19 @@ import React, { PropsWithChildren } from 'react';
import { import {
Abilities, Abilities,
Action, Action,
AlertingAction,
AlertmanagerAction, AlertmanagerAction,
AlertSourceAction, useAlertingAbilities,
useAlertSourceAbilities,
useAllAlertmanagerAbilities, useAllAlertmanagerAbilities,
} from '../hooks/useAbilities'; } from '../hooks/useAbilities';
interface AuthorizeProps extends PropsWithChildren { interface AuthorizeProps extends PropsWithChildren {
actions: AlertmanagerAction[] | AlertSourceAction[]; actions: AlertmanagerAction[] | AlertingAction[];
} }
export const Authorize = ({ actions, children }: AuthorizeProps) => { export const Authorize = ({ actions, children }: AuthorizeProps) => {
const alertmanagerActions = filter(actions, isAlertmanagerAction) as AlertmanagerAction[]; const alertmanagerActions = filter(actions, isAlertmanagerAction) as AlertmanagerAction[];
const alertSourceActions = filter(actions, isAlertSourceAction) as AlertSourceAction[]; const alertSourceActions = filter(actions, isAlertingAction) as AlertingAction[];
if (alertmanagerActions.length) { if (alertmanagerActions.length) {
return <AuthorizeAlertmanager actions={alertmanagerActions}>{children}</AuthorizeAlertmanager>; return <AuthorizeAlertmanager actions={alertmanagerActions}>{children}</AuthorizeAlertmanager>;
@ -44,8 +44,8 @@ const AuthorizeAlertmanager = ({ actions, children }: ActionsProps<AlertmanagerA
} }
}; };
const AuthorizeAlertsource = ({ actions, children }: ActionsProps<AlertSourceAction>) => { const AuthorizeAlertsource = ({ actions, children }: ActionsProps<AlertingAction>) => {
const alertSourceAbilities = useAlertSourceAbilities(); const alertSourceAbilities = useAlertingAbilities();
const allowed = actionsAllowed(alertSourceAbilities, actions); const allowed = actionsAllowed(alertSourceAbilities, actions);
if (allowed) { if (allowed) {
@ -55,6 +55,8 @@ const AuthorizeAlertsource = ({ actions, children }: ActionsProps<AlertSourceAct
} }
}; };
// TODO add some authorize helper components for alert source and individual alert rules
// check if some action is allowed from the abilities // check if some action is allowed from the abilities
function actionsAllowed<T extends Action>(abilities: Abilities<T>, actions: T[]) { function actionsAllowed<T extends Action>(abilities: Abilities<T>, actions: T[]) {
return chain(abilities) return chain(abilities)
@ -68,6 +70,6 @@ function isAlertmanagerAction(action: AlertmanagerAction) {
return Object.values(AlertmanagerAction).includes(action); return Object.values(AlertmanagerAction).includes(action);
} }
function isAlertSourceAction(action: AlertSourceAction) { function isAlertingAction(action: AlertingAction) {
return Object.values(AlertSourceAction).includes(action); return Object.values(AlertingAction).includes(action);
} }

@ -15,7 +15,7 @@ import { CombinedRule } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto'; import { PromAlertingRuleState, PromApplication } from 'app/types/unified-alerting-dto';
import { discoverFeatures } from '../../api/buildInfo'; import { discoverFeatures } from '../../api/buildInfo';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { mockAlertRuleApi, setupMswServer } from '../../mockApi'; import { mockAlertRuleApi, setupMswServer } from '../../mockApi';
import { import {
getCloudRule, getCloudRule,
@ -53,12 +53,12 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'), ...jest.requireActual('@grafana/runtime'),
getPluginLinkExtensions: jest.fn(), getPluginLinkExtensions: jest.fn(),
})); }));
jest.mock('../../hooks/useIsRuleEditable'); jest.mock('../../hooks/useAbilities');
jest.mock('../../api/buildInfo'); jest.mock('../../api/buildInfo');
const mocks = { const mocks = {
getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions), getPluginLinkExtensionsMock: jest.mocked(getPluginLinkExtensions),
useIsRuleEditable: jest.mocked(useIsRuleEditable), useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
}; };
const ui = { const ui = {
@ -86,6 +86,7 @@ const renderRuleViewer = async (ruleId: string) => {
}; };
const server = setupMswServer(); const server = setupMswServer();
const user = userEvent.setup();
const dsName = 'prometheus'; const dsName = 'prometheus';
const rulerRule = mockRulerAlertingRule({ alert: 'cloud test alert' }); const rulerRule = mockRulerAlertingRule({ alert: 'cloud test alert' });
@ -173,7 +174,7 @@ describe('RuleViewer', () => {
}); });
it('should render page with grafana alert', async () => { it('should render page with grafana alert', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); mocks.useAlertRuleAbility.mockReturnValue([true, true]);
await renderRuleViewer('test1'); await renderRuleViewer('test1');
expect(screen.getByText(/test alert/i)).toBeInTheDocument(); expect(screen.getByText(/test alert/i)).toBeInTheDocument();
@ -186,7 +187,7 @@ describe('RuleViewer', () => {
.mocked(discoverFeatures) .mocked(discoverFeatures)
.mockResolvedValue({ application: PromApplication.Mimir, features: { rulerApiEnabled: true } }); .mockResolvedValue({ application: PromApplication.Mimir, features: { rulerApiEnabled: true } });
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); mocks.useAlertRuleAbility.mockReturnValue([true, true]);
await renderRuleViewer(ruleId.stringifyIdentifier(rulerRuleIdentifier)); await renderRuleViewer(ruleId.stringifyIdentifier(rulerRuleIdentifier));
expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument(); expect(screen.getByText(/cloud test alert/i)).toBeInTheDocument();
@ -206,7 +207,9 @@ describe('RuleDetails RBAC', () => {
}); });
it('Should render Edit button for users with the update permission', async () => { it('Should render Edit button for users with the update permission', async () => {
// Arrange // Arrange
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({ mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule, result: mockGrafanaRule as CombinedRule,
loading: false, loading: false,
@ -231,9 +234,9 @@ describe('RuleDetails RBAC', () => {
requestId: 'A', requestId: 'A',
error: undefined, error: undefined,
}); });
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false];
const user = userEvent.setup(); });
// Act // Act
await renderRuleViewer('test1'); await renderRuleViewer('test1');
@ -265,6 +268,7 @@ describe('RuleDetails RBAC', () => {
it('Should render Silence button for users with the instance create permissions', async () => { it('Should render Silence button for users with the instance create permissions', async () => {
// Arrange // Arrange
mocks.useAlertRuleAbility.mockReturnValue([true, true]);
mockCombinedRule.mockReturnValue({ mockCombinedRule.mockReturnValue({
result: mockGrafanaRule as CombinedRule, result: mockGrafanaRule as CombinedRule,
loading: false, loading: false,
@ -281,12 +285,14 @@ describe('RuleDetails RBAC', () => {
// Assert // Assert
await waitFor(() => { await waitFor(() => {
expect(ui.actionButtons.silence.query()).toBeInTheDocument(); expect(ui.actionButtons.silence.get()).toBeInTheDocument();
}); });
}); });
it('Should render clone button for users having create rule permission', async () => { it('Should render clone button for users having create rule permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Duplicate ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({ mockCombinedRule.mockReturnValue({
result: getGrafanaRule({ name: 'Grafana rule' }), result: getGrafanaRule({ name: 'Grafana rule' }),
loading: false, loading: false,
@ -294,8 +300,6 @@ describe('RuleDetails RBAC', () => {
}); });
grantUserPermissions([AccessControlAction.AlertingRuleCreate]); grantUserPermissions([AccessControlAction.AlertingRuleCreate]);
const user = userEvent.setup();
await renderRuleViewer('test1'); await renderRuleViewer('test1');
await user.click(ui.moreButton.get()); await user.click(ui.moreButton.get());
@ -303,7 +307,9 @@ describe('RuleDetails RBAC', () => {
}); });
it('Should NOT render clone button for users without create rule permission', async () => { it('Should NOT render clone button for users without create rule permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Duplicate ? [true, false] : [true, true];
});
mockCombinedRule.mockReturnValue({ mockCombinedRule.mockReturnValue({
result: getGrafanaRule({ name: 'Grafana rule' }), result: getGrafanaRule({ name: 'Grafana rule' }),
loading: false, loading: false,
@ -312,7 +318,6 @@ describe('RuleDetails RBAC', () => {
const { AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete } = AccessControlAction; const { AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete } = AccessControlAction;
grantUserPermissions([AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete]); grantUserPermissions([AlertingRuleRead, AlertingRuleUpdate, AlertingRuleDelete]);
const user = userEvent.setup();
await renderRuleViewer('test1'); await renderRuleViewer('test1');
await user.click(ui.moreButton.get()); await user.click(ui.moreButton.get());
@ -320,19 +325,19 @@ describe('RuleDetails RBAC', () => {
expect(ui.moreButtons.duplicate.query()).not.toBeInTheDocument(); expect(ui.moreButtons.duplicate.query()).not.toBeInTheDocument();
}); });
}); });
describe('Cloud rules action buttons', () => {
let mockCombinedRule = jest.fn();
beforeEach(() => { describe('Cloud rules action buttons', () => {
// mockCombinedRule = jest.mocked(useCombinedRule); const mockCombinedRule = jest.fn();
});
afterEach(() => { afterEach(() => {
mockCombinedRule.mockReset(); mockCombinedRule.mockReset();
}); });
it('Should render edit button for users with the update permission', async () => { it('Should render edit button for users with the update permission', async () => {
// Arrange // Arrange
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({ mockCombinedRule.mockReturnValue({
result: mockCloudRule as CombinedRule, result: mockCloudRule as CombinedRule,
loading: false, loading: false,
@ -350,6 +355,9 @@ describe('RuleDetails RBAC', () => {
it('Should render Delete button for users with the delete permission', async () => { it('Should render Delete button for users with the delete permission', async () => {
// Arrange // Arrange
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
mockCombinedRule.mockReturnValue({ mockCombinedRule.mockReturnValue({
result: mockCloudRule as CombinedRule, result: mockCloudRule as CombinedRule,
loading: false, loading: false,
@ -357,9 +365,6 @@ describe('RuleDetails RBAC', () => {
requestId: 'A', requestId: 'A',
error: undefined, error: undefined,
}); });
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true });
const user = userEvent.setup();
// Act // Act
await renderRuleViewer('test1'); await renderRuleViewer('test1');

@ -20,12 +20,12 @@ import { useAppNotification } from 'app/core/copy/appNotification';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { deleteRuleAction } from '../../state/actions'; import { deleteRuleAction } from '../../state/actions';
import { getRulesSourceName } from '../../utils/datasource'; import { getRulesSourceName } from '../../utils/datasource';
import { createShareLink, createViewLink } from '../../utils/misc'; import { createShareLink, createViewLink } from '../../utils/misc';
import * as ruleId from '../../utils/rule-id'; import * as ruleId from '../../utils/rule-id';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../../utils/rules'; import { isGrafanaRulerRule } from '../../utils/rules';
import { createUrl } from '../../utils/url'; import { createUrl } from '../../utils/url';
import { RedirectToCloneRule } from './CloneRule'; import { RedirectToCloneRule } from './CloneRule';
@ -50,18 +50,24 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
const { namespace, group, rulerRule } = rule; const { namespace, group, rulerRule } = rule;
const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>(); const [ruleToDelete, setRuleToDelete] = useState<CombinedRule>();
const rulesSourceName = getRulesSourceName(rulesSource); const returnTo = location.pathname + location.search;
const isViewMode = inViewMode(location.pathname);
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
const [editRuleSupported, editRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
const [deleteRuleSupported, deleteRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
const [duplicateRuleSupported, duplicateRuleAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
const [modifyExportSupported, modifyExportAllowed] = useAlertRuleAbility(rule, AlertRuleAction.ModifyExport);
const canEditRule = editRuleSupported && editRuleAllowed;
const canDeleteRule = deleteRuleSupported && deleteRuleAllowed;
const canDuplicateRule = duplicateRuleSupported && duplicateRuleAllowed;
const canModifyExport = modifyExportSupported && modifyExportAllowed;
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
const moreActions: JSX.Element[] = []; const moreActions: JSX.Element[] = [];
const isFederated = isFederatedRuleGroup(group);
const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule);
const returnTo = location.pathname + location.search;
const isViewMode = inViewMode(location.pathname);
const deleteRule = () => { const deleteRule = () => {
if (ruleToDelete && ruleToDelete.rulerRule) { if (ruleToDelete && ruleToDelete.rulerRule) {
const identifier = ruleId.fromRulerRule( const identifier = ruleId.fromRulerRule(
@ -96,53 +102,53 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
); );
} }
if (rulerRule && !isFederated) { if (rulerRule) {
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
if (isEditable) { if (canEditRule) {
if (!isProvisioned) { const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, {
const editURL = createUrl(`/alerting/${encodeURIComponent(ruleId.stringifyIdentifier(identifier))}/edit`, { returnTo,
returnTo, });
});
buttons.push(
if (isViewMode) { <Tooltip placement="top" content={'Edit'}>
buttons.push( <LinkButton
<ClipboardButton title="Edit"
key="copy" className={style.button}
icon="copy" size="sm"
onClipboardError={(copiedText) => { key="edit"
notifyApp.error('Error while copying URL', copiedText); variant="secondary"
}} icon="pen"
className={style.button} href={editURL}
size="sm" />
getText={buildShareUrl} </Tooltip>
> );
Copy link to rule }
</ClipboardButton>
);
}
buttons.push(
<Tooltip placement="top" content={'Edit'}>
<LinkButton
title="Edit"
className={style.button}
size="sm"
key="edit"
variant="secondary"
icon="pen"
href={editURL}
/>
</Tooltip>
);
}
if (isViewMode) {
buttons.push(
<ClipboardButton
key="copy"
icon="copy"
onClipboardError={(copiedText) => {
notifyApp.error('Error while copying URL', copiedText);
}}
className={style.button}
size="sm"
getText={buildShareUrl}
>
Copy link to rule
</ClipboardButton>
);
}
if (canDuplicateRule) {
moreActions.push( moreActions.push(
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} /> <Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
); );
} }
if (isGrafanaRulerRule(rulerRule)) { if (canModifyExport) {
moreActions.push( moreActions.push(
<Menu.Item <Menu.Item
label="Modify export" label="Modify export"
@ -153,10 +159,10 @@ export const RuleActionsButtons = ({ rule, rulesSource }: Props) => {
/> />
); );
} }
}
if (isRemovable && rulerRule && !isFederated && !isProvisioned) { if (canDeleteRule) {
moreActions.push(<Menu.Item label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />); moreActions.push(<Menu.Item label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />);
}
} }
if (buttons.length || moreActions.length) { if (buttons.length || moreActions.length) {

@ -17,17 +17,13 @@ import {
useStyles2, useStyles2,
} from '@grafana/ui'; } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification'; import { useAppNotification } from 'app/core/copy/appNotification';
import { contextSrv } from 'app/core/services/context_srv'; import { useDispatch } from 'app/types';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction, useDispatch } from 'app/types';
import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting'; import { CombinedRule, RuleIdentifier, RulesSource } from 'app/types/unified-alerting';
import { PromAlertingRuleState } from 'app/types/unified-alerting-dto'; import { PromAlertingRuleState } from 'app/types/unified-alerting-dto';
import { alertmanagerApi } from '../../api/alertmanagerApi'; import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable';
import { useStateHistoryModal } from '../../hooks/useStateHistoryModal'; import { useStateHistoryModal } from '../../hooks/useStateHistoryModal';
import { deleteRuleAction } from '../../state/actions'; import { deleteRuleAction } from '../../state/actions';
import { getRulesPermissions } from '../../utils/access-control';
import { getAlertmanagerByUid } from '../../utils/alertmanager'; import { getAlertmanagerByUid } from '../../utils/alertmanager';
import { Annotation } from '../../utils/constants'; import { Annotation } from '../../utils/constants';
import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; import { getRulesSourceName, isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
@ -68,7 +64,11 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
? rulesSource ? rulesSource
: getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name; : getAlertmanagerByUid(rulesSource.jsonData.alertmanagerUid)?.name;
const hasExplorePermission = contextSrv.hasPermission(AccessControlAction.DataSourcesExplore); const [duplicateSupported, duplicateAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Duplicate);
const [silenceSupported, silenceAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Silence);
const [exploreSupported, exploreAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Explore);
const [deleteSupported, deleteAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Delete);
const [editSupported, editAllowed] = useAlertRuleAbility(rule, AlertRuleAction.Update);
const buttons: JSX.Element[] = []; const buttons: JSX.Element[] = [];
const rightButtons: JSX.Element[] = []; const rightButtons: JSX.Element[] = [];
@ -89,22 +89,21 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
}; };
const isFederated = isFederatedRuleGroup(group); const isFederated = isFederatedRuleGroup(group);
const rulesSourceName = getRulesSourceName(rulesSource);
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance); const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing; const isFiringRule = isAlertingRule(rule.promRule) && rule.promRule.state === PromAlertingRuleState.Firing;
const rulesPermissions = getRulesPermissions(rulesSourceName); const canDelete = deleteSupported && deleteAllowed;
const hasCreateRulePermission = contextSrv.hasPermission(rulesPermissions.create); const canEdit = editSupported && editAllowed;
const { isEditable, isRemovable } = useIsRuleEditable(rulesSourceName, rulerRule); const canSilence = silenceSupported && silenceAllowed && alertmanagerSourceName;
const canSilence = useCanSilence(rule); const canDuplicateRule = duplicateSupported && duplicateAllowed && !isFederated;
const buildShareUrl = () => createShareLink(rulesSource, rule); const buildShareUrl = () => createShareLink(rulesSource, rule);
const returnTo = location.pathname + location.search; const returnTo = location.pathname + location.search;
// explore does not support grafana rule queries atm // explore does not support grafana rule queries atm
// neither do "federated rules" // neither do "federated rules"
if (isCloudRulesSource(rulesSource) && hasExplorePermission && !isFederated) { if (isCloudRulesSource(rulesSource) && exploreSupported && exploreAllowed && !isFederated) {
buttons.push( buttons.push(
<LinkButton <LinkButton
size="sm" size="sm"
@ -165,7 +164,7 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
} }
} }
if (canSilence && alertmanagerSourceName) { if (canSilence) {
buttons.push( buttons.push(
<LinkButton <LinkButton
size="sm" size="sm"
@ -206,7 +205,7 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
const sourceName = getRulesSourceName(rulesSource); const sourceName = getRulesSourceName(rulesSource);
const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule); const identifier = ruleId.fromRulerRule(sourceName, namespace.name, group.name, rulerRule);
if (isEditable && !isFederated) { if (canEdit) {
rightButtons.push( rightButtons.push(
<ClipboardButton <ClipboardButton
key="copy" key="copy"
@ -245,13 +244,13 @@ export const RuleDetailsActionButtons = ({ rule, rulesSource, isViewMode }: Prop
moreActionsButtons.push(<Menu.Item label="Modify export" icon="edit" url={modifyUrl} />); moreActionsButtons.push(<Menu.Item label="Modify export" icon="edit" url={modifyUrl} />);
} }
if (hasCreateRulePermission && !isFederated) { if (canDuplicateRule) {
moreActionsButtons.push( moreActionsButtons.push(
<Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} /> <Menu.Item label="Duplicate" icon="copy" onClick={() => setRedirectToClone({ identifier, isProvisioned })} />
); );
} }
if (isRemovable && !isFederated && !isProvisioned) { if (canDelete) {
moreActionsButtons.push(<Menu.Divider />); moreActionsButtons.push(<Menu.Divider />);
moreActionsButtons.push( moreActionsButtons.push(
<Menu.Item key="delete" label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} /> <Menu.Item key="delete" label="Delete" icon="trash-alt" onClick={() => setRuleToDelete(rule)} />
@ -317,31 +316,6 @@ function shouldShowDeclareIncidentButton() {
return !isOpenSourceEdition() || isLocalDevEnv(); return !isOpenSourceEdition() || isLocalDevEnv();
} }
/**
* We don't want to show the silence button if either
* 1. the user has no permissions to create silences
* 2. the admin has configured to only send instances to external AMs
*/
function useCanSilence(rule: CombinedRule) {
const isGrafanaManagedRule = isGrafanaRulerRule(rule.rulerRule);
const { useGetAlertmanagerChoiceStatusQuery } = alertmanagerApi;
const { currentData: amConfigStatus, isLoading } = useGetAlertmanagerChoiceStatusQuery(undefined, {
skip: !isGrafanaManagedRule,
});
if (!isGrafanaManagedRule || isLoading) {
return false;
}
const hasPermissions = contextSrv.hasPermission(AccessControlAction.AlertingInstanceCreate);
const interactsOnlyWithExternalAMs = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.External;
const interactsWithAll = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.All;
return hasPermissions && (!interactsOnlyWithExternalAMs || interactsWithAll);
}
export const getStyles = (theme: GrafanaTheme2) => ({ export const getStyles = (theme: GrafanaTheme2) => ({
wrapper: css` wrapper: css`
padding: ${theme.spacing(2)} 0; padding: ${theme.spacing(2)} 0;

@ -3,7 +3,7 @@ import React, { useEffect, useMemo } from 'react';
import { CombinedRuleNamespace } from 'app/types/unified-alerting'; import { CombinedRuleNamespace } from 'app/types/unified-alerting';
import { LogMessages, logInfo } from '../../Analytics'; import { LogMessages, logInfo } from '../../Analytics';
import { AlertSourceAction } from '../../hooks/useAbilities'; import { AlertingAction } from '../../hooks/useAbilities';
import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource'; import { isCloudRulesSource, isGrafanaRulesSource } from '../../utils/datasource';
import { Authorize } from '../Authorize'; import { Authorize } from '../Authorize';
@ -35,10 +35,10 @@ export const RuleListGroupView = ({ namespaces, expandAll }: Props) => {
return ( return (
<> <>
<Authorize actions={[AlertSourceAction.ViewAlertRule]}> <Authorize actions={[AlertingAction.ViewAlertRule]}>
<GrafanaRules namespaces={grafanaNamespaces} expandAll={expandAll} /> <GrafanaRules namespaces={grafanaNamespaces} expandAll={expandAll} />
</Authorize> </Authorize>
<Authorize actions={[AlertSourceAction.ViewExternalAlertRule]}> <Authorize actions={[AlertingAction.ViewExternalAlertRule]}>
<CloudRules namespaces={cloudNamespaces} expandAll={expandAll} /> <CloudRules namespaces={cloudNamespaces} expandAll={expandAll} />
</Authorize> </Authorize>
</> </>

@ -8,15 +8,15 @@ import { byRole } from 'testing-library-selector';
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { CombinedRule } from 'app/types/unified-alerting'; import { CombinedRule } from 'app/types/unified-alerting';
import { useIsRuleEditable } from '../../hooks/useIsRuleEditable'; import { AlertRuleAction, useAlertRuleAbility } from '../../hooks/useAbilities';
import { getCloudRule, getGrafanaRule } from '../../mocks'; import { getCloudRule, getGrafanaRule } from '../../mocks';
import { RulesTable } from './RulesTable'; import { RulesTable } from './RulesTable';
jest.mock('../../hooks/useIsRuleEditable'); jest.mock('../../hooks/useAbilities');
const mocks = { const mocks = {
useIsRuleEditable: jest.mocked(useIsRuleEditable), useAlertRuleAbility: jest.mocked(useAlertRuleAbility),
}; };
const ui = { const ui = {
@ -42,18 +42,25 @@ function renderRulesTable(rule: CombinedRule) {
); );
} }
const user = userEvent.setup();
describe('RulesTable RBAC', () => { describe('RulesTable RBAC', () => {
describe('Grafana rules action buttons', () => { describe('Grafana rules action buttons', () => {
const grafanaRule = getGrafanaRule({ name: 'Grafana' }); const grafanaRule = getGrafanaRule({ name: 'Grafana' });
it('Should not render Edit button for users without the update permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); it('Should not render Edit button for users without the update permission', async () => {
mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true];
});
renderRulesTable(grafanaRule); renderRulesTable(grafanaRule);
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
}); });
it('Should not render Delete button for users without the delete permission', async () => { it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
const user = userEvent.setup(); return action === AlertRuleAction.Delete ? [true, false] : [true, true];
});
renderRulesTable(grafanaRule); renderRulesTable(grafanaRule);
await user.click(ui.actionButtons.more.get()); await user.click(ui.actionButtons.more.get());
@ -62,14 +69,17 @@ describe('RulesTable RBAC', () => {
}); });
it('Should render Edit button for users with the update permission', () => { it('Should render Edit button for users with the update permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
renderRulesTable(grafanaRule); renderRulesTable(grafanaRule);
expect(ui.actionButtons.edit.get()).toBeInTheDocument(); expect(ui.actionButtons.edit.get()).toBeInTheDocument();
}); });
it('Should render Delete button for users with the delete permission', async () => { it('Should render Delete button for users with the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
const user = userEvent.setup(); return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
renderRulesTable(grafanaRule); renderRulesTable(grafanaRule);
@ -81,28 +91,39 @@ describe('RulesTable RBAC', () => {
describe('Cloud rules action buttons', () => { describe('Cloud rules action buttons', () => {
const cloudRule = getCloudRule({ name: 'Cloud' }); const cloudRule = getCloudRule({ name: 'Cloud' });
it('Should not render Edit button for users without the update permission', () => { it('Should not render Edit button for users without the update permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: false }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, false] : [true, true];
});
renderRulesTable(cloudRule); renderRulesTable(cloudRule);
expect(ui.actionButtons.edit.query()).not.toBeInTheDocument(); expect(ui.actionButtons.edit.query()).not.toBeInTheDocument();
}); });
it('Should not render Delete button for users without the delete permission', async () => { it('Should not render Delete button for users without the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: false }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Delete ? [true, false] : [true, true];
});
renderRulesTable(cloudRule); renderRulesTable(cloudRule);
expect(ui.actionButtons.more.query()).not.toBeInTheDocument(); await user.click(ui.actionButtons.more.get());
expect(ui.moreActionItems.delete.query()).not.toBeInTheDocument();
}); });
it('Should render Edit button for users with the update permission', () => { it('Should render Edit button for users with the update permission', () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isEditable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
return action === AlertRuleAction.Update ? [true, true] : [false, false];
});
renderRulesTable(cloudRule); renderRulesTable(cloudRule);
expect(ui.actionButtons.edit.get()).toBeInTheDocument(); expect(ui.actionButtons.edit.get()).toBeInTheDocument();
}); });
it('Should render Delete button for users with the delete permission', async () => { it('Should render Delete button for users with the delete permission', async () => {
mocks.useIsRuleEditable.mockReturnValue({ loading: false, isRemovable: true }); mocks.useAlertRuleAbility.mockImplementation((_rule, action) => {
const user = userEvent.setup(); return action === AlertRuleAction.Delete ? [true, true] : [false, false];
});
renderRulesTable(cloudRule); renderRulesTable(cloudRule);
await user.click(ui.actionButtons.more.get()); await user.click(ui.actionButtons.more.get());

@ -1,12 +1,13 @@
import { renderHook } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
import React, { PropsWithChildren } from 'react'; import React, { PropsWithChildren } from 'react';
import { Router } from 'react-router-dom'; import { Router } from 'react-router-dom';
import { TestProvider } from 'test/helpers/TestProvider';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types'; import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { grantUserPermissions, mockDataSource } from '../mocks'; import { getGrafanaRule, grantUserPermissions, mockDataSource } from '../mocks';
import { AlertmanagerProvider } from '../state/AlertmanagerContext'; import { AlertmanagerProvider } from '../state/AlertmanagerContext';
import { setupDataSources } from '../testSetup/datasources'; import { setupDataSources } from '../testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
@ -15,6 +16,7 @@ import {
AlertmanagerAction, AlertmanagerAction,
useAlertmanagerAbilities, useAlertmanagerAbilities,
useAlertmanagerAbility, useAlertmanagerAbility,
useAllAlertRuleAbilities,
useAllAlertmanagerAbilities, useAllAlertmanagerAbilities,
} from './useAbilities'; } from './useAbilities';
@ -32,7 +34,9 @@ describe('alertmanager abilities', () => {
}) })
); );
const abilities = renderHook(() => useAllAlertmanagerAbilities(), { wrapper: createWrapper('does-not-exist') }); const abilities = renderHook(() => useAllAlertmanagerAbilities(), {
wrapper: createAlertmanagerWrapper('does-not-exist'),
});
expect(abilities.result.current).toMatchSnapshot(); expect(abilities.result.current).toMatchSnapshot();
}); });
@ -47,7 +51,7 @@ describe('alertmanager abilities', () => {
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]); grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]);
const abilities = renderHook(() => useAllAlertmanagerAbilities(), { const abilities = renderHook(() => useAllAlertmanagerAbilities(), {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME), wrapper: createAlertmanagerWrapper(GRAFANA_RULES_SOURCE_NAME),
}); });
Object.values(abilities.result.current).forEach(([supported]) => { Object.values(abilities.result.current).forEach(([supported]) => {
@ -56,7 +60,7 @@ describe('alertmanager abilities', () => {
// since we only granted "read" permissions, only those should be allowed // since we only granted "read" permissions, only those should be allowed
const viewAbility = renderHook(() => useAlertmanagerAbility(AlertmanagerAction.ViewSilence), { const viewAbility = renderHook(() => useAlertmanagerAbility(AlertmanagerAction.ViewSilence), {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME), wrapper: createAlertmanagerWrapper(GRAFANA_RULES_SOURCE_NAME),
}); });
const [viewSupported, viewAllowed] = viewAbility.result.current; const [viewSupported, viewAllowed] = viewAbility.result.current;
@ -66,7 +70,7 @@ describe('alertmanager abilities', () => {
// editing should not be allowed, but supported // editing should not be allowed, but supported
const editAbility = renderHook(() => useAlertmanagerAbility(AlertmanagerAction.ViewSilence), { const editAbility = renderHook(() => useAlertmanagerAbility(AlertmanagerAction.ViewSilence), {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME), wrapper: createAlertmanagerWrapper(GRAFANA_RULES_SOURCE_NAME),
}); });
const [editSupported, editAllowed] = editAbility.result.current; const [editSupported, editAllowed] = editAbility.result.current;
@ -97,7 +101,7 @@ describe('alertmanager abilities', () => {
]); ]);
const abilities = renderHook(() => useAllAlertmanagerAbilities(), { const abilities = renderHook(() => useAllAlertmanagerAbilities(), {
wrapper: createWrapper('mimir'), wrapper: createAlertmanagerWrapper('mimir'),
}); });
expect(abilities.result.current).toMatchSnapshot(); expect(abilities.result.current).toMatchSnapshot();
@ -121,7 +125,7 @@ describe('alertmanager abilities', () => {
AlertmanagerAction.ExportContactPoint, AlertmanagerAction.ExportContactPoint,
]), ]),
{ {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME), wrapper: createAlertmanagerWrapper(GRAFANA_RULES_SOURCE_NAME),
} }
); );
@ -132,7 +136,29 @@ describe('alertmanager abilities', () => {
}); });
}); });
function createWrapper(alertmanagerSourceName: string) { describe('rule permissions', () => {
it('should report that all actions are supported for a Grafana Managed alert rule', async () => {
const rule = getGrafanaRule();
const abilities = renderHook(() => useAllAlertRuleAbilities(rule), { wrapper: TestProvider });
await waitFor(() => {
const results = Object.values(abilities.result.current);
for (const [supported, _allowed] of results) {
expect(supported).toBe(true);
}
});
});
it('should report the correct set of supported actions for an external rule with ruler API', async () => {});
it('should not allow certain actions for provisioned rules', () => {});
it('should not allow certain actions for federated rules', () => {});
});
function createAlertmanagerWrapper(alertmanagerSourceName: string) {
const wrapper = (props: PropsWithChildren) => ( const wrapper = (props: PropsWithChildren) => (
<Router history={createBrowserHistory()}> <Router history={createBrowserHistory()}>
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertmanagerSourceName}> <AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertmanagerSourceName}>

@ -1,16 +1,25 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { contextSrv as ctx } from 'app/core/services/context_srv'; import { contextSrv as ctx } from 'app/core/services/context_srv';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { CombinedRule, RulesSource } from 'app/types/unified-alerting';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { useAlertmanager } from '../state/AlertmanagerContext'; import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions } from '../utils/access-control'; import { getInstancesPermissions, getNotificationsPermissions, getRulesPermissions } from '../utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { isFederatedRuleGroup, isGrafanaRulerRule } from '../utils/rules';
import { useIsRuleEditable } from './useIsRuleEditable';
/** /**
* These hooks will determine if * These hooks will determine if
* 1. the action is supported in the current alertmanager or data source context * 1. the action is supported in the current context (alertmanager, alert rule or general context)
* 2. user is allowed to perform actions based on their set of permissions / assigned role * 2. user is allowed to perform actions based on their set of permissions / assigned role
*/ */
// this enum lists all of the available actions we can perform within the context of an alertmanager
export enum AlertmanagerAction { export enum AlertmanagerAction {
// configuration // configuration
ViewExternalConfiguration = 'view-external-configuration', ViewExternalConfiguration = 'view-external-configuration',
@ -49,12 +58,27 @@ export enum AlertmanagerAction {
DeleteMuteTiming = 'delete-mute-timing', DeleteMuteTiming = 'delete-mute-timing',
} }
export enum AlertSourceAction { // this enum lists all of the available actions we can take on a single alert rule
export enum AlertRuleAction {
Duplicate = 'duplicate-alert-rule',
View = 'view-alert-rule',
Update = 'update-alert-rule',
Delete = 'delete-alert-rule',
Explore = 'explore-alert-rule',
Silence = 'silence-alert-rule',
ModifyExport = 'modify-export-rule',
}
// this enum lists all of the actions we can perform within alerting in general, not linked to a specific
// alert source, rule or alertmanager
export enum AlertingAction {
// internal (Grafana managed) // internal (Grafana managed)
CreateAlertRule = 'create-alert-rule', CreateAlertRule = 'create-alert-rule',
ViewAlertRule = 'view-alert-rule', ViewAlertRule = 'view-alert-rule',
UpdateAlertRule = 'update-alert-rule', UpdateAlertRule = 'update-alert-rule',
DeleteAlertRule = 'delete-alert-rule', DeleteAlertRule = 'delete-alert-rule',
ExportGrafanaManagedRules = 'export-grafana-managed-rules',
// external (any compatible alerting data source) // external (any compatible alerting data source)
CreateExternalAlertRule = 'create-external-alert-rule', CreateExternalAlertRule = 'create-external-alert-rule',
ViewExternalAlertRule = 'view-external-alert-rule', ViewExternalAlertRule = 'view-external-alert-rule',
@ -62,39 +86,92 @@ export enum AlertSourceAction {
DeleteExternalAlertRule = 'delete-external-alert-rule', DeleteExternalAlertRule = 'delete-external-alert-rule',
} }
const AlwaysSupported = true; // this just makes it easier to understand the code // these just makes it easier to read the code :)
export type Action = AlertmanagerAction | AlertSourceAction; const AlwaysSupported = true;
const NotSupported = false;
export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction;
export type Ability = [actionSupported: boolean, actionAllowed: boolean]; export type Ability = [actionSupported: boolean, actionAllowed: boolean];
export type Abilities<T extends Action> = Record<T, Ability>; export type Abilities<T extends Action> = Record<T, Ability>;
export function useAlertSourceAbilities(): Abilities<AlertSourceAction> { /**
// TODO add the "supported" booleans here, we currently only do authorization * This one will check for alerting abilities that don't apply to any particular alert source or alert rule
*/
const abilities: Abilities<AlertSourceAction> = { export const useAlertingAbilities = (): Abilities<AlertingAction> => {
// -- Grafana managed alert rules -- return {
[AlertSourceAction.CreateAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleCreate)], // internal (Grafana managed)
[AlertSourceAction.ViewAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleRead)], [AlertingAction.CreateAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleCreate),
[AlertSourceAction.UpdateAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleUpdate)], [AlertingAction.ViewAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleRead),
[AlertSourceAction.DeleteAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleDelete)], [AlertingAction.UpdateAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleUpdate),
// -- External alert rules (Mimir / Loki / etc) -- [AlertingAction.DeleteAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleDelete),
// for these we only have "read" and "write" permissions [AlertingAction.ExportGrafanaManagedRules]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleRead),
[AlertSourceAction.CreateExternalAlertRule]: [
AlwaysSupported, // external
ctx.hasPermission(AccessControlAction.AlertingRuleExternalWrite), [AlertingAction.CreateExternalAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleExternalWrite),
], [AlertingAction.ViewExternalAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleExternalRead),
[AlertSourceAction.ViewExternalAlertRule]: [ [AlertingAction.UpdateExternalAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleExternalWrite),
AlwaysSupported, [AlertingAction.DeleteExternalAlertRule]: toAbility(AlwaysSupported, AccessControlAction.AlertingRuleExternalWrite),
ctx.hasPermission(AccessControlAction.AlertingRuleExternalRead), };
], };
[AlertSourceAction.UpdateExternalAlertRule]: [
AlwaysSupported, export const useAlertingAbility = (action: AlertingAction): Ability => {
ctx.hasPermission(AccessControlAction.AlertingRuleExternalWrite), const allAbilities = useAlertingAbilities();
], return allAbilities[action];
[AlertSourceAction.DeleteExternalAlertRule]: [ };
AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingRuleExternalWrite), /**
], * This hook will check if we support the action and have sufficient permissions for it on a single alert rule
*/
export function useAlertRuleAbility(rule: CombinedRule, action: AlertRuleAction): Ability {
const abilities = useAllAlertRuleAbilities(rule);
return useMemo(() => {
return abilities[action];
}, [abilities, action]);
}
export function useAlertRuleAbilities(rule: CombinedRule, actions: AlertRuleAction[]): Ability[] {
const abilities = useAllAlertRuleAbilities(rule);
return useMemo(() => {
return actions.map((action) => abilities[action]);
}, [abilities, actions]);
}
export function useAllAlertRuleAbilities(rule: CombinedRule): Abilities<AlertRuleAction> {
const rulesSource = rule.namespace.rulesSource;
const rulesSourceName = typeof rulesSource === 'string' ? rulesSource : rulesSource.name;
const isProvisioned = isGrafanaRulerRule(rule.rulerRule) && Boolean(rule.rulerRule.grafana_alert.provenance);
const isFederated = isFederatedRuleGroup(rule.group);
// if a rule is either provisioned or a federated rule, we don't allow it to be removed or edited
const immutableRule = isProvisioned || isFederated;
// TODO refactor this hook maybe
const {
isEditable,
isRemovable,
isRulerAvailable = false,
loading,
} = useIsRuleEditable(rulesSourceName, rule.rulerRule);
const [_, exportAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules);
// while we gather info, pretend it's not supported
const MaybeSupported = loading ? NotSupported : isRulerAvailable;
const MaybeSupportedUnlessImmutable = immutableRule ? NotSupported : MaybeSupported;
const rulesPermissions = getRulesPermissions(rulesSourceName);
const canSilence = useCanSilence(rulesSource);
const abilities: Abilities<AlertRuleAction> = {
[AlertRuleAction.Duplicate]: toAbility(MaybeSupported, rulesPermissions.create),
[AlertRuleAction.View]: toAbility(AlwaysSupported, rulesPermissions.read),
[AlertRuleAction.Update]: [MaybeSupportedUnlessImmutable, isEditable ?? false],
[AlertRuleAction.Delete]: [MaybeSupportedUnlessImmutable, isRemovable ?? false],
[AlertRuleAction.Explore]: toAbility(AlwaysSupported, AccessControlAction.DataSourcesExplore),
[AlertRuleAction.Silence]: canSilence,
[AlertRuleAction.ModifyExport]: [MaybeSupported, exportAllowed],
}; };
return abilities; return abilities;
@ -115,72 +192,48 @@ export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
// list out all of the abilities, and if the user has permissions to perform them // list out all of the abilities, and if the user has permissions to perform them
const abilities: Abilities<AlertmanagerAction> = { const abilities: Abilities<AlertmanagerAction> = {
// -- configuration -- // -- configuration --
[AlertmanagerAction.ViewExternalConfiguration]: [ [AlertmanagerAction.ViewExternalConfiguration]: toAbility(
AlwaysSupported, AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingNotificationsExternalRead), AccessControlAction.AlertingNotificationsExternalRead
], ),
[AlertmanagerAction.UpdateExternalConfiguration]: [ [AlertmanagerAction.UpdateExternalConfiguration]: toAbility(
hasConfigurationAPI, hasConfigurationAPI,
ctx.hasPermission(AccessControlAction.AlertingNotificationsExternalWrite), AccessControlAction.AlertingNotificationsExternalWrite
], ),
// -- contact points -- // -- contact points --
[AlertmanagerAction.CreateContactPoint]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.create)], [AlertmanagerAction.CreateContactPoint]: toAbility(hasConfigurationAPI, notificationsPermissions.create),
[AlertmanagerAction.ViewContactPoint]: [AlwaysSupported, ctx.hasPermission(notificationsPermissions.read)], [AlertmanagerAction.ViewContactPoint]: toAbility(AlwaysSupported, notificationsPermissions.read),
[AlertmanagerAction.UpdateContactPoint]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.update)], [AlertmanagerAction.UpdateContactPoint]: toAbility(hasConfigurationAPI, notificationsPermissions.update),
[AlertmanagerAction.DeleteContactPoint]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.delete)], [AlertmanagerAction.DeleteContactPoint]: toAbility(hasConfigurationAPI, notificationsPermissions.delete),
// only Grafana flavored alertmanager supports exporting // only Grafana flavored alertmanager supports exporting
[AlertmanagerAction.ExportContactPoint]: [ [AlertmanagerAction.ExportContactPoint]: toAbility(isGrafanaFlavoredAlertmanager, notificationsPermissions.read),
isGrafanaFlavoredAlertmanager,
ctx.hasPermission(notificationsPermissions.read),
],
// -- notification templates -- // -- notification templates --
[AlertmanagerAction.CreateNotificationTemplate]: [ [AlertmanagerAction.CreateNotificationTemplate]: toAbility(hasConfigurationAPI, notificationsPermissions.create),
hasConfigurationAPI, [AlertmanagerAction.ViewNotificationTemplate]: toAbility(AlwaysSupported, notificationsPermissions.read),
ctx.hasPermission(notificationsPermissions.create), [AlertmanagerAction.UpdateNotificationTemplate]: toAbility(hasConfigurationAPI, notificationsPermissions.update),
], [AlertmanagerAction.DeleteNotificationTemplate]: toAbility(hasConfigurationAPI, notificationsPermissions.delete),
[AlertmanagerAction.ViewNotificationTemplate]: [AlwaysSupported, ctx.hasPermission(notificationsPermissions.read)],
[AlertmanagerAction.UpdateNotificationTemplate]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.update),
],
[AlertmanagerAction.DeleteNotificationTemplate]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.delete),
],
// -- notification policies -- // -- notification policies --
[AlertmanagerAction.CreateNotificationPolicy]: [ [AlertmanagerAction.CreateNotificationPolicy]: toAbility(hasConfigurationAPI, notificationsPermissions.create),
hasConfigurationAPI, [AlertmanagerAction.ViewNotificationPolicyTree]: toAbility(AlwaysSupported, notificationsPermissions.read),
ctx.hasPermission(notificationsPermissions.create), [AlertmanagerAction.UpdateNotificationPolicyTree]: toAbility(hasConfigurationAPI, notificationsPermissions.update),
], [AlertmanagerAction.DeleteNotificationPolicy]: toAbility(hasConfigurationAPI, notificationsPermissions.delete),
[AlertmanagerAction.ViewNotificationPolicyTree]: [ [AlertmanagerAction.ExportNotificationPolicies]: toAbility(
AlwaysSupported,
ctx.hasPermission(notificationsPermissions.read),
],
[AlertmanagerAction.UpdateNotificationPolicyTree]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.update),
],
[AlertmanagerAction.DeleteNotificationPolicy]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.delete),
],
[AlertmanagerAction.ExportNotificationPolicies]: [
isGrafanaFlavoredAlertmanager, isGrafanaFlavoredAlertmanager,
ctx.hasPermission(notificationsPermissions.read), notificationsPermissions.read
], ),
[AlertmanagerAction.DecryptSecrets]: [ [AlertmanagerAction.DecryptSecrets]: toAbility(
isGrafanaFlavoredAlertmanager, isGrafanaFlavoredAlertmanager,
ctx.hasPermission(notificationsPermissions.provisioning.readSecrets), notificationsPermissions.provisioning.readSecrets
], ),
// -- silences -- // -- silences --
[AlertmanagerAction.CreateSilence]: [hasConfigurationAPI, ctx.hasPermission(instancePermissions.create)], [AlertmanagerAction.CreateSilence]: toAbility(hasConfigurationAPI, instancePermissions.create),
[AlertmanagerAction.ViewSilence]: [AlwaysSupported, ctx.hasPermission(instancePermissions.read)], [AlertmanagerAction.ViewSilence]: toAbility(AlwaysSupported, instancePermissions.read),
[AlertmanagerAction.UpdateSilence]: [hasConfigurationAPI, ctx.hasPermission(instancePermissions.update)], [AlertmanagerAction.UpdateSilence]: toAbility(hasConfigurationAPI, instancePermissions.update),
// -- mute timtings -- // -- mute timtings --
[AlertmanagerAction.CreateMuteTiming]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.create)], [AlertmanagerAction.CreateMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.create),
[AlertmanagerAction.ViewMuteTiming]: [AlwaysSupported, ctx.hasPermission(notificationsPermissions.read)], [AlertmanagerAction.ViewMuteTiming]: toAbility(AlwaysSupported, notificationsPermissions.read),
[AlertmanagerAction.UpdateMuteTiming]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.update)], [AlertmanagerAction.UpdateMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.update),
[AlertmanagerAction.DeleteMuteTiming]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.delete)], [AlertmanagerAction.DeleteMuteTiming]: toAbility(hasConfigurationAPI, notificationsPermissions.delete),
}; };
return abilities; return abilities;
@ -202,7 +255,31 @@ export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability
}, [abilities, actions]); }, [abilities, actions]);
} }
export function useAlertSourceAbility(action: AlertSourceAction): Ability { /**
const abilities = useAlertSourceAbilities(); * We don't want to show the silence button if either
return useMemo(() => abilities[action], [abilities, action]); * 1. the user has no permissions to create silences
* 2. the admin has configured to only send instances to external AMs
*/
function useCanSilence(rulesSource: RulesSource): [boolean, boolean] {
const isGrafanaManagedRule = rulesSource === GRAFANA_RULES_SOURCE_NAME;
const { useGetAlertmanagerChoiceStatusQuery } = alertmanagerApi;
const { currentData: amConfigStatus, isLoading } = useGetAlertmanagerChoiceStatusQuery(undefined, {
skip: !isGrafanaManagedRule,
});
// we don't support silencing when the rule is not a Grafana managed rule
// we simply don't know what Alertmanager the ruler is sending alerts to
if (!isGrafanaManagedRule || isLoading) {
return [false, false];
}
const interactsOnlyWithExternalAMs = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.External;
const interactsWithAll = amConfigStatus?.alertmanagersChoice === AlertmanagerChoice.All;
const silenceSupported = !interactsOnlyWithExternalAMs || interactsWithAll;
return toAbility(silenceSupported, AccessControlAction.AlertingInstanceCreate);
} }
// just a convenient function
const toAbility = (supported: boolean, action: AccessControlAction): Ability => [supported, ctx.hasPermission(action)];

@ -9,6 +9,7 @@ import { useFolder } from './useFolder';
import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector'; import { useUnifiedAlertingSelector } from './useUnifiedAlertingSelector';
interface ResultBag { interface ResultBag {
isRulerAvailable?: boolean;
isEditable?: boolean; isEditable?: boolean;
isRemovable?: boolean; isRemovable?: boolean;
loading: boolean; loading: boolean;
@ -42,6 +43,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
if (!folder) { if (!folder) {
// Loading or invalid folder UID // Loading or invalid folder UID
return { return {
isRulerAvailable: true,
isEditable: false, isEditable: false,
isRemovable: false, isRemovable: false,
loading, loading,
@ -52,6 +54,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
const canRemoveGrafanaRules = contextSrv.hasPermissionInMetadata(rulePermission.delete, folder); const canRemoveGrafanaRules = contextSrv.hasPermissionInMetadata(rulePermission.delete, folder);
return { return {
isRulerAvailable: true,
isEditable: canEditGrafanaRules, isEditable: canEditGrafanaRules,
isRemovable: canRemoveGrafanaRules, isRemovable: canRemoveGrafanaRules,
loading: loading || isLoading, loading: loading || isLoading,
@ -65,6 +68,7 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
const canRemoveCloudRules = contextSrv.hasPermission(rulePermission.delete); const canRemoveCloudRules = contextSrv.hasPermission(rulePermission.delete);
return { return {
isRulerAvailable,
isEditable: canEditCloudRules && isRulerAvailable, isEditable: canEditCloudRules && isRulerAvailable,
isRemovable: canRemoveCloudRules && isRulerAvailable, isRemovable: canRemoveCloudRules && isRulerAvailable,
loading: isLoading || dataSources[rulesSourceName]?.loading, loading: isLoading || dataSources[rulesSourceName]?.loading,

@ -211,7 +211,7 @@ export const mockGrafanaRulerRule = (partial: Partial<GrafanaRuleDefinition> = {
grafana_alert: { grafana_alert: {
uid: '', uid: '',
title: 'my rule', title: 'my rule',
namespace_uid: '', namespace_uid: 'NAMESPACE_UID',
namespace_id: 0, namespace_id: 0,
condition: '', condition: '',
no_data_state: GrafanaAlertStateDecision.NoData, no_data_state: GrafanaAlertStateDecision.NoData,

Loading…
Cancel
Save