Alerting: useAbilities hook (#72626)

This commit is contained in:
Gilles De Mey 2023-09-12 15:20:39 +02:00 committed by GitHub
parent 07d96eb458
commit bd23d48660
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 1093 additions and 349 deletions

View File

@ -1704,6 +1704,10 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/alerting/unified/components/Authorize.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/alerting/unified/components/Expression.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
@ -1825,9 +1829,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/alerting/unified/utils/datasource.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/alerting/unified/utils/misc.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"],

View File

@ -5,21 +5,21 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
import { AccessControlAction } from 'app/types';
import AlertGroups from './AlertGroups';
import { fetchAlertGroups } from './api/alertmanager';
import { mockAlertGroup, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv } from './mocks';
import {
grantUserPermissions,
mockAlertGroup,
mockAlertmanagerAlert,
mockDataSource,
MockDataSourceSrv,
} from './mocks';
import { AlertmanagerProvider } from './state/AlertmanagerContext';
import { DataSourceType } from './utils/datasource';
jest.mock('./api/alertmanager');
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
isEditor: true,
hasAccess: () => true,
hasPermission: () => true,
},
}));
const mocks = {
api: {
fetchAlertGroups: jest.mocked(fetchAlertGroups),
@ -29,7 +29,9 @@ const mocks = {
const renderAmNotifications = () => {
return render(
<TestProvider>
<AlertGroups />
<AlertmanagerProvider accessType={'instance'}>
<AlertGroups />
</AlertmanagerProvider>
</TestProvider>
);
};
@ -57,6 +59,13 @@ const ui = {
describe('AlertGroups', () => {
beforeAll(() => {
grantUserPermissions([
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstanceCreate,
AccessControlAction.AlertingInstancesExternalRead,
AccessControlAction.AlertingRuleRead,
]);
mocks.api.fetchAlertGroups.mockImplementation(() => {
return Promise.resolve([
mockAlertGroup({ labels: {}, alerts: [mockAlertmanagerAlert({ labels: { foo: 'bar' } })] }),

View File

@ -31,7 +31,6 @@ import { updateAlertManagerConfigAction } from './state/actions';
import { FormAmRoute } from './types/amroutes';
import { useRouteGroupsMatcher } from './useRouteGroupsMatcher';
import { addUniqueIdentifierToRoute } from './utils/amroutes';
import { isVanillaPrometheusAlertManagerDataSource } from './utils/datasource';
import { normalizeMatchers } from './utils/matchers';
import { computeInheritedTree } from './utils/notification-policies';
import { initialAsyncRequestState } from './utils/redux';
@ -57,7 +56,7 @@ const AmRoutes = () => {
const [labelMatchersFilter, setLabelMatchersFilter] = useState<ObjectMatcher[]>([]);
const { getRouteGroupsMap } = useRouteGroupsMatcher();
const { selectedAlertmanager } = useAlertmanager();
const { selectedAlertmanager, hasConfigurationAPI } = useAlertmanager();
const contactPointsState = useGetContactPointsState(selectedAlertmanager ?? '');
@ -186,10 +185,6 @@ const AmRoutes = () => {
return null;
}
const vanillaPrometheusAlertManager = isVanillaPrometheusAlertManagerDataSource(selectedAlertmanager);
const readOnlyPolicies = vanillaPrometheusAlertManager;
const readOnlyMuteTimings = vanillaPrometheusAlertManager;
const numberOfMuteTimings = result?.alertmanager_config.mute_time_intervals?.length ?? 0;
const haveData = result && !resultError && !resultLoading;
const isFetching = !result && resultLoading;
@ -246,7 +241,7 @@ const AmRoutes = () => {
currentRoute={rootRoute}
alertGroups={alertGroups ?? []}
contactPointsState={contactPointsState.receivers}
readOnly={readOnlyPolicies}
readOnly={!hasConfigurationAPI}
provisioned={isProvisioned}
alertManagerSourceName={selectedAlertmanager}
onAddPolicy={openAddModal}
@ -265,7 +260,7 @@ const AmRoutes = () => {
</>
)}
{muteTimingsTabActive && (
<MuteTimingsTable alertManagerSourceName={selectedAlertmanager} hideActions={readOnlyMuteTimings} />
<MuteTimingsTable alertManagerSourceName={selectedAlertmanager} hideActions={!hasConfigurationAPI} />
)}
</>
)}

View File

@ -7,7 +7,6 @@ import { byRole, byTestId, byText } from 'testing-library-selector';
import { DataSourceSrv, locationService, logInfo, setBackendSrv, setDataSourceSrv } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { contextSrv } from 'app/core/services/context_srv';
import * as ruleActionButtons from 'app/features/alerting/unified/components/rules/RuleActionsButtons';
import * as actions from 'app/features/alerting/unified/state/actions';
import { AccessControlAction } from 'app/types';
@ -19,7 +18,6 @@ import { discoverFeatures } from './api/buildInfo';
import { fetchRules } from './api/prometheus';
import { deleteNamespace, deleteRulerRulesGroup, fetchRulerRules, setRulerRuleGroup } from './api/ruler';
import {
disableRBAC,
enableRBAC,
grantUserPermissions,
mockDataSource,
@ -137,7 +135,12 @@ beforeAll(() => {
describe('RuleList', () => {
beforeEach(() => {
contextSrv.isEditor = true;
grantUserPermissions([
AccessControlAction.AlertingRuleRead,
AccessControlAction.AlertingRuleUpdate,
AccessControlAction.AlertingRuleExternalRead,
AccessControlAction.AlertingRuleExternalWrite,
]);
mocks.rulesInSameGroupHaveInvalidForMock.mockReturnValue([]);
});
@ -147,7 +150,6 @@ describe('RuleList', () => {
});
it('load & show rule groups from multiple cloud data sources', async () => {
disableRBAC();
mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources));
setDataSourceSrv(new MockDataSourceSrv(dataSources));

View File

@ -14,7 +14,7 @@ import { SilenceState } from '../../../plugins/datasource/alertmanager/types';
import Silences from './Silences';
import { createOrUpdateSilence, fetchAlerts, fetchSilences } from './api/alertmanager';
import { mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks';
import { grantUserPermissions, mockAlertmanagerAlert, mockDataSource, MockDataSourceSrv, mockSilence } from './mocks';
import { parseMatchers } from './utils/alertmanager';
import { DataSourceType } from './utils/datasource';
@ -98,19 +98,13 @@ 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);
grantUserPermissions([
AccessControlAction.AlertingInstanceRead,
AccessControlAction.AlertingInstanceCreate,
AccessControlAction.AlertingInstanceUpdate,
AccessControlAction.AlertingInstancesExternalRead,
AccessControlAction.AlertingInstancesExternalWrite,
]);
};
const setUserLogged = (isLogged: boolean) => {
@ -207,10 +201,7 @@ describe('Silences', () => {
});
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);
});
grantUserPermissions([AccessControlAction.AlertingInstanceRead, AccessControlAction.AlertingInstancesExternalRead]);
renderSilences();
await waitFor(() => expect(mocks.api.fetchSilences).toHaveBeenCalled());

View File

@ -1,17 +1,73 @@
import React from 'react';
import { chain, filter } from 'lodash';
import React, { PropsWithChildren } from 'react';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
import {
Abilities,
Action,
AlertmanagerAction,
AlertSourceAction,
useAlertSourceAbilities,
useAllAlertmanagerAbilities,
} from '../hooks/useAbilities';
type Props = {
actions: AccessControlAction[];
fallback?: boolean;
interface AuthorizeProps extends PropsWithChildren {
actions: AlertmanagerAction[] | AlertSourceAction[];
}
export const Authorize = ({ actions, children }: AuthorizeProps) => {
const alertmanagerActions = filter(actions, isAlertmanagerAction) as AlertmanagerAction[];
const alertSourceActions = filter(actions, isAlertSourceAction) as AlertSourceAction[];
if (alertmanagerActions.length) {
return <AuthorizeAlertmanager actions={alertmanagerActions}>{children}</AuthorizeAlertmanager>;
}
if (alertSourceActions.length) {
return <AuthorizeAlertsource actions={alertSourceActions}>{children}</AuthorizeAlertsource>;
}
return null;
};
export const Authorize = ({ actions, children, fallback = true }: React.PropsWithChildren<Props>) => {
if (actions.some((action) => contextSrv.hasAccess(action, fallback))) {
interface ActionsProps<T extends Action> extends PropsWithChildren {
actions: T[];
}
const AuthorizeAlertmanager = ({ actions, children }: ActionsProps<AlertmanagerAction>) => {
const alertmanagerAbilties = useAllAlertmanagerAbilities();
const allowed = actionsAllowed(alertmanagerAbilties, actions);
if (allowed) {
return <>{children}</>;
} else {
return null;
}
};
const AuthorizeAlertsource = ({ actions, children }: ActionsProps<AlertSourceAction>) => {
const alertSourceAbilities = useAlertSourceAbilities();
const allowed = actionsAllowed(alertSourceAbilities, actions);
if (allowed) {
return <>{children}</>;
} else {
return null;
}
};
// check if some action is allowed from the abilities
function actionsAllowed<T extends Action>(abilities: Abilities<T>, actions: T[]) {
return chain(abilities)
.pick(actions)
.values()
.value()
.some(([_supported, allowed]) => allowed === true);
}
function isAlertmanagerAction(action: AlertmanagerAction) {
return Object.values(AlertmanagerAction).includes(action);
}
function isAlertSourceAction(action: AlertSourceAction) {
return Object.values(AlertSourceAction).includes(action);
}

View File

@ -7,7 +7,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { getInstancesPermissions } from '../../utils/access-control';
import { AlertmanagerAction } from '../../hooks/useAbilities';
import { isGrafanaRulesSource } from '../../utils/datasource';
import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc';
import { AnnotationDetailsField } from '../AnnotationDetailsField';
@ -20,7 +20,6 @@ interface AmNotificationsAlertDetailsProps {
export const AlertDetails = ({ alert, alertManagerSourceName }: AmNotificationsAlertDetailsProps) => {
const styles = useStyles2(getStyles);
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
@ -32,8 +31,8 @@ export const AlertDetails = ({ alert, alertManagerSourceName }: AmNotificationsA
return (
<>
<div className={styles.actionsRow}>
<Authorize actions={[instancePermissions.update, instancePermissions.create]} fallback={contextSrv.isEditor}>
{alert.status.state === AlertState.Suppressed && (
{alert.status.state === AlertState.Suppressed && (
<Authorize actions={[AlertmanagerAction.CreateSilence, AlertmanagerAction.UpdateSilence]}>
<LinkButton
href={`${makeAMLink(
'/alerting/silences',
@ -45,8 +44,10 @@ export const AlertDetails = ({ alert, alertManagerSourceName }: AmNotificationsA
>
Manage silences
</LinkButton>
)}
{alert.status.state === AlertState.Active && (
</Authorize>
)}
{alert.status.state === AlertState.Active && (
<Authorize actions={[AlertmanagerAction.CreateSilence]}>
<LinkButton
href={makeLabelBasedSilenceLink(alertManagerSourceName, alert.labels)}
className={styles.button}
@ -55,8 +56,8 @@ export const AlertDetails = ({ alert, alertManagerSourceName }: AmNotificationsA
>
Silence
</LinkButton>
)}
</Authorize>
</Authorize>
)}
{isSeeSourceButtonEnabled && alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source

View File

@ -27,6 +27,7 @@ import * as receiversApi from '../../api/receiversApi';
import * as grafanaApp from '../../components/receivers/grafanaAppReceivers/grafanaApp';
import { mockApi, setupMswServer } from '../../mockApi';
import {
grantUserPermissions,
mockDataSource,
MockDataSourceSrv,
onCallPluginMetaMock,
@ -86,14 +87,11 @@ const dataSources = {
};
const renderReceivers = (alertManagerSourceName?: string) => {
locationService.push(
'/alerting/notifications' +
(alertManagerSourceName ? `?${ALERTMANAGER_NAME_QUERY_KEY}=${alertManagerSourceName}` : '')
);
locationService.push('/alerting/notifications');
return render(
<TestProvider>
<AlertmanagerProvider accessType="notification">
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}>
<Receivers />
</AlertmanagerProvider>
</TestProvider>
@ -167,26 +165,15 @@ describe('Receivers', () => {
mocks.api.discoverAlertmanagerFeatures.mockResolvedValue({ lazyConfigInit: false });
mocks.hooks.useGetContactPointsState.mockReturnValue(emptyContactPointsState);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
mocks.contextSrv.isEditor = true;
store.delete(ALERTMANAGER_NAME_LOCAL_STORAGE_KEY);
mocks.contextSrv.evaluatePermission.mockImplementation(() => []);
mocks.contextSrv.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return permissions.includes(action as AccessControlAction);
});
// respond with "true" when asked if we are an administrator
mocks.contextSrv.hasRole.mockImplementation((role: string) => {
return role === 'Admin';
});
mocks.contextSrv.hasAccess.mockImplementation(() => true);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
]);
});
it('Template and receiver tables are rendered, alertmanager can be selected, no notification errors', async () => {
@ -340,11 +327,10 @@ describe('Receivers', () => {
mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig);
mocks.api.updateConfig.mockResolvedValue();
mocks.contextSrv.hasPermission.mockImplementation((action) =>
[AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead].some(
(a) => a === action
)
);
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
]);
mocks.hooks.useGetContactPointsState.mockReturnValue(emptyContactPointsState);
renderReceivers();
await ui.receiversTable.find();
@ -523,7 +509,6 @@ describe('Receivers', () => {
expect(templatesTable).toBeInTheDocument();
expect(receiversTable).toBeInTheDocument();
expect(ui.newContactPointButton.get()).toBeInTheDocument();
});
describe('Contact points health', () => {

View File

@ -4,14 +4,13 @@ import React, { useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { IconButton, LinkButton, Link, useStyles2, ConfirmModal } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { MuteTimeInterval } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types/store';
import { Authorize } from '../../components/Authorize';
import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { useAlertmanagerConfig } from '../../hooks/useAlertmanagerConfig';
import { deleteMuteTimingAction } from '../../state/actions';
import { getNotificationsPermissions } from '../../utils/access-control';
import { makeAMLink } from '../../utils/misc';
import { DynamicTable, DynamicTableItemProps, DynamicTableColumnProps } from '../DynamicTable';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
@ -29,7 +28,6 @@ interface Props {
export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hideActions }: Props) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const permissions = getNotificationsPermissions(alertManagerSourceName);
const { currentData } = useAlertmanagerConfig(alertManagerSourceName, {
refetchOnFocus: true,
@ -57,6 +55,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
}, [config?.mute_time_intervals, config?.muteTimeProvenances, muteTimingNames]);
const columns = useColumns(alertManagerSourceName, hideActions, setMuteTimingName);
const [_, allowedToCreateMuteTiming] = useAlertmanagerAbility(AlertmanagerAction.CreateMuteTiming);
return (
<div className={styles.container}>
@ -67,7 +66,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
</span>
<Spacer />
{!hideActions && items.length > 0 && (
<Authorize actions={[permissions.create]}>
<Authorize actions={[AlertmanagerAction.CreateMuteTiming]}>
<LinkButton
className={styles.addMuteButton}
icon="plus"
@ -88,7 +87,7 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
buttonIcon="plus"
buttonSize="lg"
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
showButton={contextSrv.hasPermission(permissions.create)}
showButton={allowedToCreateMuteTiming}
/>
) : (
<EmptyAreaWithCTA text="No mute timings configured" buttonLabel={''} showButton={false} />
@ -111,11 +110,11 @@ export const MuteTimingsTable = ({ alertManagerSourceName, muteTimingNames, hide
};
function useColumns(alertManagerSourceName: string, hideActions = false, setMuteTimingName: (name: string) => void) {
const permissions = getNotificationsPermissions(alertManagerSourceName);
const userHasEditPermissions = contextSrv.hasPermission(permissions.update);
const userHasDeletePermissions = contextSrv.hasPermission(permissions.delete);
const showActions = !hideActions && (userHasEditPermissions || userHasDeletePermissions);
const [[_editSupported, allowedToEdit], [_deleteSupported, allowedToDelete]] = useAlertmanagerAbilities([
AlertmanagerAction.UpdateMuteTiming,
AlertmanagerAction.DeleteMuteTiming,
]);
const showActions = !hideActions && (allowedToEdit || allowedToDelete);
return useMemo((): Array<DynamicTableColumnProps<MuteTimeInterval>> => {
const columns: Array<DynamicTableColumnProps<MuteTimeInterval>> = [
@ -159,7 +158,7 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
}
return (
<div>
<Authorize actions={[permissions.update]}>
<Authorize actions={[AlertmanagerAction.UpdateMuteTiming]}>
<Link
href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, {
muteName: data.name,
@ -168,7 +167,7 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
<IconButton name="edit" tooltip="Edit mute timing" />
</Link>
</Authorize>
<Authorize actions={[permissions.delete]}>
<Authorize actions={[AlertmanagerAction.DeleteMuteTiming]}>
<IconButton
name="trash-alt"
tooltip="Delete mute timing"
@ -182,7 +181,7 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
});
}
return columns;
}, [alertManagerSourceName, setMuteTimingName, showActions, permissions]);
}, [alertManagerSourceName, setMuteTimingName, showActions]);
}
const getStyles = (theme: GrafanaTheme2) => ({

View File

@ -15,6 +15,7 @@ import {
import { ReceiversState } from 'app/types/alerting';
import { mockAlertGroup, mockAlertmanagerAlert, mockReceiversState } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { Policy } from './Policy';
@ -221,7 +222,11 @@ describe('Policy', () => {
});
const renderPolicy = (element: JSX.Element) =>
render(<Router history={locationService.getHistory()}>{element}</Router>);
render(
<Router history={locationService.getHistory()}>
<AlertmanagerProvider accessType="notification">{element}</AlertmanagerProvider>
</Router>
);
const eq = MatcherOperator.equal;

View File

@ -7,18 +7,17 @@ import { Link } from 'react-router-dom';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Badge, Button, Dropdown, getTagColorsFromName, Icon, Menu, Tooltip, useStyles2, Text } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import ConditionalWrap from 'app/features/alerting/components/ConditionalWrap';
import { RouteWithID, Receiver, ObjectMatcher, AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
import { ReceiversState } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbilities } from '../../hooks/useAbilities';
import { INTEGRATION_ICONS } from '../../types/contact-points';
import { getNotificationsPermissions } from '../../utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { normalizeMatchers } from '../../utils/matchers';
import { createContactPointLink, createMuteTimingLink } from '../../utils/misc';
import { getInheritedProperties, InhertitableProperties } from '../../utils/notification-policies';
import { createUrl } from '../../utils/url';
import { Authorize } from '../Authorize';
import { HoverCard } from '../HoverCard';
import { Label } from '../Label';
import { MetaText } from '../MetaText';
@ -70,12 +69,15 @@ const Policy: FC<PolicyComponentProps> = ({
const styles = useStyles2(getStyles);
const isDefaultPolicy = currentRoute === routeTree;
const permissions = getNotificationsPermissions(alertManagerSourceName);
const canEditRoutes = contextSrv.hasPermission(permissions.update);
const canDeleteRoutes = contextSrv.hasPermission(permissions.delete);
const canReadProvisioning =
contextSrv.hasPermission(permissions.provisioning.read) ||
contextSrv.hasPermission(permissions.provisioning.readSecrets);
const [
[updatePoliciesSupported, updatePoliciesAllowed],
[deletePolicySupported, deletePolicyAllowed],
[exportPoliciesSupported, exportPoliciesAllowed],
] = useAlertmanagerAbilities([
AlertmanagerAction.UpdateNotificationPolicyTree,
AlertmanagerAction.DeleteNotificationPolicy,
AlertmanagerAction.ExportNotificationPolicies,
]);
const contactPoint = currentRoute.receiver;
const continueMatching = currentRoute.continue ?? false;
@ -116,9 +118,6 @@ const Policy: FC<PolicyComponentProps> = ({
const customGrouping = !noGrouping && isArray(groupBy) && groupBy.length > 0;
const singleGroup = isDefaultPolicy && isArray(groupBy) && groupBy.length === 0;
const isEditable = canEditRoutes;
const isDeletable = canDeleteRoutes && !isDefaultPolicy;
const matchingAlertGroups = matchingInstancesPreview?.groupsMap?.get(currentRoute.id);
// sum all alert instances for all groups we're handling
@ -126,8 +125,59 @@ const Policy: FC<PolicyComponentProps> = ({
? sumBy(matchingAlertGroups, (group) => group.alerts.length)
: undefined;
const isGrafanaAM = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME;
const showExport = isGrafanaAM && isDefaultPolicy && canReadProvisioning;
const showExportAction = exportPoliciesAllowed && exportPoliciesSupported && isDefaultPolicy;
const showEditAction = updatePoliciesSupported && updatePoliciesAllowed;
const showDeleteAction = deletePolicySupported && deletePolicyAllowed && !isDefaultPolicy;
// build the menu actions for our policy
const dropdownMenuActions: JSX.Element[] = [];
if (showEditAction) {
dropdownMenuActions.push(
<Fragment key="edit-policy">
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
icon="edit"
disabled={provisioned}
label="Edit"
onClick={() => onEditPolicy(currentRoute, isDefaultPolicy)}
/>
</ConditionalWrap>
</Fragment>
);
}
if (showExportAction) {
dropdownMenuActions.push(
<Menu.Item
key="export-policy"
icon="download-alt"
label="Export"
url={createUrl('/api/v1/provisioning/policies/export', {
download: 'true',
format: 'yaml',
})}
target="_blank"
/>
);
}
if (showDeleteAction) {
dropdownMenuActions.push(
<Fragment key="delete-policy">
<Menu.Divider />
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
destructive
icon="trash-alt"
disabled={provisioned}
label="Delete"
onClick={() => onDeletePolicy(currentRoute)}
/>
</ConditionalWrap>
</Fragment>
);
}
// TODO dead branch detection, warnings for all sort of configs that won't work or will never be activated
return (
@ -155,9 +205,9 @@ const Policy: FC<PolicyComponentProps> = ({
{/* TODO maybe we should move errors to the gutter instead? */}
{errors.length > 0 && <Errors errors={errors} />}
{provisioned && <ProvisioningBadge />}
{readOnly && !showExport ? null : (
{!readOnly && (
<Stack direction="row" gap={0.5}>
{!readOnly && (
<Authorize actions={[AlertmanagerAction.CreateNotificationPolicy]}>
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Button
variant="secondary"
@ -170,58 +220,20 @@ const Policy: FC<PolicyComponentProps> = ({
New nested policy
</Button>
</ConditionalWrap>
)}
</Authorize>
<Dropdown
overlay={
<Menu>
{!readOnly && (
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
icon="edit"
disabled={!isEditable || provisioned}
label="Edit"
onClick={() => onEditPolicy(currentRoute, isDefaultPolicy)}
/>
</ConditionalWrap>
)}
{showExport && (
<Menu.Item
icon="download-alt"
label="Export"
url={createUrl('/api/v1/provisioning/policies/export', {
download: 'true',
format: 'yaml',
})}
target="_blank"
/>
)}
{!readOnly && isDeletable && (
<>
<Menu.Divider />
<ConditionalWrap shouldWrap={provisioned} wrap={ProvisionedTooltip}>
<Menu.Item
destructive
icon="trash-alt"
disabled={!isDeletable || provisioned}
label="Delete"
onClick={() => onDeletePolicy(currentRoute)}
/>
</ConditionalWrap>
</>
)}
</Menu>
}
>
<Button
icon="ellipsis-h"
variant="secondary"
size="sm"
type="button"
aria-label="more-actions"
data-testid="more-actions"
/>
</Dropdown>
{dropdownMenuActions.length > 0 && (
<Dropdown overlay={<Menu>{dropdownMenuActions}</Menu>}>
<Button
icon="ellipsis-h"
variant="secondary"
size="sm"
type="button"
aria-label="more-actions"
data-testid="more-actions"
/>
</Dropdown>
)}
</Stack>
)}
</Stack>

View File

@ -3,8 +3,8 @@ import React from 'react';
import { Stack } from '@grafana/experimental';
import { Alert, LinkButton } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { AlertmanagerAction } from '../../hooks/useAbilities';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { Authorize } from '../Authorize';
@ -26,7 +26,7 @@ export const ReceiversAndTemplatesView = ({ config, alertManagerName }: Props) =
<ReceiversTable config={config} alertManagerName={alertManagerName} />
{!isVanillaAM && <TemplatesTable config={config} alertManagerName={alertManagerName} />}
{isCloud && (
<Authorize actions={[AccessControlAction.AlertingNotificationsExternalWrite]}>
<Authorize actions={[AlertmanagerAction.UpdateExternalConfiguration]}>
<Alert severity="info" title="Global config for contact points">
<p>
For each external Alertmanager you can define global settings, like server addresses, usernames and

View File

@ -14,6 +14,7 @@ import { AccessControlAction, ContactPointsState, NotifierDTO, NotifierType } fr
import { backendSrv } from '../../../../../core/services/backend_srv';
import * as receiversApi from '../../api/receiversApi';
import { enableRBAC, grantUserPermissions } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { fetchGrafanaNotifiersAction } from '../../state/actions';
import { GRAFANA_RULES_SOURCE_NAME } from '../../utils/datasource';
import { createUrl } from '../../utils/url';
@ -39,7 +40,9 @@ const renderReceieversTable = async (
return render(
<TestProvider store={store}>
<ReceiversTable config={config} alertManagerName={alertmanagerName} />
<AlertmanagerProvider accessType={'notification'}>
<ReceiversTable config={config} alertManagerName={alertmanagerName} />
</AlertmanagerProvider>
</TestProvider>
);
};

View File

@ -4,14 +4,16 @@ import React, { useMemo, useState } from 'react';
import { dateTime, dateTimeFormat } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Badge, Button, ConfirmModal, Icon, Modal, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { contextSrv } from 'app/core/core';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction, ContactPointsState, NotifiersState, ReceiversState, useDispatch } from 'app/types';
import { ContactPointsState, NotifiersState, ReceiversState, useDispatch } from 'app/types';
import { isOrgAdmin } from '../../../../plugins/admin/permissions';
import { useGetContactPointsState } from '../../api/receiversApi';
import { Authorize } from '../../components/Authorize';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { deleteReceiverAction } from '../../state/actions';
import { getAlertTableStyles } from '../../styles/table';
import { SupportedPlugin } from '../../types/pluginBridges';
@ -34,10 +36,10 @@ interface UpdateActionProps extends ActionProps {
onClickDeleteReceiver: (receiverName: string) => void;
}
function UpdateActions({ permissions, alertManagerName, receiverName, onClickDeleteReceiver }: UpdateActionProps) {
function UpdateActions({ alertManagerName, receiverName, onClickDeleteReceiver }: UpdateActionProps) {
return (
<>
<Authorize actions={[permissions.update]}>
<Authorize actions={[AlertmanagerAction.UpdateContactPoint]}>
<ActionIcon
aria-label="Edit"
data-testid="edit"
@ -49,7 +51,7 @@ function UpdateActions({ permissions, alertManagerName, receiverName, onClickDel
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<Authorize actions={[AlertmanagerAction.DeleteContactPoint]}>
<ActionIcon
onClick={() => onClickDeleteReceiver(receiverName)}
tooltip="Delete contact point"
@ -61,23 +63,13 @@ function UpdateActions({ permissions, alertManagerName, receiverName, onClickDel
}
interface ActionProps {
permissions: {
read: AccessControlAction;
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
provisioning: {
read: AccessControlAction;
readSecrets: AccessControlAction;
};
};
alertManagerName: string;
receiverName: string;
}
function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps) {
function ViewAction({ alertManagerName, receiverName }: ActionProps) {
return (
<Authorize actions={[permissions.update]}>
<Authorize actions={[AlertmanagerAction.UpdateContactPoint]}>
<ActionIcon
data-testid="view"
to={makeAMLink(`/alerting/notifications/receivers/${encodeURIComponent(receiverName)}/edit`, alertManagerName)}
@ -88,10 +80,14 @@ function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps
);
}
function ExportAction({ permissions, receiverName }: ActionProps) {
const canReadSecrets = contextSrv.hasPermission(permissions.provisioning.readSecrets);
function ExportAction({ receiverName }: ActionProps) {
const { selectedAlertmanager } = useAlertmanager();
const canReadSecrets = contextSrv.hasPermission(
getNotificationsPermissions(selectedAlertmanager ?? '').provisioning.readSecrets
);
return (
<Authorize actions={[permissions.provisioning.read, permissions.provisioning.readSecrets]}>
<Authorize actions={[AlertmanagerAction.ExportContactPoint]}>
<ActionIcon
data-testid="export"
to={createUrl(`/api/v1/provisioning/contact-points/export/`, {
@ -294,7 +290,6 @@ interface Props {
export const ReceiversTable = ({ config, alertManagerName }: Props) => {
const dispatch = useDispatch();
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
const permissions = getNotificationsPermissions(alertManagerName);
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
const configHealth = useAlertmanagerConfigHealth(config.alertmanager_config);
@ -305,11 +300,8 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
const [receiverToDelete, setReceiverToDelete] = useState<string>();
const [showCannotDeleteReceiverModal, setShowCannotDeleteReceiverModal] = useState(false);
const isGrafanaAM = alertManagerName === GRAFANA_RULES_SOURCE_NAME;
const showExport =
isGrafanaAM &&
(contextSrv.hasPermission(permissions.provisioning.read) ||
contextSrv.hasPermission(permissions.provisioning.readSecrets));
const [supportsExport, allowedToExport] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint);
const showExport = supportsExport && allowedToExport;
const onClickDeleteReceiver = (receiverName: string): void => {
if (isReceiverUsed(receiverName, config)) {
@ -355,15 +347,16 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => {
contactPointsState,
configHealth,
onClickDeleteReceiver,
permissions,
isVanillaAM
);
const [createSupported, createAllowed] = useAlertmanagerAbility(AlertmanagerAction.CreateContactPoint);
return (
<ReceiversSection
title="Contact points"
description="Define where notifications are sent, for example, email or Slack."
showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)}
showButton={createSupported && createAllowed}
addButtonLabel={'Add contact point'}
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
exportLink={
@ -439,16 +432,6 @@ function useGetColumns(
contactPointsState: ContactPointsState | undefined,
configHealth: AlertmanagerConfigHealth,
onClickDeleteReceiver: (receiverName: string) => void,
permissions: {
read: AccessControlAction;
create: AccessControlAction;
update: AccessControlAction;
delete: AccessControlAction;
provisioning: {
read: AccessControlAction;
readSecrets: AccessControlAction;
};
},
isVanillaAM: boolean
): RowTableColumnProps[] {
const tableStyles = useStyles2(getAlertTableStyles);
@ -510,28 +493,21 @@ function useGetColumns(
renderCell: ({ data: { provisioned, name } }) => (
<Authorize
actions={[
permissions.update,
permissions.delete,
permissions.provisioning.read,
permissions.provisioning.readSecrets,
AlertmanagerAction.UpdateContactPoint,
AlertmanagerAction.DeleteContactPoint,
AlertmanagerAction.ExportContactPoint,
]}
fallback={isOrgAdmin()}
>
<div className={tableStyles.actionsCell}>
{!isVanillaAM && !provisioned && (
<UpdateActions
permissions={permissions}
alertManagerName={alertManagerName}
receiverName={name}
onClickDeleteReceiver={onClickDeleteReceiver}
/>
)}
{(isVanillaAM || provisioned) && (
<ViewAction permissions={permissions} alertManagerName={alertManagerName} receiverName={name} />
)}
{isGrafanaAlertManager && (
<ExportAction permissions={permissions} alertManagerName={alertManagerName} receiverName={name} />
)}
{(isVanillaAM || provisioned) && <ViewAction alertManagerName={alertManagerName} receiverName={name} />}
{isGrafanaAlertManager && <ExportAction alertManagerName={alertManagerName} receiverName={name} />}
</div>
</Authorize>
),

View File

@ -4,11 +4,13 @@ import { Provider } from 'react-redux';
import { Router } from 'react-router-dom';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction } from 'app/types';
import { grantUserPermissions } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
import { TemplatesTable } from './TemplatesTable';
const defaultConfig: AlertManagerCortexConfig = {
@ -25,7 +27,6 @@ jest.mock('app/types', () => ({
}));
jest.mock('app/core/services/context_srv');
const contextSrvMock = jest.mocked(contextSrv);
const renderWithProvider = () => {
const store = configureStore();
@ -33,7 +34,9 @@ const renderWithProvider = () => {
render(
<Provider store={store}>
<Router history={locationService.getHistory()}>
<TemplatesTable config={defaultConfig} alertManagerName={'potato'} />
<AlertmanagerProvider accessType={'notification'}>
<TemplatesTable config={defaultConfig} alertManagerName={'potato'} />
</AlertmanagerProvider>
</Router>
</Provider>
);
@ -42,16 +45,12 @@ const renderWithProvider = () => {
describe('TemplatesTable', () => {
beforeEach(() => {
jest.resetAllMocks();
contextSrvMock.hasAccess.mockImplementation(() => true);
contextSrvMock.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return permissions.includes(action as AccessControlAction);
});
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
]);
});
it('Should render templates table with the correct rows', () => {
renderWithProvider();
@ -64,13 +63,11 @@ describe('TemplatesTable', () => {
expect(within(rows[0]).getByRole('cell', { name: /Copy/i })).toBeInTheDocument();
});
it('Should not render duplicate template button when not having write permissions', () => {
contextSrvMock.hasPermission.mockImplementation((action) => {
const permissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
];
return permissions.includes(action as AccessControlAction);
});
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
]);
renderWithProvider();
const rows = screen.getAllByRole('row', { name: /template1/i });
expect(within(rows[0]).queryByRole('cell', { name: /Copy/i })).not.toBeInTheDocument();

View File

@ -1,14 +1,13 @@
import React, { Fragment, useMemo, useState } from 'react';
import { ConfirmModal, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { Authorize } from '../../components/Authorize';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { deleteTemplateAction } from '../../state/actions';
import { getAlertTableStyles } from '../../styles/table';
import { getNotificationsPermissions } from '../../utils/access-control';
import { makeAMLink } from '../../utils/misc';
import { CollapseToggle } from '../CollapseToggle';
import { DetailsField } from '../DetailsField';
@ -27,7 +26,9 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
const dispatch = useDispatch();
const [expandedTemplates, setExpandedTemplates] = useState<Record<string, boolean>>({});
const tableStyles = useStyles2(getAlertTableStyles);
const permissions = getNotificationsPermissions(alertManagerName);
const [createNotificationTemplateSupported, createNotificationTemplateAllowed] = useAlertmanagerAbility(
AlertmanagerAction.CreateNotificationTemplate
);
const templateRows = useMemo(() => {
const templates = Object.entries(config.template_files);
@ -53,7 +54,7 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
description="Create notification templates to customize your notifications."
addButtonLabel="Add template"
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
showButton={contextSrv.hasPermission(permissions.create)}
showButton={createNotificationTemplateSupported && createNotificationTemplateAllowed}
>
<table className={tableStyles.table} data-testid="templates-table">
<colgroup>
@ -65,7 +66,13 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
<tr>
<th></th>
<th>Template</th>
<Authorize actions={[permissions.update, permissions.delete]}>
<Authorize
actions={[
AlertmanagerAction.CreateNotificationTemplate,
AlertmanagerAction.UpdateNotificationTemplate,
AlertmanagerAction.DeleteNotificationTemplate,
]}
>
<th>Actions</th>
</Authorize>
</tr>
@ -102,7 +109,7 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
/>
)}
{!provenance && (
<Authorize actions={[permissions.update]}>
<Authorize actions={[AlertmanagerAction.UpdateNotificationTemplate]}>
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
@ -113,7 +120,7 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
/>
</Authorize>
)}
{contextSrv.hasPermission(permissions.create) && (
<Authorize actions={[AlertmanagerAction.CreateContactPoint]}>
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/duplicate`,
@ -122,10 +129,9 @@ export const TemplatesTable = ({ config, alertManagerName }: Props) => {
tooltip="Copy template"
icon="copy"
/>
)}
</Authorize>
{!provenance && (
<Authorize actions={[permissions.delete]}>
<Authorize actions={[AlertmanagerAction.DeleteNotificationTemplate]}>
<ActionIcon
onClick={() => setTemplateToDelete(name)}
tooltip="delete template"

View File

@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { byRole, byTestId, byText } from 'testing-library-selector';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types/accessControl';
import 'core-js/stable/structured-clone';
@ -11,7 +10,7 @@ import { TestProvider } from '../../../../../../../test/helpers/TestProvider';
import { MatcherOperator } from '../../../../../../plugins/datasource/alertmanager/types';
import { Labels } from '../../../../../../types/unified-alerting-dto';
import { mockApi, setupMswServer } from '../../../mockApi';
import { mockAlertQuery } from '../../../mocks';
import { grantUserPermissions, mockAlertQuery } from '../../../mocks';
import { mockPreviewApiResponse } from '../../../mocks/alertRuleApi';
import * as dataSource from '../../../utils/datasource';
import { GRAFANA_RULES_SOURCE_NAME } from '../../../utils/datasource';
@ -34,8 +33,6 @@ jest.spyOn(notificationPreview, 'useGetAlertManagersSourceNamesAndImage').mockRe
]);
jest.spyOn(dataSource, 'getDatasourceAPIUid').mockImplementation((ds: string) => ds);
jest.mock('app/core/services/context_srv');
const contextSrvMock = jest.mocked(contextSrv);
const useGetAlertManagersSourceNamesAndImageMock = useGetAlertManagersSourceNamesAndImage as jest.MockedFunction<
typeof useGetAlertManagersSourceNamesAndImage
@ -113,20 +110,19 @@ function mockTwoAlertManagers() {
}
function mockHasEditPermission(enabled: boolean) {
contextSrvMock.accessControlEnabled.mockReturnValue(true);
contextSrvMock.hasAccess.mockImplementation((action) => {
const onlyReadPermissions: string[] = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
];
const readAndWritePermissions: string[] = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return enabled ? readAndWritePermissions.includes(action) : onlyReadPermissions.includes(action);
});
const onlyReadPermissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
];
const readAndWritePermissions = [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return enabled ? grantUserPermissions(readAndWritePermissions) : grantUserPermissions(onlyReadPermissions);
}
const folder: Folder = {

View File

@ -7,7 +7,9 @@ import { Button, Icon, Modal, useStyles2 } from '@grafana/ui';
import { Receiver } from '../../../../../../plugins/datasource/alertmanager/types';
import { Stack } from '../../../../../../plugins/datasource/parca/QueryEditor/Stack';
import { getNotificationsPermissions } from '../../../utils/access-control';
import { AlertmanagerAction } from '../../../hooks/useAbilities';
import { AlertmanagerProvider } from '../../../state/AlertmanagerContext';
import { GRAFANA_DATASOURCE_NAME } from '../../../utils/datasource';
import { makeAMLink } from '../../../utils/misc';
import { Authorize } from '../../Authorize';
import { Matchers } from '../../notification-policies/Matchers';
@ -57,54 +59,57 @@ export function NotificationRouteDetailsModal({
const styles = useStyles2(getStyles);
const isDefault = isDefaultPolicy(route);
const permissions = getNotificationsPermissions(alertManagerSourceName);
return (
<Modal
className={styles.detailsModal}
isOpen={true}
title="Routing details"
onDismiss={onClose}
onClickBackdrop={onClose}
>
<Stack gap={0} direction="column">
<div className={cx(styles.textMuted, styles.marginBottom(2))}>Your alert instances are routed as follows.</div>
<div>Notification policy path</div>
{isDefault && <div className={styles.textMuted}>Default policy</div>}
<div className={styles.separator(1)} />
{!isDefault && (
<>
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
</>
)}
<div className={styles.separator(4)} />
<div className={styles.contactPoint}>
<Stack gap={1} direction="row" alignItems="center">
Contact point:
<span className={styles.textMuted}>{receiver.name}</span>
</Stack>
<Authorize actions={[permissions.update]}>
<AlertmanagerProvider accessType="notification" alertmanagerSourceName={GRAFANA_DATASOURCE_NAME}>
<Modal
className={styles.detailsModal}
isOpen={true}
title="Routing details"
onDismiss={onClose}
onClickBackdrop={onClose}
>
<Stack gap={0} direction="column">
<div className={cx(styles.textMuted, styles.marginBottom(2))}>
Your alert instances are routed as follows.
</div>
<div>Notification policy path</div>
{isDefault && <div className={styles.textMuted}>Default policy</div>}
<div className={styles.separator(1)} />
{!isDefault && (
<>
<PolicyPath route={route} routesByIdMap={routesByIdMap} />
</>
)}
<div className={styles.separator(4)} />
<div className={styles.contactPoint}>
<Stack gap={1} direction="row" alignItems="center">
<a
href={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerSourceName
)}
className={styles.link}
target="_blank"
rel="noreferrer"
>
See details <Icon name="external-link-alt" />
</a>
Contact point:
<span className={styles.textMuted}>{receiver.name}</span>
</Stack>
</Authorize>
</div>
<div className={styles.button}>
<Button variant="primary" type="button" onClick={onClose}>
Close
</Button>
</div>
</Stack>
</Modal>
<Authorize actions={[AlertmanagerAction.UpdateContactPoint]}>
<Stack gap={1} direction="row" alignItems="center">
<a
href={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerSourceName
)}
className={styles.link}
target="_blank"
rel="noreferrer"
>
See details <Icon name="external-link-alt" />
</a>
</Stack>
</Authorize>
</div>
<div className={styles.button}>
<Button variant="primary" type="button" onClick={onClose}>
Close
</Button>
</div>
</Stack>
</Modal>
</AlertmanagerProvider>
);
}

View File

@ -14,11 +14,10 @@ import {
import { config } from '@grafana/runtime';
import { DataSourceRef } from '@grafana/schema';
import { DateTimePicker, LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/core';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { AccessControlAction } from 'app/types';
import { AlertDataQuery, AlertQuery } from 'app/types/unified-alerting-dto';
import { Authorize } from '../Authorize';
import { VizWrapper } from '../rule-editor/VizWrapper';
import { ThresholdDefinition } from '../rule-editor/util';
@ -64,6 +63,8 @@ export function RuleViewerVisualization({
return null;
}
const allowedToExploreDataSources = contextSrv.hasAccessToExplore();
return (
<div className={className}>
<div className={styles.header}>
@ -71,19 +72,18 @@ export function RuleViewerVisualization({
{!isExpression && relativeTimeRange ? (
<DateTimePicker date={setDateTime(relativeTimeRange.to)} onChange={onTimeChange} maxDate={new Date()} />
) : null}
<Authorize actions={[AccessControlAction.DataSourcesExplore]}>
{!isExpression && (
<LinkButton
size="md"
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, model)}
>
View in Explore
</LinkButton>
)}
</Authorize>
{allowedToExploreDataSources && !isExpression && (
<LinkButton
size="md"
variant="secondary"
icon="compass"
target="_blank"
href={createExploreLink(dsSettings, model)}
>
View in Explore
</LinkButton>
)}
</div>
</div>
<VizWrapper data={data} thresholds={thresholds?.config} thresholdsType={thresholds?.mode} />

View File

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

View File

@ -5,12 +5,11 @@ import { dateMath, GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { CollapsableSection, Icon, Link, LinkButton, useStyles2 } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerAlert, Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { expireSilenceAction } from '../../state/actions';
import { getInstancesPermissions } from '../../utils/access-control';
import { parseMatchers } from '../../utils/alertmanager';
import { getSilenceFiltersFromUrlParams, makeAMLink } from '../../utils/misc';
import { Authorize } from '../Authorize';
@ -41,7 +40,6 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }:
const [queryParams] = useQueryParams();
const filteredSilencesNotExpired = useFilteredSilences(silences, false);
const filteredSilencesExpired = useFilteredSilences(silences, true);
const permissions = getInstancesPermissions(alertManagerSourceName);
const { silenceState: silenceStateInParams } = getSilenceFiltersFromUrlParams(queryParams);
const showExpiredFromUrl = silenceStateInParams === SilenceState.Expired;
@ -77,7 +75,7 @@ const SilencesTable = ({ silences, alertManagerAlerts, alertManagerSourceName }:
{!!silences.length && (
<Stack direction="column">
<SilencesFilter />
<Authorize actions={[permissions.create]} fallback={contextSrv.isEditor}>
<Authorize actions={[AlertmanagerAction.CreateSilence]}>
<div className={styles.topButtonContainer}>
<LinkButton href={makeAMLink('/alerting/silence/new', alertManagerSourceName)} icon="plus">
Add Silence
@ -205,12 +203,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
function useColumns(alertManagerSourceName: string) {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const permissions = getInstancesPermissions(alertManagerSourceName);
const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence);
return useMemo((): SilenceTableColumnProps[] => {
const handleExpireSilenceClick = (id: string) => {
dispatch(expireSilenceAction(alertManagerSourceName, id));
};
const showActions = contextSrv.hasAccess(permissions.update, contextSrv.isEditor);
const columns: SilenceTableColumnProps[] = [
{
id: 'state',
@ -254,7 +252,7 @@ function useColumns(alertManagerSourceName: string) {
size: 7,
},
];
if (showActions) {
if (updateSupported && updateAllowed) {
columns.push({
id: 'actions',
label: 'Actions',
@ -285,6 +283,6 @@ function useColumns(alertManagerSourceName: string) {
});
}
return columns;
}, [alertManagerSourceName, dispatch, styles, permissions]);
}, [alertManagerSourceName, dispatch, styles.editButton, updateAllowed, updateSupported]);
}
export default SilencesTable;

View File

@ -0,0 +1,292 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`alertmanager abilities should report Create / Update / Delete actions aren't supported for external vanilla alertmanager 1`] = `
{
"create-contact-point": [
false,
false,
],
"create-mute-timing": [
false,
false,
],
"create-notification-policy": [
false,
false,
],
"create-notification-template": [
false,
false,
],
"create-silence": [
false,
false,
],
"delete-contact-point": [
false,
false,
],
"delete-mute-timing": [
false,
false,
],
"delete-notification-policy": [
false,
false,
],
"delete-notification-template": [
false,
false,
],
"edit-contact-points": [
false,
false,
],
"edit-notification-template": [
false,
false,
],
"export-contact-point": [
false,
false,
],
"export-notification-policies": [
false,
false,
],
"update-external-configuration": [
false,
false,
],
"update-mute-timing": [
false,
false,
],
"update-notification-policy-tree": [
false,
false,
],
"update-silence": [
false,
false,
],
"view-contact-point": [
true,
false,
],
"view-external-configuration": [
true,
false,
],
"view-mute-timing": [
true,
false,
],
"view-notification-policy-tree": [
true,
false,
],
"view-notification-template": [
true,
false,
],
"view-silence": [
true,
false,
],
}
`;
exports[`alertmanager abilities should report everything except exporting for Mimir alertmanager 1`] = `
{
"create-contact-point": [
true,
true,
],
"create-mute-timing": [
true,
true,
],
"create-notification-policy": [
true,
true,
],
"create-notification-template": [
true,
true,
],
"create-silence": [
true,
true,
],
"delete-contact-point": [
true,
true,
],
"delete-mute-timing": [
true,
true,
],
"delete-notification-policy": [
true,
true,
],
"delete-notification-template": [
true,
true,
],
"edit-contact-points": [
true,
true,
],
"edit-notification-template": [
true,
true,
],
"export-contact-point": [
false,
false,
],
"export-notification-policies": [
false,
false,
],
"update-external-configuration": [
true,
true,
],
"update-mute-timing": [
true,
true,
],
"update-notification-policy-tree": [
true,
true,
],
"update-silence": [
true,
true,
],
"view-contact-point": [
true,
true,
],
"view-external-configuration": [
true,
true,
],
"view-mute-timing": [
true,
true,
],
"view-notification-policy-tree": [
true,
true,
],
"view-notification-template": [
true,
true,
],
"view-silence": [
true,
true,
],
}
`;
exports[`alertmanager abilities should report everything is supported for builtin alertmanager 1`] = `
{
"create-contact-point": [
true,
false,
],
"create-mute-timing": [
true,
false,
],
"create-notification-policy": [
true,
false,
],
"create-notification-template": [
true,
false,
],
"create-silence": [
true,
false,
],
"delete-contact-point": [
true,
false,
],
"delete-mute-timing": [
true,
false,
],
"delete-notification-policy": [
true,
false,
],
"delete-notification-template": [
true,
false,
],
"edit-contact-points": [
true,
false,
],
"edit-notification-template": [
true,
false,
],
"export-contact-point": [
true,
false,
],
"export-notification-policies": [
true,
false,
],
"update-external-configuration": [
true,
false,
],
"update-mute-timing": [
true,
false,
],
"update-notification-policy-tree": [
true,
false,
],
"update-silence": [
true,
false,
],
"view-contact-point": [
true,
true,
],
"view-external-configuration": [
true,
false,
],
"view-mute-timing": [
true,
true,
],
"view-notification-policy-tree": [
true,
true,
],
"view-notification-template": [
true,
true,
],
"view-silence": [
true,
true,
],
}
`;

View File

@ -0,0 +1,145 @@
import { renderHook } from '@testing-library/react';
import { createBrowserHistory } from 'history';
import React, { PropsWithChildren } from 'react';
import { Router } from 'react-router-dom';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { grantUserPermissions, mockDataSource } from '../mocks';
import { AlertmanagerProvider } from '../state/AlertmanagerContext';
import { setupDataSources } from '../testSetup/datasources';
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import {
AlertmanagerAction,
useAlertmanagerAbilities,
useAlertmanagerAbility,
useAllAlertmanagerAbilities,
} from './useAbilities';
/**
* This test will write snapshots with a record of the current permissions assigned to actions.
* We encourage that every change to the snapshot is inspected _very_ thoroughly!
*/
describe('alertmanager abilities', () => {
it("should report Create / Update / Delete actions aren't supported for external vanilla alertmanager", () => {
setupDataSources(
mockDataSource<AlertManagerDataSourceJsonData>({
name: GRAFANA_RULES_SOURCE_NAME,
type: DataSourceType.Alertmanager,
jsonData: { implementation: AlertManagerImplementation.prometheus },
})
);
const abilities = renderHook(() => useAllAlertmanagerAbilities(), { wrapper: createWrapper('does-not-exist') });
expect(abilities.result.current).toMatchSnapshot();
});
it('should report everything is supported for builtin alertmanager', () => {
setupDataSources(
mockDataSource<AlertManagerDataSourceJsonData>({
name: GRAFANA_RULES_SOURCE_NAME,
type: DataSourceType.Alertmanager,
})
);
grantUserPermissions([AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingInstanceRead]);
const abilities = renderHook(() => useAllAlertmanagerAbilities(), {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME),
});
Object.values(abilities.result.current).forEach(([supported]) => {
expect(supported).toBe(true);
});
// since we only granted "read" permissions, only those should be allowed
const viewAbility = renderHook(() => useAlertmanagerAbility(AlertmanagerAction.ViewSilence), {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME),
});
const [viewSupported, viewAllowed] = viewAbility.result.current;
expect(viewSupported).toBe(true);
expect(viewAllowed).toBe(true);
// editing should not be allowed, but supported
const editAbility = renderHook(() => useAlertmanagerAbility(AlertmanagerAction.ViewSilence), {
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME),
});
const [editSupported, editAllowed] = editAbility.result.current;
expect(editSupported).toBe(true);
expect(editAllowed).toBe(true);
// record the snapshot to prevent future regressions
expect(abilities.result.current).toMatchSnapshot();
});
it('should report everything except exporting for Mimir alertmanager', () => {
setupDataSources(
mockDataSource<AlertManagerDataSourceJsonData>({
name: 'mimir',
type: DataSourceType.Alertmanager,
jsonData: {
implementation: AlertManagerImplementation.mimir,
},
})
);
grantUserPermissions([
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
AccessControlAction.AlertingInstancesExternalRead,
AccessControlAction.AlertingInstancesExternalWrite,
]);
const abilities = renderHook(() => useAllAlertmanagerAbilities(), {
wrapper: createWrapper('mimir'),
});
expect(abilities.result.current).toMatchSnapshot();
});
it('should be able to return multiple abilities', () => {
setupDataSources(
mockDataSource<AlertManagerDataSourceJsonData>({
name: GRAFANA_RULES_SOURCE_NAME,
type: DataSourceType.Alertmanager,
})
);
grantUserPermissions([AccessControlAction.AlertingNotificationsRead]);
const abilities = renderHook(
() =>
useAlertmanagerAbilities([
AlertmanagerAction.ViewContactPoint,
AlertmanagerAction.CreateContactPoint,
AlertmanagerAction.ExportContactPoint,
]),
{
wrapper: createWrapper(GRAFANA_RULES_SOURCE_NAME),
}
);
expect(abilities.result.current).toHaveLength(3);
expect(abilities.result.current[0]).toStrictEqual([true, true]);
expect(abilities.result.current[1]).toStrictEqual([true, false]);
expect(abilities.result.current[2]).toStrictEqual([true, false]);
});
});
function createWrapper(alertmanagerSourceName: string) {
const wrapper = (props: PropsWithChildren) => (
<Router history={createBrowserHistory()}>
<AlertmanagerProvider accessType={'notification'} alertmanagerSourceName={alertmanagerSourceName}>
{props.children}
</AlertmanagerProvider>
</Router>
);
return wrapper;
}

View File

@ -0,0 +1,205 @@
import { useMemo } from 'react';
import { contextSrv as ctx } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
import { useAlertmanager } from '../state/AlertmanagerContext';
import { getInstancesPermissions, getNotificationsPermissions } from '../utils/access-control';
/**
* These hooks will determine if
* 1. the action is supported in the current alertmanager or data source context
* 2. user is allowed to perform actions based on their set of permissions / assigned role
*/
export enum AlertmanagerAction {
// configuration
ViewExternalConfiguration = 'view-external-configuration',
UpdateExternalConfiguration = 'update-external-configuration',
// contact points
CreateContactPoint = 'create-contact-point',
ViewContactPoint = 'view-contact-point',
UpdateContactPoint = 'edit-contact-points',
DeleteContactPoint = 'delete-contact-point',
ExportContactPoint = 'export-contact-point',
// notification templates
CreateNotificationTemplate = 'create-notification-template',
ViewNotificationTemplate = 'view-notification-template',
UpdateNotificationTemplate = 'edit-notification-template',
DeleteNotificationTemplate = 'delete-notification-template',
// notification policies
CreateNotificationPolicy = 'create-notification-policy',
ViewNotificationPolicyTree = 'view-notification-policy-tree',
UpdateNotificationPolicyTree = 'update-notification-policy-tree',
DeleteNotificationPolicy = 'delete-notification-policy',
ExportNotificationPolicies = 'export-notification-policies',
// silences these cannot be deleted only "expired" (updated)
CreateSilence = 'create-silence',
ViewSilence = 'view-silence',
UpdateSilence = 'update-silence',
// mute timings
ViewMuteTiming = 'view-mute-timing',
CreateMuteTiming = 'create-mute-timing',
UpdateMuteTiming = 'update-mute-timing',
DeleteMuteTiming = 'delete-mute-timing',
}
export enum AlertSourceAction {
// internal (Grafana managed)
CreateAlertRule = 'create-alert-rule',
ViewAlertRule = 'view-alert-rule',
UpdateAlertRule = 'update-alert-rule',
DeleteAlertRule = 'delete-alert-rule',
// external (any compatible alerting data source)
CreateExternalAlertRule = 'create-external-alert-rule',
ViewExternalAlertRule = 'view-external-alert-rule',
UpdateExternalAlertRule = 'update-external-alert-rule',
DeleteExternalAlertRule = 'delete-external-alert-rule',
}
const AlwaysSupported = true; // this just makes it easier to understand the code
export type Action = AlertmanagerAction | AlertSourceAction;
export type Ability = [actionSupported: boolean, actionAllowed: boolean];
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
const abilities: Abilities<AlertSourceAction> = {
// -- Grafana managed alert rules --
[AlertSourceAction.CreateAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleCreate)],
[AlertSourceAction.ViewAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleRead)],
[AlertSourceAction.UpdateAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleUpdate)],
[AlertSourceAction.DeleteAlertRule]: [AlwaysSupported, ctx.hasPermission(AccessControlAction.AlertingRuleDelete)],
// -- External alert rules (Mimir / Loki / etc) --
// for these we only have "read" and "write" permissions
[AlertSourceAction.CreateExternalAlertRule]: [
AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingRuleExternalWrite),
],
[AlertSourceAction.ViewExternalAlertRule]: [
AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingRuleExternalRead),
],
[AlertSourceAction.UpdateExternalAlertRule]: [
AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingRuleExternalWrite),
],
[AlertSourceAction.DeleteExternalAlertRule]: [
AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingRuleExternalWrite),
],
};
return abilities;
}
export function useAllAlertmanagerAbilities(): Abilities<AlertmanagerAction> {
const {
selectedAlertmanager,
hasConfigurationAPI,
isGrafanaAlertmanager: isGrafanaFlavoredAlertmanager,
} = useAlertmanager();
// These are used for interacting with Alertmanager resources where we apply alert.notifications:<name> permissions.
// There are different permissions based on wether the built-in alertmanager is selected (grafana) or an external one.
const notificationsPermissions = getNotificationsPermissions(selectedAlertmanager!);
const instancePermissions = getInstancesPermissions(selectedAlertmanager!);
// list out all of the abilities, and if the user has permissions to perform them
const abilities: Abilities<AlertmanagerAction> = {
// -- configuration --
[AlertmanagerAction.ViewExternalConfiguration]: [
AlwaysSupported,
ctx.hasPermission(AccessControlAction.AlertingNotificationsExternalRead),
],
[AlertmanagerAction.UpdateExternalConfiguration]: [
hasConfigurationAPI,
ctx.hasPermission(AccessControlAction.AlertingNotificationsExternalWrite),
],
// -- contact points --
[AlertmanagerAction.CreateContactPoint]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.create)],
[AlertmanagerAction.ViewContactPoint]: [AlwaysSupported, ctx.hasPermission(notificationsPermissions.read)],
[AlertmanagerAction.UpdateContactPoint]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.update)],
[AlertmanagerAction.DeleteContactPoint]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.delete)],
// only Grafana flavored alertmanager supports exporting
[AlertmanagerAction.ExportContactPoint]: [
isGrafanaFlavoredAlertmanager,
ctx.hasPermission(notificationsPermissions.provisioning.read) ||
ctx.hasPermission(notificationsPermissions.provisioning.readSecrets),
],
// -- notification templates --
[AlertmanagerAction.CreateNotificationTemplate]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.create),
],
[AlertmanagerAction.ViewNotificationTemplate]: [AlwaysSupported, ctx.hasPermission(notificationsPermissions.read)],
[AlertmanagerAction.UpdateNotificationTemplate]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.update),
],
[AlertmanagerAction.DeleteNotificationTemplate]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.delete),
],
// -- notification policies --
[AlertmanagerAction.CreateNotificationPolicy]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.create),
],
[AlertmanagerAction.ViewNotificationPolicyTree]: [
AlwaysSupported,
ctx.hasPermission(notificationsPermissions.read),
],
[AlertmanagerAction.UpdateNotificationPolicyTree]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.update),
],
[AlertmanagerAction.DeleteNotificationPolicy]: [
hasConfigurationAPI,
ctx.hasPermission(notificationsPermissions.delete),
],
[AlertmanagerAction.ExportNotificationPolicies]: [
isGrafanaFlavoredAlertmanager,
ctx.hasPermission(notificationsPermissions.provisioning.read) ||
ctx.hasPermission(notificationsPermissions.provisioning.readSecrets),
],
// -- silences --
[AlertmanagerAction.CreateSilence]: [hasConfigurationAPI, ctx.hasPermission(instancePermissions.create)],
[AlertmanagerAction.ViewSilence]: [AlwaysSupported, ctx.hasPermission(instancePermissions.read)],
[AlertmanagerAction.UpdateSilence]: [hasConfigurationAPI, ctx.hasPermission(instancePermissions.update)],
// -- mute timtings --
[AlertmanagerAction.CreateMuteTiming]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.create)],
[AlertmanagerAction.ViewMuteTiming]: [AlwaysSupported, ctx.hasPermission(notificationsPermissions.read)],
[AlertmanagerAction.UpdateMuteTiming]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.update)],
[AlertmanagerAction.DeleteMuteTiming]: [hasConfigurationAPI, ctx.hasPermission(notificationsPermissions.delete)],
};
return abilities;
}
export function useAlertmanagerAbility(action: AlertmanagerAction): Ability {
const abilities = useAllAlertmanagerAbilities();
return useMemo(() => {
return abilities[action];
}, [abilities, action]);
}
export function useAlertmanagerAbilities(actions: AlertmanagerAction[]): Ability[] {
const abilities = useAllAlertmanagerAbilities();
return useMemo(() => {
return actions.map((action) => abilities[action]);
}, [abilities, actions]);
}
export function useAlertSourceAbility(action: AlertSourceAction): Ability {
const abilities = useAlertSourceAbilities();
return useMemo(() => abilities[action], [abilities, action]);
}

