Alerting: Filter folders based on users' permissions (#49698)

This commit is contained in:
Konrad Lalik
2022-06-02 17:06:07 +02:00
committed by GitHub
parent 51dcac4c34
commit b06235de28
9 changed files with 172 additions and 46 deletions
@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import selectEvent from 'react-select-event';
import { selectors } from '@grafana/e2e-selectors';
import * as api from 'app/features/manage-dashboards/state/actions';
@@ -20,6 +21,27 @@ describe('FolderPicker', () => {
render(<FolderPicker onChange={jest.fn()} />);
expect(await screen.findByTestId(selectors.components.FolderPicker.containerV2)).toBeInTheDocument();
});
it('Should apply filter to the folders search results', async () => {
jest
.spyOn(api, 'searchFolders')
.mockResolvedValue([
{ title: 'Dash 1', id: 1 } as DashboardSearchHit,
{ title: 'Dash 2', id: 2 } as DashboardSearchHit,
{ title: 'Dash 3', id: 3 } as DashboardSearchHit,
]);
render(<FolderPicker onChange={jest.fn()} filter={(hits) => hits.filter((h) => h.id !== 2)} />);
const pickerContainer = screen.getByLabelText(selectors.components.FolderPicker.input);
selectEvent.openMenu(pickerContainer);
const pickerOptions = await screen.findAllByLabelText('Select option');
expect(pickerOptions).toHaveLength(2);
expect(pickerOptions[0]).toHaveTextContent('Dash 1');
expect(pickerOptions[1]).toHaveTextContent('Dash 3');
});
});
describe('getInitialValues', () => {
@@ -6,10 +6,13 @@ import { selectors } from '@grafana/e2e-selectors';
import { AsyncSelect } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { createFolder, getFolderById, searchFolders } from 'app/features/manage-dashboards/state/actions';
import { DashboardSearchHit } from 'app/features/search/types';
import { AccessControlAction, PermissionLevelString } from '../../../types';
import appEvents from '../../app_events';
export type FolderPickerFilter = (hits: DashboardSearchHit[]) => DashboardSearchHit[];
export interface Props {
onChange: ($folder: { title: string; id: number }) => void;
enableCreateNew?: boolean;
@@ -19,8 +22,10 @@ export interface Props {
initialTitle?: string;
initialFolderId?: number;
permissionLevel?: Exclude<PermissionLevelString, PermissionLevelString.Admin>;
filter?: FolderPickerFilter;
allowEmpty?: boolean;
showRoot?: boolean;
accessControlMetadata?: boolean;
/**
* Skips loading all folders in order to find the folder matching
* the folder where the dashboard is stored.
@@ -77,11 +82,19 @@ export class FolderPicker extends PureComponent<Props, State> {
};
getOptions = async (query: string) => {
const { rootName, enableReset, initialTitle, permissionLevel, initialFolderId, showRoot } = this.props;
const {
rootName,
enableReset,
initialTitle,
permissionLevel,
filter,
accessControlMetadata,
initialFolderId,
showRoot,
} = this.props;
const searchHits = await searchFolders(query, permissionLevel);
const options: Array<SelectableValue<number>> = searchHits.map((hit) => ({ label: hit.title, value: hit.id }));
const searchHits = await searchFolders(query, permissionLevel, accessControlMetadata);
const options: Array<SelectableValue<number>> = mapSearchHitsToOptions(searchHits, filter);
const hasAccess =
contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor) ||
@@ -198,6 +211,11 @@ export class FolderPicker extends PureComponent<Props, State> {
}
}
function mapSearchHitsToOptions(hits: DashboardSearchHit[], filter?: FolderPickerFilter) {
const filteredHits = filter ? filter(hits) : hits;
return filteredHits.map((hit) => ({ label: hit.title, value: hit.id }));
}
interface Args {
getFolder: typeof getFolderById;
folderId?: number;
@@ -9,6 +9,7 @@ import { PageToolbar, Button, useStyles2, CustomScrollbar, Spinner, ConfirmModal
import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useQueryParams } from 'app/core/hooks/useQueryParams';
import { AccessControlAction } from 'app/types';
import { RuleWithLocation } from 'app/types/unified-alerting';
import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector';
@@ -155,7 +156,11 @@ export const AlertRuleForm: FC<Props> = ({ existing }) => {
{showStep2 && (
<>
{type === RuleFormType.grafana ? <GrafanaEvaluationBehavior /> : <CloudEvaluationBehavior />}
<DetailsStep />
<DetailsStep
folderPermissions={[
existing ? AccessControlAction.AlertingRuleUpdate : AccessControlAction.AlertingRuleCreate,
]}
/>
<NotificationsStep />
</>
)}
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import classNames from 'classnames';
import React, { FC } from 'react';
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
@@ -13,7 +13,7 @@ import AnnotationsField from './AnnotationsField';
import { GroupAndNamespaceFields } from './GroupAndNamespaceFields';
import LabelsField from './LabelsField';
import { RuleEditorSection } from './RuleEditorSection';
import { RuleFolderPicker, Folder } from './RuleFolderPicker';
import { RuleFolderPicker, Folder, RuleFolderPickerProps } from './RuleFolderPicker';
import { checkForPathSeparator } from './util';
const recordingRuleNameValidationPattern = {
@@ -22,7 +22,11 @@ const recordingRuleNameValidationPattern = {
value: /^[a-zA-Z_:][a-zA-Z0-9_:]*$/,
};
export const DetailsStep: FC = () => {
interface DetailsStepProps {
folderPermissions: RuleFolderPickerProps['folderPermissions'];
}
export const DetailsStep = ({ folderPermissions }: DetailsStepProps) => {
const {
register,
watch,
@@ -103,7 +107,13 @@ export const DetailsStep: FC = () => {
>
<InputControl
render={({ field: { ref, ...field } }) => (
<RuleFolderPicker inputId="folder" {...field} enableCreateNew={true} enableReset={true} />
<RuleFolderPicker
inputId="folder"
{...field}
enableCreateNew={true}
enableReset={true}
folderPermissions={folderPermissions}
/>
)}
name="folder"
rules={{
@@ -1,24 +1,49 @@
import React, { FC } from 'react';
import React, { FC, useCallback } from 'react';
import { FolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/FolderPicker';
import { PermissionLevelString } from 'app/types';
import { FolderPicker, FolderPickerFilter, Props as FolderPickerProps } from 'app/core/components/Select/FolderPicker';
import { contextSrv } from 'app/core/services/context_srv';
import { DashboardSearchHit } from 'app/features/search/types';
import { AccessControlAction, PermissionLevelString } from 'app/types';
export interface Folder {
title: string;
id: number;
}
export interface Props extends Omit<FolderPickerProps, 'initialTitle' | 'initialFolderId'> {
export interface RuleFolderPickerProps extends Omit<FolderPickerProps, 'initialTitle' | 'initialFolderId'> {
value?: Folder;
/** An empty array of permissions means no filtering at all */
folderPermissions?: AccessControlAction[];
}
export const RuleFolderPicker: FC<Props> = ({ value, ...props }) => (
<FolderPicker
showRoot={false}
allowEmpty={true}
initialTitle={value?.title}
initialFolderId={value?.id}
{...props}
permissionLevel={PermissionLevelString.View}
/>
);
export const RuleFolderPicker: FC<RuleFolderPickerProps> = ({ value, folderPermissions = [], ...props }) => {
const folderFilter = useFolderPermissionFilter(folderPermissions);
return (
<FolderPicker
showRoot={false}
allowEmpty={true}
initialTitle={value?.title}
initialFolderId={value?.id}
filter={folderFilter}
accessControlMetadata
{...props}
permissionLevel={PermissionLevelString.View}
/>
);
};
const useFolderPermissionFilter = (permissions: AccessControlAction[]) => {
const permissionFilter = getFolderPermissionFilter(permissions);
return useCallback<FolderPickerFilter>(permissionFilter, [permissionFilter]);
};
function getFolderPermissionFilter(permissions: AccessControlAction[]): FolderPickerFilter {
return (folderHits: DashboardSearchHit[]) => {
return folderHits.filter((hit) =>
permissions.every((permission) =>
contextSrv.hasAccessInMetadata(permission, hit, contextSrv.hasEditPermissionInFolders)
)
);
};
}
@@ -6,7 +6,7 @@ import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { AccessControlAction, FolderDTO, StoreState } from 'app/types';
import { enableRBAC, mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks';
import { disableRBAC, enableRBAC, mockFolder, mockRulerAlertingRule, mockRulerGrafanaRule } from '../mocks';
import { useFolder } from './useFolder';
import { useIsRuleEditable } from './useIsRuleEditable';
@@ -21,11 +21,16 @@ const mocks = {
describe('useIsRuleEditable', () => {
describe('RBAC enabled', () => {
enableRBAC();
beforeEach(enableRBAC);
describe('Grafana rules', () => {
it('Should allow editing when the user has the alert rule update permission and folder permissions', () => {
// When RBAC is enabled we require only folder:read permission and apriopriate alerting permissions
beforeEach(() => {
mockUseFolder({ canSave: false });
});
it('Should allow editing when the user has the alert rule update permission', () => {
mockPermissions([AccessControlAction.AlertingRuleUpdate]);
mockUseFolder({ canSave: true });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
@@ -34,9 +39,8 @@ describe('useIsRuleEditable', () => {
expect(result.current.isEditable).toBe(true);
});
it('Should allow deleting when the user has the alert rule delete permission and folder permissions', () => {
it('Should allow deleting when the user has the alert rule delete permission', () => {
mockPermissions([AccessControlAction.AlertingRuleDelete]);
mockUseFolder({ canSave: true });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
@@ -45,9 +49,8 @@ describe('useIsRuleEditable', () => {
expect(result.current.isRemovable).toBe(true);
});
it('Should forbid editing when the user has no alert rule update permission and has folder permissions', () => {
it('Should forbid editing when the user has no alert rule update permission', () => {
mockPermissions([]);
mockUseFolder({ canSave: true });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
@@ -56,9 +59,8 @@ describe('useIsRuleEditable', () => {
expect(result.current.isEditable).toBe(false);
});
it('Should forbid deleting when the user has no alert rule delete permission and has folder permissions', () => {
it('Should forbid deleting when the user has no alert rule delete permission', () => {
mockPermissions([]);
mockUseFolder({ canSave: true });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
@@ -67,7 +69,7 @@ describe('useIsRuleEditable', () => {
expect(result.current.isRemovable).toBe(false);
});
it('Should forbid editing and deleting when the user has aler rule permissions but does not have folder permissions', () => {
it('Should allow editing and deleting when the user has aler rule permissions but does not have folder canSave permission', () => {
mockPermissions([AccessControlAction.AlertingRuleUpdate, AccessControlAction.AlertingRuleDelete]);
mockUseFolder({ canSave: false });
const wrapper = getProviderWrapper();
@@ -75,8 +77,8 @@ describe('useIsRuleEditable', () => {
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
expect(result.current.loading).toBe(false);
expect(result.current.isEditable).toBe(false);
expect(result.current.isRemovable).toBe(false);
expect(result.current.isEditable).toBe(true);
expect(result.current.isRemovable).toBe(true);
});
});
@@ -108,6 +110,35 @@ describe('useIsRuleEditable', () => {
});
});
});
describe('RBAC disabled', () => {
beforeEach(disableRBAC);
describe('Grafana rules', () => {
it('Should allow editing and deleting when the user has folder canSave permission', () => {
mockUseFolder({ canSave: true });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
expect(result.current.loading).toBe(false);
expect(result.current.isEditable).toBe(true);
expect(result.current.isRemovable).toBe(true);
});
it('Should forbid editing and deleting when the user has no folder canSave permission', () => {
mockUseFolder({ canSave: false });
const wrapper = getProviderWrapper();
const { result } = renderHook(() => useIsRuleEditable('grafana', mockRulerGrafanaRule()), { wrapper });
expect(result.current.loading).toBe(false);
expect(result.current.isEditable).toBe(false);
expect(result.current.isRemovable).toBe(false);
});
});
});
});
function mockUseFolder(partial?: Partial<FolderDTO>) {
@@ -18,8 +18,6 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
const folderUID = rule && isGrafanaRulerRule(rule) ? rule.grafana_alert.namespace_uid : undefined;
const rulePermission = getRulesPermissions(rulesSourceName);
const hasEditPermission = contextSrv.hasPermission(rulePermission.update);
const hasRemovePermission = contextSrv.hasPermission(rulePermission.delete);
const { folder, loading } = useFolder(folderUID);
@@ -29,24 +27,32 @@ export function useIsRuleEditable(rulesSourceName: string, rule?: RulerRuleDTO):
// Grafana rules can be edited if user can edit the folder they're in
// When RBAC is disabled access to a folder is the only requirement for managing rules
// When RBAC is enabled the appropriate alerting permissions need to be met
if (isGrafanaRulerRule(rule)) {
if (!folderUID) {
throw new Error(
`Rule ${rule.grafana_alert.title} does not have a folder uid, cannot determine if it is editable.`
);
}
const canEditGrafanaRules = contextSrv.hasAccess(rulePermission.update, folder?.canSave ?? false);
const canRemoveGrafanaRules = contextSrv.hasAccess(rulePermission.delete, folder?.canSave ?? false);
return {
isEditable: hasEditPermission && folder?.canSave,
isRemovable: hasRemovePermission && folder?.canSave,
isEditable: canEditGrafanaRules,
isRemovable: canRemoveGrafanaRules,
loading,
};
}
// prom rules are only editable by users with Editor role and only if rules source supports editing
const isRulerAvailable = Boolean(dataSources[rulesSourceName]?.result?.rulerConfig);
const canEditCloudRules = contextSrv.hasAccess(rulePermission.update, contextSrv.isEditor);
const canRemoveCloudRules = contextSrv.hasAccess(rulePermission.delete, contextSrv.isEditor);
return {
isEditable: hasEditPermission && contextSrv.isEditor && isRulerAvailable,
isRemovable: hasRemovePermission && contextSrv.isEditor && isRulerAvailable,
isEditable: canEditCloudRules && isRulerAvailable,
isRemovable: canRemoveCloudRules && isRulerAvailable,
loading: dataSources[rulesSourceName]?.loading,
};
}
@@ -288,8 +288,17 @@ export function createFolder(payload: any) {
return getBackendSrv().post('/api/folders', payload);
}
export function searchFolders(query: any, permission?: PermissionLevelString): Promise<DashboardSearchHit[]> {
return getBackendSrv().get('/api/search', { query, type: 'dash-folder', permission });
export function searchFolders(
query: any,
permission?: PermissionLevelString,
withAccessControl = false
): Promise<DashboardSearchHit[]> {
return getBackendSrv().get('/api/search', {
query,
type: 'dash-folder',
permission,
accesscontrol: withAccessControl,
});
}
export function getFolderById(id: number): Promise<{ id: number; title: string }> {
+2 -2
View File
@@ -1,7 +1,7 @@
import { Dispatch } from 'react';
import { Action } from 'redux';
import { SelectableValue } from '@grafana/data';
import { SelectableValue, WithAccessControlMetadata } from '@grafana/data';
import { FolderInfo } from '../../types';
@@ -48,7 +48,7 @@ export interface DashboardSectionItem {
sortMetaName?: string;
}
export interface DashboardSearchHit extends DashboardSectionItem, DashboardSection {}
export interface DashboardSearchHit extends DashboardSectionItem, DashboardSection, WithAccessControlMetadata {}
export interface DashboardTag {
term: string;