Alerting: fgac for notification policies and contact points (#46939)

* add FGAC actions for silences table

* redirect users without permissions

* add permissions checks to routes

* add fgac to notifications and contact points

* fgac for notification policies

* fix mute timing authorization

* use consistent naming for checking grafana alertmanager

* tests for fgac in contact points and notification policies

* bump up timeout on rule editor test

* use new permissions util

* break out route evaluation into util

* Remove test timeout

* Change permissions for the alert-notifiers endpoint

* Use signed in handler for alert-notifiers when unified alerting enabled

Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Nathan Rodman 2022-04-06 09:24:33 -07:00 committed by GitHub
parent 460b8e85d7
commit 49505b9a3b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 399 additions and 151 deletions

View File

@ -14,6 +14,7 @@ import (
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/web"
)
var plog = log.New("api")
@ -417,7 +418,14 @@ func (hs *HTTPServer) registerRoutes() {
alertsRoute.Get("/states-for-dashboard", routing.Wrap(hs.GetAlertStatesForDashboard))
})
apiRoute.Get("/alert-notifiers", reqEditorRole, routing.Wrap(
var notifiersAuthHandler web.Handler
if hs.Cfg.UnifiedAlerting.IsEnabled() {
notifiersAuthHandler = reqSignedIn
} else {
notifiersAuthHandler = reqEditorRole
}
apiRoute.Get("/alert-notifiers", notifiersAuthHandler, routing.Wrap(
hs.GetAlertNotifiers(hs.Cfg.UnifiedAlerting.IsEnabled())),
)

View File

@ -6,6 +6,7 @@ import { RouteDescriptor } from 'app/core/navigation/types';
import { uniq } from 'lodash';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { evaluateAccess } from './unified/utils/access-control';
const commonRoutes: RouteDescriptor[] = [
{
@ -95,84 +96,118 @@ const unifiedRoutes: RouteDescriptor[] = [
},
{
path: '/alerting/routes',
roles: () => ['Admin', 'Editor'],
roles: () =>
contextSrv.evaluatePermission(config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : () => [], [
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
]),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertAmRoutes" */ 'app/features/alerting/unified/AmRoutes')
),
},
{
path: '/alerting/routes/mute-timing/new',
roles: () => ['Admin', 'Editor'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsCreate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings')
),
},
{
path: '/alerting/routes/mute-timing/edit',
roles: () => ['Admin', 'Editor'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "MuteTimings" */ 'app/features/alerting/unified/MuteTimings')
),
},
{
path: '/alerting/silences',
roles: () => contextSrv.evaluatePermission(() => [], [AccessControlAction.AlertingInstanceRead]),
roles: evaluateAccess([AccessControlAction.AlertingInstanceRead], ['Editor', 'Admin']),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),
},
{
path: '/alerting/silence/new',
roles: () => contextSrv.evaluatePermission(() => ['Editor', 'Admin'], [AccessControlAction.AlertingInstanceCreate]),
roles: evaluateAccess(
[AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingInstancesExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),
},
{
path: '/alerting/silence/:id/edit',
roles: () => contextSrv.evaluatePermission(() => ['Editor', 'Admin'], [AccessControlAction.AlertingInstanceUpdate]),
roles: evaluateAccess(
[AccessControlAction.AlertingInstanceUpdate, AccessControlAction.AlertingInstancesExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "AlertSilences" */ 'app/features/alerting/unified/Silences')
),
},
{
path: '/alerting/notifications',
roles: config.unifiedAlertingEnabled ? () => ['Editor', 'Admin'] : undefined,
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/templates/new',
roles: () => ['Editor', 'Admin'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsCreate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/templates/:id/edit',
roles: () => ['Editor', 'Admin'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/receivers/new',
roles: () => ['Editor', 'Admin'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsCreate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/receivers/:id/edit',
roles: () => ['Editor', 'Admin'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),
},
{
path: '/alerting/notifications/global-config',
roles: () => ['Admin', 'Editor'],
roles: evaluateAccess(
[AccessControlAction.AlertingNotificationsUpdate, AccessControlAction.AlertingNotificationsExternalWrite],
['Editor', 'Admin']
),
component: SafeDynamicImport(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
),

View File

@ -20,9 +20,12 @@ import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from './utils/datasource';
import userEvent from '@testing-library/user-event';
import { selectOptionInTest } from '@grafana/ui';
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
jest.mock('./api/alertmanager');
jest.mock('./utils/config');
jest.mock('app/core/services/context_srv');
const mocks = {
getAllDataSourcesMock: jest.mocked(getAllDataSources),
@ -32,6 +35,7 @@ const mocks = {
updateAlertManagerConfig: jest.mocked(updateAlertManagerConfig),
fetchStatus: jest.mocked(fetchStatus),
},
contextSrv: jest.mocked(contextSrv),
};
const renderAmRoutes = (alertManagerSourceName?: string) => {
@ -177,6 +181,9 @@ describe('AmRoutes', () => {
beforeEach(() => {
mocks.getAllDataSourcesMock.mockReturnValue(Object.values(dataSources));
mocks.contextSrv.hasAccess.mockImplementation(() => true);
mocks.contextSrv.hasPermission.mockImplementation(() => true);
mocks.contextSrv.evaluatePermission.mockImplementation(() => []);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
});
@ -359,6 +366,18 @@ describe('AmRoutes', () => {
});
});
it('hides create and edit button if user does not have permission', () => {
mocks.contextSrv.hasAccess.mockImplementation((action) =>
[AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsRead].includes(
action as AccessControlAction
)
);
renderAmRoutes();
expect(ui.newPolicyButton.query()).not.toBeInTheDocument();
expect(ui.editButton.query()).not.toBeInTheDocument();
});
it('Show error message if loading Alertmanager config fails', async () => {
mocks.api.fetchAlertManagerConfig.mockRejectedValue({
status: 500,

View File

@ -122,6 +122,7 @@ const AmRoutes: FC = () => {
/>
<div className={styles.break} />
<AmSpecificRouting
alertManagerSourceName={alertManagerSourceName}
onChange={handleSave}
readOnly={readOnly}
onRootRouteEdit={enterRootRouteEditMode}

View File

@ -25,10 +25,12 @@ import { contextSrv } from 'app/core/services/context_srv';
import { selectOptionInTest } from '@grafana/ui';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { interceptLinkClicks } from 'app/core/navigation/patch/interceptLinkClicks';
import { AccessControlAction } from 'app/types';
jest.mock('./api/alertmanager');
jest.mock('./api/grafana');
jest.mock('./utils/config');
jest.mock('app/core/services/context_srv');
const mocks = {
getAllDataSources: jest.mocked(getAllDataSources),
@ -40,6 +42,7 @@ const mocks = {
fetchNotifiers: jest.mocked(fetchNotifiers),
testReceivers: jest.mocked(testReceivers),
},
contextSrv: jest.mocked(contextSrv),
};
const renderReceivers = (alertManagerSourceName?: string) => {
@ -125,8 +128,23 @@ describe('Receivers', () => {
mocks.getAllDataSources.mockReturnValue(Object.values(dataSources));
mocks.api.fetchNotifiers.mockResolvedValue(grafanaNotifiersMock);
setDataSourceSrv(new MockDataSourceSrv(dataSources));
contextSrv.isEditor = true;
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.AlertingNotificationsCreate,
AccessControlAction.AlertingNotificationsUpdate,
AccessControlAction.AlertingNotificationsDelete,
AccessControlAction.AlertingNotificationsExternalRead,
AccessControlAction.AlertingNotificationsExternalWrite,
];
return permissions.includes(action as AccessControlAction);
});
mocks.contextSrv.hasAccess.mockImplementation(() => true);
});
it('Template and receiver tables are rendered, alertmanager can be selected', async () => {
@ -295,6 +313,19 @@ describe('Receivers', () => {
});
});
it('Hides create contact point button for users without permission', () => {
mocks.api.fetchConfig.mockResolvedValue(someGrafanaAlertManagerConfig);
mocks.api.updateConfig.mockResolvedValue();
mocks.contextSrv.hasAccess.mockImplementation((action) =>
[AccessControlAction.AlertingNotificationsRead, AccessControlAction.AlertingNotificationsExternalRead].some(
(a) => a === action
)
);
renderReceivers();
expect(ui.newContactPointButton.query()).not.toBeInTheDocument();
});
it('Cloud alertmanager receiver can be edited', async () => {
mocks.api.fetchConfig.mockResolvedValue(someCloudAlertManagerConfig);
mocks.api.updateConfig.mockResolvedValue();

View File

@ -41,7 +41,10 @@ const Receivers: FC = () => {
}, [alertManagerSourceName, dispatch, shouldLoadConfig]);
useEffect(() => {
if (alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME && !(receiverTypes.result || receiverTypes.loading)) {
if (
alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME &&
!(receiverTypes.result || receiverTypes.loading || receiverTypes.error)
) {
dispatch(fetchGrafanaNotifiersAction());
}
}, [alertManagerSourceName, dispatch, receiverTypes]);

View File

@ -13,6 +13,7 @@ export interface EmptyAreaWithCTAProps {
buttonIcon?: IconName;
buttonSize?: 'xs' | 'sm' | 'md' | 'lg';
buttonVariant?: ButtonVariant;
showButton?: boolean;
}
export const EmptyAreaWithCTA: FC<EmptyAreaWithCTAProps> = ({
@ -23,6 +24,7 @@ export const EmptyAreaWithCTA: FC<EmptyAreaWithCTAProps> = ({
onButtonClick,
text,
href,
showButton = true,
}) => {
const styles = useStyles(getStyles);
@ -37,15 +39,16 @@ export const EmptyAreaWithCTA: FC<EmptyAreaWithCTAProps> = ({
<EmptyArea>
<>
<p className={styles.text}>{text}</p>
{href ? (
<LinkButton href={href} type="button" {...commonProps}>
{buttonLabel}
</LinkButton>
) : (
<Button onClick={onButtonClick} type="button" {...commonProps}>
{buttonLabel}
</Button>
)}
{showButton &&
(href ? (
<LinkButton href={href} type="button" {...commonProps}>
{buttonLabel}
</LinkButton>
) : (
<Button onClick={onButtonClick} type="button" {...commonProps}>
{buttonLabel}
</Button>
))}
</>
</EmptyArea>
);

View File

@ -3,12 +3,11 @@ import { GrafanaTheme2 } from '@grafana/data';
import { LinkButton, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertmanagerAlert, AlertState } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import React, { FC } from 'react';
import { isGrafanaRulesSource } from '../../utils/datasource';
import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc';
import { AnnotationDetailsField } from '../AnnotationDetailsField';
import { Authorize } from '../Authorize';
import { getInstancesPermissions } from '../../utils/access-control';
interface AmNotificationsAlertDetailsProps {
alertManagerSourceName: string;
@ -17,18 +16,11 @@ interface AmNotificationsAlertDetailsProps {
export const AlertDetails: FC<AmNotificationsAlertDetailsProps> = ({ alert, alertManagerSourceName }) => {
const styles = useStyles2(getStyles);
const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
const permissions = getInstancesPermissions(alertManagerSourceName);
return (
<>
<div className={styles.actionsRow}>
<Authorize
actions={
isExternalAM
? [AccessControlAction.AlertingInstancesExternalWrite]
: [AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingInstanceUpdate]
}
fallback={contextSrv.isEditor}
>
<Authorize actions={[permissions.update, permissions.create]} fallback={contextSrv.isEditor}>
{alert.status.state === AlertState.Suppressed && (
<LinkButton
href={`${makeAMLink(
@ -53,9 +45,7 @@ export const AlertDetails: FC<AmNotificationsAlertDetailsProps> = ({ alert, aler
</LinkButton>
)}
</Authorize>
<Authorize
actions={isExternalAM ? [AccessControlAction.DataSourcesExplore] : [AccessControlAction.AlertingInstanceRead]}
>
<Authorize actions={[permissions.viewSource]}>
{alert.generatorURL && (
<LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}>
See source

View File

@ -6,7 +6,8 @@ import { AmRouteReceiver, FormAmRoute } from '../../types/amroutes';
import { AmRootRouteForm } from './AmRootRouteForm';
import { AmRootRouteRead } from './AmRootRouteRead';
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { Authorize } from '../../components/Authorize';
import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmRootRouteProps {
isEditMode: boolean;
onEnterEditMode: () => void;
@ -28,6 +29,7 @@ export const AmRootRoute: FC<AmRootRouteProps> = ({
}) => {
const styles = useStyles2(getStyles);
const permissions = getNotificationsPermissions(alertManagerSourceName);
const isReadOnly = isVanillaPrometheusAlertManagerDataSource(alertManagerSourceName);
return (
@ -37,9 +39,11 @@ export const AmRootRoute: FC<AmRootRouteProps> = ({
Root policy - <i>default for all alerts</i>
</h5>
{!isEditMode && !isReadOnly && (
<Button icon="pen" onClick={onEnterEditMode} size="sm" type="button" variant="secondary">
Edit
</Button>
<Authorize actions={[permissions.update]}>
<Button icon="pen" onClick={onEnterEditMode} size="sm" type="button" variant="secondary">
Edit
</Button>
</Authorize>
)}
</div>
<p>

View File

@ -7,13 +7,15 @@ import { emptyRoute } from '../../utils/amroutes';
import { AmRoutesTable } from './AmRoutesTable';
import { getGridStyles } from './gridStyles';
import { MuteTimingsTable } from './MuteTimingsTable';
import { useAlertManagerSourceName } from '../../hooks/useAlertManagerSourceName';
import { Authorize } from '../Authorize';
import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmRoutesExpandedReadProps {
onChange: (routes: FormAmRoute) => void;
receivers: AmRouteReceiver[];
routes: FormAmRoute;
readOnly?: boolean;
alertManagerSourceName: string;
}
export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
@ -21,10 +23,11 @@ export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
receivers,
routes,
readOnly = false,
alertManagerSourceName,
}) => {
const styles = useStyles2(getStyles);
const gridStyles = useStyles2(getGridStyles);
const [alertManagerSourceName] = useAlertManagerSourceName();
const permissions = getNotificationsPermissions(alertManagerSourceName);
const groupWait = routes.groupWaitValue ? `${routes.groupWaitValue}${routes.groupWaitValueType}` : '-';
const groupInterval = routes.groupIntervalValue
@ -71,23 +74,26 @@ export const AmRoutesExpandedRead: FC<AmRoutesExpandedReadProps> = ({
}}
receivers={receivers}
routes={subroutes}
alertManagerSourceName={alertManagerSourceName}
/>
) : (
<p>No nested policies configured.</p>
)}
{!isAddMode && !readOnly && (
<Button
className={styles.addNestedRoutingBtn}
icon="plus"
onClick={() => {
setSubroutes((subroutes) => [...subroutes, emptyRoute]);
setIsAddMode(true);
}}
variant="secondary"
type="button"
>
Add nested policy
</Button>
<Authorize actions={[permissions.create]}>
<Button
className={styles.addNestedRoutingBtn}
icon="plus"
onClick={() => {
setSubroutes((subroutes) => [...subroutes, emptyRoute]);
setIsAddMode(true);
}}
variant="secondary"
type="button"
>
Add nested policy
</Button>
</Authorize>
)}
</div>
<div className={gridStyles.titleCell}>Mute timings</div>

View File

@ -9,6 +9,8 @@ import { Matchers } from '../silences/Matchers';
import { matcherFieldToMatcher, parseMatchers } from '../../utils/alertmanager';
import { intersectionWith, isEqual } from 'lodash';
import { EmptyArea } from '../EmptyArea';
import { contextSrv } from 'app/core/services/context_srv';
import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmRoutesTableProps {
isAddMode: boolean;
@ -18,6 +20,7 @@ export interface AmRoutesTableProps {
routes: FormAmRoute[];
filters?: { queryString?: string; contactPoint?: string };
readOnly?: boolean;
alertManagerSourceName: string;
}
type RouteTableColumnProps = DynamicTableColumnProps<FormAmRoute>;
@ -69,9 +72,15 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({
routes,
filters,
readOnly = false,
alertManagerSourceName,
}) => {
const [editMode, setEditMode] = useState(false);
const [expandedId, setExpandedId] = useState<string | number>();
const permissions = getNotificationsPermissions(alertManagerSourceName);
const canEditRoutes = contextSrv.hasPermission(permissions.update);
const canDeleteRoutes = contextSrv.hasPermission(permissions.delete);
const showActions = !readOnly && (canEditRoutes || canDeleteRoutes);
const expandItem = useCallback((item: RouteTableItemProps) => setExpandedId(item.id), []);
const collapseItem = useCallback(() => setExpandedId(undefined), []);
@ -102,7 +111,7 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({
renderCell: (item) => item.data.muteTimeIntervals.join(', ') || '-',
size: 5,
},
...(readOnly
...(!showActions
? []
: [
{
@ -212,6 +221,7 @@ export const AmRoutesTable: FC<AmRoutesTableProps> = ({
receivers={receivers}
routes={item.data}
readOnly={readOnly}
alertManagerSourceName={alertManagerSourceName}
/>
)
}

View File

@ -11,8 +11,12 @@ import { MatcherFilter } from '../alert-groups/MatcherFilter';
import { EmptyArea } from '../EmptyArea';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { AmRoutesTable } from './AmRoutesTable';
import { Authorize } from '../../components/Authorize';
import { contextSrv } from 'app/core/services/context_srv';
import { getNotificationsPermissions } from '../../utils/access-control';
export interface AmSpecificRoutingProps {
alertManagerSourceName: string;
onChange: (routes: FormAmRoute) => void;
onRootRouteEdit: () => void;
receivers: AmRouteReceiver[];
@ -26,6 +30,7 @@ interface Filters {
}
export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
alertManagerSourceName,
onChange,
onRootRouteEdit,
receivers,
@ -34,6 +39,8 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
}) => {
const [actualRoutes, setActualRoutes] = useState([...routes.routes]);
const [isAddMode, setIsAddMode] = useState(false);
const permissions = getNotificationsPermissions(alertManagerSourceName);
const canCreateNotifications = contextSrv.hasPermission(permissions.create);
const [searchParams, setSearchParams] = useURLSearchParams();
const { queryString, contactPoint } = getNotificationPoliciesFilters(searchParams);
@ -97,6 +104,7 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
buttonLabel="Set a default contact point"
onButtonClick={onRootRouteEdit}
text="You haven't set a default contact point for the root route yet."
showButton={canCreateNotifications}
/>
)
) : actualRoutes.length > 0 ? (
@ -132,11 +140,13 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
)}
{!isAddMode && !readOnly && (
<div className={styles.addMatcherBtnRow}>
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
New policy
</Button>
</div>
<Authorize actions={[permissions.create]}>
<div className={styles.addMatcherBtnRow}>
<Button className={styles.addMatcherBtn} icon="plus" onClick={addNewRoute} type="button">
New policy
</Button>
</div>
</Authorize>
)}
</div>
<AmRoutesTable
@ -147,6 +157,7 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
receivers={receivers}
routes={actualRoutes}
filters={{ queryString, contactPoint }}
alertManagerSourceName={alertManagerSourceName}
/>
</>
) : readOnly ? (
@ -159,6 +170,7 @@ export const AmSpecificRouting: FC<AmSpecificRoutingProps> = ({
buttonLabel="New specific policy"
onButtonClick={addNewRoute}
text="You haven't created any specific policies yet."
showButton={canCreateNotifications}
/>
)}
</div>

View File

@ -17,6 +17,9 @@ import {
getYearsString,
} from '../../utils/alertmanager';
import { EmptyAreaWithCTA } from '../EmptyAreaWithCTA';
import { Authorize } from '../../components/Authorize';
import { contextSrv } from 'app/core/services/context_srv';
import { getNotificationsPermissions } from '../../utils/access-control';
interface Props {
alertManagerSourceName: string;
@ -27,6 +30,7 @@ interface Props {
export const MuteTimingsTable: FC<Props> = ({ alertManagerSourceName, muteTimingNames, hideActions }) => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const permissions = getNotificationsPermissions(alertManagerSourceName);
const amConfigs = useUnifiedAlertingSelector((state) => state.amConfigs);
const [muteTimingName, setMuteTimingName] = useState<string>('');
const { result }: AsyncRequestState<AlertManagerCortexConfig> =
@ -56,14 +60,16 @@ export const MuteTimingsTable: FC<Props> = ({ alertManagerSourceName, muteTiming
</p>
)}
{!hideActions && items.length > 0 && (
<LinkButton
className={styles.addMuteButton}
icon="plus"
variant="primary"
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
>
New mute timing
</LinkButton>
<Authorize actions={[permissions.create]}>
<LinkButton
className={styles.addMuteButton}
icon="plus"
variant="primary"
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
>
New mute timing
</LinkButton>
</Authorize>
)}
{items.length > 0 ? (
<DynamicTable items={items} cols={columns} />
@ -74,6 +80,7 @@ export const MuteTimingsTable: FC<Props> = ({ alertManagerSourceName, muteTiming
buttonIcon="plus"
buttonSize="lg"
href={makeAMLink('alerting/routes/mute-timing/new', alertManagerSourceName)}
showButton={contextSrv.hasPermission(permissions.create)}
/>
) : (
<p>No mute timings configured</p>
@ -93,6 +100,11 @@ export const MuteTimingsTable: FC<Props> = ({ alertManagerSourceName, muteTiming
};
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);
return useMemo((): Array<DynamicTableColumnProps<MuteTimeInterval>> => {
const columns: Array<DynamicTableColumnProps<MuteTimeInterval>> = [
{
@ -109,19 +121,29 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
renderCell: ({ data }) => renderTimeIntervals(data.time_intervals),
},
];
if (!hideActions) {
if (showActions) {
columns.push({
id: 'actions',
label: 'Actions',
renderCell: function renderActions({ data }) {
return (
<div>
<Link
href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, { muteName: data.name })}
>
<IconButton name="edit" title="Edit mute timing" />
</Link>
<IconButton name={'trash-alt'} title="Delete mute timing" onClick={() => setMuteTimingName(data.name)} />
<Authorize actions={[permissions.update]}>
<Link
href={makeAMLink(`/alerting/routes/mute-timing/edit`, alertManagerSourceName, {
muteName: data.name,
})}
>
<IconButton name="edit" title="Edit mute timing" />
</Link>
</Authorize>
<Authorize actions={[permissions.delete]}>
<IconButton
name={'trash-alt'}
title="Delete mute timing"
onClick={() => setMuteTimingName(data.name)}
/>
</Authorize>
</div>
);
},
@ -129,7 +151,7 @@ function useColumns(alertManagerSourceName: string, hideActions = false, setMute
});
}
return columns;
}, [alertManagerSourceName, hideActions, setMuteTimingName]);
}, [alertManagerSourceName, setMuteTimingName, showActions, permissions]);
}
function renderTimeIntervals(timeIntervals: TimeInterval[]) {

View File

@ -2,9 +2,11 @@ import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, LinkButton, useStyles2 } from '@grafana/ui';
import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import React, { FC } from 'react';
import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { makeAMLink } from '../../utils/misc';
import { Authorize } from '../Authorize';
import { ReceiversTable } from './ReceiversTable';
import { TemplatesTable } from './TemplatesTable';
@ -22,15 +24,17 @@ export const ReceiversAndTemplatesView: FC<Props> = ({ config, alertManagerName
{!isVanillaAM && <TemplatesTable config={config} alertManagerName={alertManagerName} />}
<ReceiversTable config={config} alertManagerName={alertManagerName} />
{isCloud && (
<Alert className={styles.section} severity="info" title="Global config for contact points">
<p>
For each external Alertmanager you can define global settings, like server addresses, usernames and
password, for all the supported contact points.
</p>
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">
{isVanillaAM ? 'View global config' : 'Edit global config'}
</LinkButton>
</Alert>
<Authorize actions={[AccessControlAction.AlertingNotificationsExternalWrite]}>
<Alert className={styles.section} severity="info" title="Global config for contact points">
<p>
For each external Alertmanager you can define global settings, like server addresses, usernames and
password, for all the supported contact points.
</p>
<LinkButton href={makeAMLink('alerting/notifications/global-config', alertManagerName)} variant="secondary">
{isVanillaAM ? 'View global config' : 'Edit global config'}
</LinkButton>
</Alert>
</Authorize>
)}
</>
);

View File

@ -13,6 +13,9 @@ import { isReceiverUsed } from '../../utils/alertmanager';
import { useDispatch } from 'react-redux';
import { deleteReceiverAction } from '../../state/actions';
import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource';
import { Authorize } from '../../components/Authorize';
import { contextSrv } from 'app/core/services/context_srv';
import { getNotificationsPermissions } from '../../utils/access-control';
interface Props {
config: AlertManagerCortexConfig;
@ -24,6 +27,7 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
const tableStyles = useStyles2(getAlertTableStyles);
const styles = useStyles2(getStyles);
const isVanillaAM = isVanillaPrometheusAlertManagerDataSource(alertManagerName);
const permissions = getNotificationsPermissions(alertManagerName);
const grafanaNotifiers = useUnifiedAlertingSelector((state) => state.grafanaNotifiers);
// receiver name slated for deletion. If this is set, a confirmation modal is shown. If user approves, this receiver is deleted
@ -66,7 +70,7 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
className={styles.section}
title="Contact points"
description="Define where the notifications will be sent to, for example email or Slack."
showButton={!isVanillaAM}
showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)}
addButtonLabel="New contact point"
addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)}
>
@ -74,13 +78,17 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
<colgroup>
<col />
<col />
<col />
<Authorize actions={[permissions.update, permissions.delete]}>
<col />
</Authorize>
</colgroup>
<thead>
<tr>
<th>Contact point name</th>
<th>Type</th>
<th>Actions</th>
<Authorize actions={[permissions.update, permissions.delete]}>
<th>Actions</th>
</Authorize>
</tr>
</thead>
<tbody>
@ -93,38 +101,46 @@ export const ReceiversTable: FC<Props> = ({ config, alertManagerName }) => {
<tr key={receiver.name} className={idx % 2 === 0 ? tableStyles.evenRow : undefined}>
<td>{receiver.name}</td>
<td>{receiver.types.join(', ')}</td>
<td className={tableStyles.actionsCell}>
{!isVanillaAM && (
<>
<ActionIcon
aria-label="Edit"
data-testid="edit"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}
tooltip="Edit contact point"
icon="pen"
/>
<ActionIcon
onClick={() => onClickDeleteReceiver(receiver.name)}
tooltip="Delete contact point"
icon="trash-alt"
/>
</>
)}
{isVanillaAM && (
<ActionIcon
data-testid="view"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}
tooltip="View contact point"
icon="file-alt"
/>
)}
</td>
<Authorize actions={[permissions.update, permissions.delete]}>
<td className={tableStyles.actionsCell}>
{!isVanillaAM && (
<>
<Authorize actions={[permissions.update]}>
<ActionIcon
aria-label="Edit"
data-testid="edit"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}
tooltip="Edit contact point"
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => onClickDeleteReceiver(receiver.name)}
tooltip="Delete contact point"
icon="trash-alt"
/>
</Authorize>
</>
)}
{isVanillaAM && (
<Authorize actions={[permissions.update]}>
<ActionIcon
data-testid="view"
to={makeAMLink(
`/alerting/notifications/receivers/${encodeURIComponent(receiver.name)}/edit`,
alertManagerName
)}
tooltip="View contact point"
icon="file-alt"
/>
</Authorize>
)}
</td>
</Authorize>
</tr>
))}
</tbody>

View File

@ -9,6 +9,9 @@ import { ReceiversSection } from './ReceiversSection';
import { makeAMLink } from '../../utils/misc';
import { useDispatch } from 'react-redux';
import { deleteTemplateAction } from '../../state/actions';
import { contextSrv } from 'app/core/services/context_srv';
import { Authorize } from '../../components/Authorize';
import { getNotificationsPermissions } from '../../utils/access-control';
interface Props {
config: AlertManagerCortexConfig;
@ -19,6 +22,7 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
const dispatch = useDispatch();
const [expandedTemplates, setExpandedTemplates] = useState<Record<string, boolean>>({});
const tableStyles = useStyles2(getAlertTableStyles);
const permissions = getNotificationsPermissions(alertManagerName);
const templateRows = useMemo(() => Object.entries(config.template_files), [config]);
const [templateToDelete, setTemplateToDelete] = useState<string>();
@ -36,6 +40,7 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
description="Templates construct the messages that get sent to the contact points."
addButtonLabel="New template"
addButtonTo={makeAMLink('/alerting/notifications/templates/new', alertManagerName)}
showButton={contextSrv.hasPermission(permissions.create)}
>
<table className={tableStyles.table} data-testid="templates-table">
<colgroup>
@ -47,7 +52,9 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
<tr>
<th></th>
<th>Template</th>
<th>Actions</th>
<Authorize actions={[permissions.update, permissions.delete]}>
<th>Actions</th>
</Authorize>
</tr>
</thead>
<tbody>
@ -68,17 +75,27 @@ export const TemplatesTable: FC<Props> = ({ config, alertManagerName }) => {
/>
</td>
<td>{name}</td>
<td className={tableStyles.actionsCell}>
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
alertManagerName
)}
tooltip="edit template"
icon="pen"
/>
<ActionIcon onClick={() => setTemplateToDelete(name)} tooltip="delete template" icon="trash-alt" />
</td>
<Authorize actions={[permissions.update, permissions.delete]}>
<td className={tableStyles.actionsCell}>
<Authorize actions={[permissions.update]}>
<ActionIcon
to={makeAMLink(
`/alerting/notifications/templates/${encodeURIComponent(name)}/edit`,
alertManagerName
)}
tooltip="edit template"
icon="pen"
/>
</Authorize>
<Authorize actions={[permissions.delete]}>
<ActionIcon
onClick={() => setTemplateToDelete(name)}
tooltip="delete template"
icon="trash-alt"
/>
</Authorize>
</td>
</Authorize>
</tr>
{isExpanded && (
<tr className={idx % 2 === 0 ? tableStyles.evenRow : undefined}>

View File

@ -18,9 +18,8 @@ import { useDispatch } from 'react-redux';
import { expireSilenceAction } from '../../state/actions';
import { SilenceDetails } from './SilenceDetails';
import { Stack } from '@grafana/experimental';
import { AccessControlAction } from '../../../../../types';
import { Authorize } from '../Authorize';
import { isGrafanaRulesSource } from '../../utils/datasource';
import { getInstancesPermissions } from '../../utils/access-control';
export interface SilenceTableItem extends Silence {
silencedAlerts: AlertmanagerAlert[];
@ -38,7 +37,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
const styles = useStyles2(getStyles);
const [queryParams] = useQueryParams();
const filteredSilences = useFilteredSilences(silences);
const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
const permissions = getInstancesPermissions(alertManagerSourceName);
const { silenceState } = getSilenceFiltersFromUrlParams(queryParams);
@ -65,14 +64,7 @@ const SilencesTable: FC<Props> = ({ silences, alertManagerAlerts, alertManagerSo
{!!silences.length && (
<>
<SilencesFilter />
<Authorize
actions={
isExternalAM
? [AccessControlAction.AlertingInstancesExternalWrite]
: [AccessControlAction.AlertingInstanceCreate]
}
fallback={contextSrv.isEditor}
>
<Authorize actions={[permissions.create]} fallback={contextSrv.isEditor}>
<div className={styles.topButtonContainer}>
<Link href={makeAMLink('/alerting/silence/new', alertManagerSourceName)}>
<Button className={styles.addNewSilence} icon="plus">
@ -178,15 +170,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
function useColumns(alertManagerSourceName: string) {
const dispatch = useDispatch();
const styles = useStyles2(getStyles);
const isExternalAM = !isGrafanaRulesSource(alertManagerSourceName);
const permissions = getInstancesPermissions(alertManagerSourceName);
return useMemo((): SilenceTableColumnProps[] => {
const handleExpireSilenceClick = (id: string) => {
dispatch(expireSilenceAction(alertManagerSourceName, id));
};
const showActions = contextSrv.hasAccess(
isExternalAM ? AccessControlAction.AlertingInstancesExternalWrite : AccessControlAction.AlertingInstanceUpdate,
contextSrv.isEditor
);
const showActions = contextSrv.hasAccess(permissions.update, contextSrv.isEditor);
const columns: SilenceTableColumnProps[] = [
{
id: 'state',
@ -262,7 +251,7 @@ function useColumns(alertManagerSourceName: string) {
});
}
return columns;
}, [alertManagerSourceName, dispatch, styles, isExternalAM]);
}, [alertManagerSourceName, dispatch, styles, permissions]);
}
export default SilencesTable;

View File

@ -0,0 +1,78 @@
import { AccessControlAction } from 'app/types';
import { isGrafanaRulesSource } from './datasource';
import { contextSrv } from 'app/core/services/context_srv';
function getAMversion(alertManagerSourceName: string) {
return isGrafanaRulesSource(alertManagerSourceName) ? 'grafana' : 'external';
}
export function getInstancesPermissions(alertManagerSourceName: string) {
const amVersion = getAMversion(alertManagerSourceName);
const permissions = {
read: {
grafana: AccessControlAction.AlertingInstanceRead,
external: AccessControlAction.AlertingInstancesExternalRead,
},
create: {
grafana: AccessControlAction.AlertingInstanceCreate,
external: AccessControlAction.AlertingInstancesExternalWrite,
},
update: {
grafana: AccessControlAction.AlertingInstanceUpdate,
external: AccessControlAction.AlertingInstancesExternalWrite,
},
delete: {
grafana: AccessControlAction.AlertingInstanceUpdate,
external: AccessControlAction.AlertingInstancesExternalWrite,
},
viewSource: {
grafana: AccessControlAction.AlertingInstanceRead,
external: AccessControlAction.DataSourcesExplore,
},
};
return {
read: permissions.read[amVersion],
create: permissions.create[amVersion],
update: permissions.update[amVersion],
delete: permissions.delete[amVersion],
viewSource: permissions.viewSource[amVersion],
};
}
export function getNotificationsPermissions(alertManagerSourceName: string) {
const amVersion = getAMversion(alertManagerSourceName);
const permissions = {
read: {
grafana: AccessControlAction.AlertingNotificationsRead,
external: AccessControlAction.AlertingNotificationsExternalRead,
},
create: {
grafana: AccessControlAction.AlertingNotificationsCreate,
external: AccessControlAction.AlertingNotificationsExternalWrite,
},
update: {
grafana: AccessControlAction.AlertingNotificationsUpdate,
external: AccessControlAction.AlertingNotificationsExternalWrite,
},
delete: {
grafana: AccessControlAction.AlertingNotificationsDelete,
external: AccessControlAction.AlertingNotificationsExternalWrite,
},
};
return {
read: permissions.read[amVersion],
create: permissions.create[amVersion],
update: permissions.update[amVersion],
delete: permissions.delete[amVersion],
};
}
export function evaluateAccess(actions: AccessControlAction[], fallBackUserRoles: string[]) {
return () => {
return contextSrv.evaluatePermission(() => fallBackUserRoles, actions);
};
}