View File

@ -4,12 +4,13 @@ import React from 'react';
import { MemoryRouter, Router } from 'react-router-dom';
import store from 'app/core/store';
import { AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import * as useAlertManagerSources from '../hooks/useAlertManagerSources';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY } from '../utils/constants';
import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { AlertmanagerProvider, useAlertmanager } from './AlertmanagerContext';
import { AlertmanagerProvider, isAlertManagerWithConfigAPI, useAlertmanager } from './AlertmanagerContext';
const grafanaAm: AlertManagerDataSource = {
name: GRAFANA_RULES_SOURCE_NAME,
@ -118,3 +119,23 @@ describe('useAlertmanager', () => {
expect(result.current.selectedAlertmanager).toBe(externalAmProm.name);
});
});
test('isAlertManagerWithConfigAPI', () => {
expect(
isAlertManagerWithConfigAPI({
implementation: undefined,
})
).toBe(true);
expect(
isAlertManagerWithConfigAPI({
implementation: AlertManagerImplementation.mimir,
})
).toBe(true);
expect(
isAlertManagerWithConfigAPI({
implementation: AlertManagerImplementation.prometheus,
})
).toBe(false);
});

View File

@ -2,13 +2,21 @@ import * as React from 'react';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import store from 'app/core/store';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources';
import { ALERTMANAGER_NAME_LOCAL_STORAGE_KEY, ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants';
import { AlertManagerDataSource, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import {
AlertManagerDataSource,
getAlertmanagerDataSourceByName,
GRAFANA_RULES_SOURCE_NAME,
} from '../utils/datasource';
interface Context {
selectedAlertmanager: string | undefined;
hasConfigurationAPI: boolean; // returns true when a configuration API is available
isGrafanaAlertmanager: boolean; // returns true if we are dealing with the built-in Alertmanager
selectedAlertmanagerConfig: AlertManagerDataSourceJsonData | undefined;
availableAlertManagers: AlertManagerDataSource[];
setSelectedAlertmanager: (name: string) => void;
}
@ -17,9 +25,11 @@ const AlertmanagerContext = React.createContext<Context | undefined>(undefined);
interface Props extends React.PropsWithChildren {
accessType: 'instance' | 'notification';
// manually setting the alertmanagersource name will override all of the other sources
alertmanagerSourceName?: string;
}
const AlertmanagerProvider = ({ children, accessType }: Props) => {
const AlertmanagerProvider = ({ children, accessType, alertmanagerSourceName }: Props) => {
const [queryParams, updateQueryParams] = useQueryParams();
const availableAlertManagers = useAlertManagersByPermission(accessType);
@ -45,13 +55,26 @@ const AlertmanagerProvider = ({ children, accessType }: Props) => {
const defaultSource = GRAFANA_RULES_SOURCE_NAME;
// queryParam > localStorage > default
const desiredAlertmanager = sourceFromQuery ?? sourceFromStore ?? defaultSource;
const desiredAlertmanager = alertmanagerSourceName ?? sourceFromQuery ?? sourceFromStore ?? defaultSource;
const selectedAlertmanager = isAlertManagerAvailable(availableAlertManagers, desiredAlertmanager)
? desiredAlertmanager
: undefined;
const selectedAlertmanagerConfig = getAlertmanagerDataSourceByName(selectedAlertmanager)?.jsonData;
// determine if we're dealing with an Alertmanager data source that supports the ruler API
const isGrafanaAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
const isAlertmanagerWithConfigAPI = selectedAlertmanagerConfig
? isAlertManagerWithConfigAPI(selectedAlertmanagerConfig)
: false;
const hasConfigurationAPI = isGrafanaAlertmanager || isAlertmanagerWithConfigAPI;
const value: Context = {
selectedAlertmanager,
hasConfigurationAPI,
isGrafanaAlertmanager,
selectedAlertmanagerConfig,
availableAlertManagers,
setSelectedAlertmanager: updateSelectedAlertmanager,
};
@ -75,3 +98,15 @@ function isAlertManagerAvailable(availableAlertManagers: AlertManagerDataSource[
const availableAlertManagersNames = availableAlertManagers.map((am) => am.name);
return availableAlertManagersNames.includes(alertManagerName);
}
// when the `implementation` is set to `undefined` we assume we're dealing with an AlertManager with config API. The reason for this is because
// our Hosted Grafana stacks provision Alertmanager data sources without `jsonData: { implementation: "mimir" }`.
const CONFIG_API_ENABLED_ALERTMANAGER_FLAVORS = [
AlertManagerImplementation.mimir,
AlertManagerImplementation.cortex,
undefined,
];
export function isAlertManagerWithConfigAPI(dataSourceConfig: AlertManagerDataSourceJsonData): boolean {
return CONFIG_API_ENABLED_ALERTMANAGER_FLAVORS.includes(dataSourceConfig.implementation);
}

View File

@ -1,9 +1,12 @@
import { DataSourceInstanceSettings, DataSourceJsonData } from '@grafana/data';
import { config } from '@grafana/runtime';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { isValidPrometheusDuration, parsePrometheusDuration } from './time';
export function getAllDataSources(): Array<DataSourceInstanceSettings<DataSourceJsonData>> {
export function getAllDataSources(): Array<
DataSourceInstanceSettings<DataSourceJsonData | AlertManagerDataSourceJsonData>
> {
return Object.values(config.datasources);
}

View File

@ -137,8 +137,7 @@ export function isCloudRulesSource(rulesSource: RulesSource | string): rulesSour
export function isVanillaPrometheusAlertManagerDataSource(name: string): boolean {
return (
name !== GRAFANA_RULES_SOURCE_NAME &&
(getDataSourceByName(name)?.jsonData as AlertManagerDataSourceJsonData)?.implementation ===
AlertManagerImplementation.prometheus
getAlertmanagerDataSourceByName(name)?.jsonData?.implementation === AlertManagerImplementation.prometheus
);
}
@ -152,6 +151,13 @@ export function getDataSourceByName(name: string): DataSourceInstanceSettings<Da
return getAllDataSources().find((source) => source.name === name);
}
export function getAlertmanagerDataSourceByName(name: string) {
return getAllDataSources().find(
(source): source is DataSourceInstanceSettings<AlertManagerDataSourceJsonData> =>
source.name === name && source.type === 'alertmanager'
);
}
export function getRulesSourceByName(name: string): RulesSource | undefined {
if (name === GRAFANA_RULES_SOURCE_NAME) {
return GRAFANA_RULES_SOURCE_NAME;