Alerting: FGAC bug bash fixes (#47873)

* Improve new alert and new silence buttons permission handling

* Prevent loading alert rules when no sufficient permissions provided

* Improve add and edit rule permissions

* Add new rule CTA button for non-editors

* Update mock

* Fix imports
This commit is contained in:
Konrad Lalik 2022-04-19 18:43:33 +02:00 committed by GitHub
parent 0c31399e34
commit 785145c045
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 158 additions and 102 deletions

View File

@ -4,7 +4,6 @@ import { Alert, LinkButton, LoadingPlaceholder, useStyles2, withErrorBoundary }
import Page from 'app/core/components/Page/Page';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { contextSrv } from 'app/core/services/context_srv';
import { RuleIdentifier } from 'app/types/unified-alerting';
import React, { FC, useEffect } from 'react';
import { useDispatch } from 'react-redux';
@ -13,6 +12,7 @@ import { AlertRuleForm } from './components/rule-editor/AlertRuleForm';
import { useIsRuleEditable } from './hooks/useIsRuleEditable';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchAllPromBuildInfoAction, fetchEditableRuleAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks';
import * as ruleId from './utils/rule-id';
interface ExistingRuleEditorProps {
@ -38,6 +38,7 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
</Page.Contents>
);
}
if (error) {
return (
<Page.Contents>
@ -47,12 +48,15 @@ const ExistingRuleEditor: FC<ExistingRuleEditorProps> = ({ identifier }) => {
</Page.Contents>
);
}
if (!result) {
return <AlertWarning title="Rule not found">Sorry! This rule does not exist.</AlertWarning>;
}
if (isEditable === false) {
return <AlertWarning title="Cannot edit rule">Sorry! You do not have permission to edit this rule.</AlertWarning>;
}
return <AlertRuleForm existing={result} />;
};
@ -67,10 +71,16 @@ const RuleEditor: FC<RuleEditorProps> = ({ match }) => {
await dispatch(fetchAllPromBuildInfoAction());
}, [dispatch]);
if (!(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor)) {
const { canCreateGrafanaRules, canCreateCloudRules, canEditRules } = useRulesAccess();
if (!canCreateGrafanaRules && !canCreateCloudRules) {
return <AlertWarning title="Cannot create rules">Sorry! You are not allowed to create rules.</AlertWarning>;
}
if (identifier && !canEditRules(identifier.ruleSourceName)) {
return <AlertWarning title="Cannot edit rules">Sorry! You are not allowed to edit rules.</AlertWarning>;
}
if (loading) {
return (
<Page.Contents>

View File

@ -1,24 +1,24 @@
import { css } from '@emotion/css';
import { GrafanaTheme2, urlUtil } from '@grafana/data';
import { useStyles2, LinkButton, withErrorBoundary, Button } from '@grafana/ui';
import { Button, LinkButton, useStyles2, withErrorBoundary } from '@grafana/ui';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import React, { useEffect, useMemo, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { AlertingPageWrapper } from './components/AlertingPageWrapper';
import { NoRulesSplash } from './components/rules/NoRulesCTA';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { useFilteredRules } from './hooks/useFilteredRules';
import { fetchAllPromAndRulerRulesAction } from './state/actions';
import { getAllRulesSourceNames } from './utils/datasource';
import { css } from '@emotion/css';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
import RulesFilter from './components/rules/RulesFilter';
import { RuleListErrors } from './components/rules/RuleListErrors';
import { RuleListGroupView } from './components/rules/RuleListGroupView';
import { RuleListStateView } from './components/rules/RuleListStateView';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { useLocation } from 'react-router-dom';
import { contextSrv } from 'app/core/services/context_srv';
import RulesFilter from './components/rules/RulesFilter';
import { RuleStats } from './components/rules/RuleStats';
import { RuleListErrors } from './components/rules/RuleListErrors';
import { useCombinedRuleNamespaces } from './hooks/useCombinedRuleNamespaces';
import { useFilteredRules } from './hooks/useFilteredRules';
import { useUnifiedAlertingSelector } from './hooks/useUnifiedAlertingSelector';
import { fetchAllPromAndRulerRulesAction } from './state/actions';
import { useRulesAccess } from './utils/accessControlHooks';
import { RULE_LIST_POLL_INTERVAL_MS } from './utils/constants';
import { getAllRulesSourceNames } from './utils/datasource';
import { getFiltersFromUrlParams } from './utils/misc';
const VIEWS = {
@ -38,6 +38,8 @@ const RuleList = withErrorBoundary(
const filters = getFiltersFromUrlParams(queryParams);
const filtersActive = Object.values(filters).some((filter) => filter !== undefined);
const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess();
const view = VIEWS[queryParams['view'] as keyof typeof VIEWS]
? (queryParams['view'] as keyof typeof VIEWS)
: 'groups';
@ -93,7 +95,7 @@ const RuleList = withErrorBoundary(
)}
<RuleStats showInactive={true} showRecording={true} namespaces={filteredNamespaces} />
</div>
{(contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) && (
{(canCreateGrafanaRules || canCreateCloudRules) && (
<LinkButton
href={urlUtil.renderUrl('alerting/new', { returnTo: location.pathname + location.search })}
icon="plus"

View File

@ -8,6 +8,7 @@ const fetch = jest.fn();
jest.mock('./prometheus');
jest.mock('./ruler');
jest.mock('app/core/services/context_srv', () => {});
jest.mock('@grafana/runtime', () => ({
getBackendSrv: () => ({ fetch }),
}));

View File

@ -1,10 +1,12 @@
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { contextSrv } from 'app/core/services/context_srv';
import React, { FC } from 'react';
import { CallToActionCard } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import React, { FC } from 'react';
import { useRulesAccess } from '../../utils/accessControlHooks';
export const NoRulesSplash: FC = () => {
if (contextSrv.hasEditPermissionInFolders || contextSrv.isEditor) {
const { canCreateGrafanaRules, canCreateCloudRules } = useRulesAccess();
if (canCreateGrafanaRules || canCreateCloudRules) {
return (
<EmptyListCTA
title="You haven`t created any alert rules yet"

View File

@ -2,6 +2,7 @@ import { CallToActionCard } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { contextSrv } from 'app/core/services/context_srv';
import React, { FC } from 'react';
import { getInstancesPermissions } from '../../utils/access-control';
import { makeAMLink } from '../../utils/misc';
type Props = {
@ -9,7 +10,9 @@ type Props = {
};
export const NoSilencesSplash: FC<Props> = ({ alertManagerSourceName }) => {
if (contextSrv.isEditor) {
const permissions = getInstancesPermissions(alertManagerSourceName);
if (contextSrv.hasAccess(permissions.create, contextSrv.isEditor)) {
return (
<EmptyListCTA
title="You haven't created any silences yet"

View File

@ -8,93 +8,93 @@ function getRulesSourceType(alertManagerSourceName: string): RulesSourceType {
return isGrafanaRulesSource(alertManagerSourceName) ? 'grafana' : 'external';
}
const instancesPermissions = {
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,
},
};
const notificationsPermissions = {
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,
},
};
const rulesPermissions = {
read: {
grafana: AccessControlAction.AlertingRuleRead,
external: AccessControlAction.AlertingRuleExternalRead,
},
create: {
grafana: AccessControlAction.AlertingRuleCreate,
external: AccessControlAction.AlertingRuleExternalWrite,
},
update: {
grafana: AccessControlAction.AlertingRuleUpdate,
external: AccessControlAction.AlertingRuleExternalWrite,
},
delete: {
grafana: AccessControlAction.AlertingRuleDelete,
external: AccessControlAction.AlertingRuleExternalWrite,
},
};
export function getInstancesPermissions(rulesSourceName: string) {
const sourceType = getRulesSourceType(rulesSourceName);
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,
},
};
return {
read: permissions.read[sourceType],
create: permissions.create[sourceType],
update: permissions.update[sourceType],
delete: permissions.delete[sourceType],
read: instancesPermissions.read[sourceType],
create: instancesPermissions.create[sourceType],
update: instancesPermissions.update[sourceType],
delete: instancesPermissions.delete[sourceType],
};
}
export function getNotificationsPermissions(rulesSourceName: string) {
const sourceType = getRulesSourceType(rulesSourceName);
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[sourceType],
create: permissions.create[sourceType],
update: permissions.update[sourceType],
delete: permissions.delete[sourceType],
read: notificationsPermissions.read[sourceType],
create: notificationsPermissions.create[sourceType],
update: notificationsPermissions.update[sourceType],
delete: notificationsPermissions.delete[sourceType],
};
}
export function getRulesPermissions(rulesSourceName: string) {
const sourceType = getRulesSourceType(rulesSourceName);
const permissions = {
read: {
grafana: AccessControlAction.AlertingRuleRead,
external: AccessControlAction.AlertingRuleExternalRead,
},
create: {
grafana: AccessControlAction.AlertingRuleCreate,
external: AccessControlAction.AlertingRuleExternalWrite,
},
update: {
grafana: AccessControlAction.AlertingRuleUpdate,
external: AccessControlAction.AlertingRuleExternalWrite,
},
delete: {
grafana: AccessControlAction.AlertingRuleDelete,
external: AccessControlAction.AlertingRuleExternalWrite,
},
};
return {
read: permissions.read[sourceType],
create: permissions.create[sourceType],
update: permissions.update[sourceType],
delete: permissions.delete[sourceType],
read: rulesPermissions.read[sourceType],
create: rulesPermissions.create[sourceType],
update: rulesPermissions.update[sourceType],
delete: rulesPermissions.delete[sourceType],
};
}
@ -103,3 +103,14 @@ export function evaluateAccess(actions: AccessControlAction[], fallBackUserRoles
return contextSrv.evaluatePermission(() => fallBackUserRoles, actions);
};
}
export function getRulesAccess() {
return {
canCreateGrafanaRules:
contextSrv.hasEditPermissionInFolders &&
contextSrv.hasAccess(rulesPermissions.create.grafana, contextSrv.isEditor),
canCreateCloudRules: contextSrv.hasAccess(rulesPermissions.create.external, contextSrv.isEditor),
canEditRules: (rulesSourceName: string) =>
contextSrv.hasAccess(getRulesPermissions(rulesSourceName).update, contextSrv.isEditor),
};
}

View File

@ -0,0 +1,6 @@
import { useMemo } from 'react';
import { getRulesAccess } from './access-control';
export function useRulesAccess() {
return useMemo(() => getRulesAccess(), []);
}

View File

@ -1,5 +1,7 @@
import { DataSourceJsonData, DataSourceInstanceSettings } from '@grafana/data';
import { contextSrv } from 'app/core/services/context_srv';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
import { RulesSource } from 'app/types/unified-alerting';
import { getAllDataSources } from './config';
@ -15,13 +17,17 @@ export enum DataSourceType {
export const RulesDataSourceTypes: string[] = [DataSourceType.Loki, DataSourceType.Prometheus];
export function getRulesDataSources() {
if (!contextSrv.hasPermission(AccessControlAction.AlertingRuleExternalRead)) {
return [];
}
return getAllDataSources()
.filter((ds) => RulesDataSourceTypes.includes(ds.type) && ds.jsonData.manageAlerts !== false)
.sort((a, b) => a.name.localeCompare(b.name));
}
export function getRulesDataSource(rulesSourceName: string) {
return getAllDataSources().find((x) => x.name === rulesSourceName);
return getRulesDataSources().find((x) => x.name === rulesSourceName);
}
export function getAlertManagerDataSources() {
@ -42,11 +48,23 @@ export function getLotexDataSourceByName(dataSourceName: string): DataSourceInst
}
export function getAllRulesSourceNames(): string[] {
return [...getRulesDataSources().map((r) => r.name), GRAFANA_RULES_SOURCE_NAME];
const availableRulesSources: string[] = getRulesDataSources().map((r) => r.name);
if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead)) {
availableRulesSources.push(GRAFANA_RULES_SOURCE_NAME);
}
return availableRulesSources;
}
export function getAllRulesSources(): RulesSource[] {
return [...getRulesDataSources(), GRAFANA_RULES_SOURCE_NAME];
const availableRulesSources: RulesSource[] = getRulesDataSources();
if (contextSrv.hasPermission(AccessControlAction.AlertingRuleRead)) {
availableRulesSources.push(GRAFANA_RULES_SOURCE_NAME);
}
return availableRulesSources;
}
export function getRulesSourceName(rulesSource: RulesSource): string {

View File

@ -1,39 +1,41 @@
import {
DataQuery,
DataSourceRef,
getDefaultRelativeTimeRange,
IntervalValues,
rangeUtil,
RelativeTimeRange,
ScopedVars,
getDefaultRelativeTimeRange,
TimeRange,
IntervalValues,
DataSourceRef,
} from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
import { getNextRefIdChar } from 'app/core/utils/query';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { ExpressionDatasourceUID } from 'app/features/expressions/ExpressionDatasource';
import { ExpressionQuery, ExpressionQueryType } from 'app/features/expressions/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import {
AlertQuery,
Annotations,
GrafanaAlertStateDecision,
AlertQuery,
Labels,
PostableRuleGrafanaRuleDTO,
RulerRuleDTO,
} from 'app/types/unified-alerting-dto';
import { EvalFunction } from '../../state/alertDef';
import { RuleFormType, RuleFormValues } from '../types/rule-form';
import { getRulesAccess } from './access-control';
import { Annotation } from './constants';
import { isGrafanaRulesSource } from './datasource';
import { arrayToRecord, recordToArray } from './misc';
import { isAlertingRulerRule, isGrafanaRulerRule, isRecordingRulerRule } from './rules';
import { parseInterval } from './time';
import { ExpressionDatasourceRef } from '@grafana/runtime/src/utils/DataSourceWithBackend';
export const getDefaultFormValues = (): RuleFormValues =>
Object.freeze({
export const getDefaultFormValues = (): RuleFormValues => {
const { canCreateGrafanaRules, canCreateCloudRules } = getRulesAccess();
return Object.freeze({
name: '',
labels: [{ key: '', value: '' }],
annotations: [
@ -42,7 +44,7 @@ export const getDefaultFormValues = (): RuleFormValues =>
{ key: Annotation.runbookURL, value: '' },
],
dataSourceName: null,
type: !contextSrv.isEditor ? RuleFormType.grafana : undefined, // viewers can't create prom alerts
type: canCreateGrafanaRules ? RuleFormType.grafana : canCreateCloudRules ? RuleFormType.cloudAlerting : undefined, // viewers can't create prom alerts
// grafana
folder: null,
@ -60,6 +62,7 @@ export const getDefaultFormValues = (): RuleFormValues =>
forTime: 1,
forTimeUnit: 'm',
});
};
export function formValuesToRulerRuleDTO(values: RuleFormValues): RulerRuleDTO {
const { name, expression, forTime, forTimeUnit, type } = values;