Alerting: Add RBAC for enrichment (#113296)
* wip * wip 2 * prettier * fix tests * address pr feedback * address pr feedback 2 * address review comments * update useEnrichmentAbilities changing AlwaysSupported with onfig.featureToggles.alertEnrichmentfix
This commit is contained in:
@@ -13,7 +13,9 @@ import { PromAlertingRuleState, RulerRuleDTO } from 'app/types/unified-alerting-
|
||||
|
||||
import {
|
||||
AlertRuleAction,
|
||||
EnrichmentAction,
|
||||
skipToken,
|
||||
useEnrichmentAbility,
|
||||
useGrafanaPromRuleAbilities,
|
||||
useRulerRuleAbilities,
|
||||
} from '../../hooks/useAbilities';
|
||||
@@ -112,6 +114,8 @@ const AlertRuleMenu = ({
|
||||
|
||||
const extensionsAvailable = ruleExtensionLinks.length > 0;
|
||||
|
||||
const [enrichmentReadSupported, enrichmentReadAllowed] = useEnrichmentAbility(EnrichmentAction.Read);
|
||||
|
||||
/**
|
||||
* Since Incident isn't available as an open-source product we shouldn't show it for Open-Source licenced editions of Grafana.
|
||||
* We should show it in development mode
|
||||
@@ -139,7 +143,8 @@ const AlertRuleMenu = ({
|
||||
ruleUid &&
|
||||
handleManageEnrichments &&
|
||||
config.featureToggles.alertingEnrichmentPerRule &&
|
||||
config.featureToggles.alertEnrichment;
|
||||
enrichmentReadSupported &&
|
||||
enrichmentReadAllowed;
|
||||
|
||||
const menuItems = (
|
||||
<>
|
||||
|
||||
@@ -8,6 +8,10 @@ import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmana
|
||||
import { AccessControlAction } from 'app/types/accessControl';
|
||||
import { CombinedRule, RuleIdentifier } from 'app/types/unified-alerting';
|
||||
|
||||
import {
|
||||
__clearRuleViewTabsForTests,
|
||||
addEnrichmentSection,
|
||||
} from '../../enterprise-components/rule-view-page/extensions';
|
||||
import {
|
||||
getCloudRule,
|
||||
getGrafanaRule,
|
||||
@@ -86,6 +90,18 @@ const openSilenceDrawer = async () => {
|
||||
await screen.findByText(/Configure silences/i);
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
// Register the enrichment tab for all tests
|
||||
addEnrichmentSection();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clear tabs after each test to prevent interference
|
||||
__clearRuleViewTabsForTests();
|
||||
// Re-register for next test
|
||||
addEnrichmentSection();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
grantPermissionsHelper([
|
||||
AccessControlAction.AlertingRuleCreate,
|
||||
@@ -484,6 +500,34 @@ describe('RuleViewer', () => {
|
||||
);
|
||||
expect(screen.getByTestId('enrichment-section')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enrichment tab when user has enrichments:read permission', async () => {
|
||||
grantPermissionsHelper([AccessControlAction.AlertingRuleRead, AccessControlAction.AlertingEnrichmentsRead]);
|
||||
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Query);
|
||||
|
||||
// Check if enrichment tab exists in the navigation
|
||||
expect(screen.getByText('Alert enrichment')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide enrichment tab when user lacks enrichments:read permission', async () => {
|
||||
grantPermissionsHelper([AccessControlAction.AlertingRuleRead]);
|
||||
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Query);
|
||||
|
||||
// Check that enrichment tab does not exist in the navigation
|
||||
expect(screen.queryByText('Alert enrichment')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enrichment tab for admin users', async () => {
|
||||
grantPermissionsHelper([AccessControlAction.AlertingRuleRead]);
|
||||
jest.spyOn(require('../../utils/misc'), 'isAdmin').mockReturnValue(true);
|
||||
|
||||
await renderRuleViewer(mockRule, mockRuleIdentifier, ActiveTab.Query);
|
||||
|
||||
// Admin should see the tab even without explicit permission
|
||||
expect(screen.getByText('Alert enrichment')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import {
|
||||
AlertRuleAction,
|
||||
useAlertRuleAbility,
|
||||
useEnrichmentAbility,
|
||||
useGrafanaPromRuleAbilities,
|
||||
useGrafanaPromRuleAbility,
|
||||
useRulerRuleAbilities,
|
||||
@@ -29,6 +30,7 @@ const mocks = {
|
||||
useGrafanaPromRuleAbility: jest.mocked(useGrafanaPromRuleAbility),
|
||||
useRulerRuleAbilities: jest.mocked(useRulerRuleAbilities),
|
||||
useGrafanaPromRuleAbilities: jest.mocked(useGrafanaPromRuleAbilities),
|
||||
useEnrichmentAbility: jest.mocked(useEnrichmentAbility),
|
||||
};
|
||||
|
||||
setPluginLinksHook(() => ({
|
||||
@@ -63,6 +65,7 @@ describe('RulesTable RBAC', () => {
|
||||
mocks.useAlertRuleAbility.mockReturnValue([false, false]);
|
||||
mocks.useRulerRuleAbility.mockReturnValue([false, false]);
|
||||
mocks.useGrafanaPromRuleAbility.mockReturnValue([false, false]);
|
||||
mocks.useEnrichmentAbility.mockReturnValue([false, false]);
|
||||
|
||||
// Plural hooks (used by AlertRuleMenu) - need to return arrays based on input actions
|
||||
mocks.useRulerRuleAbilities.mockImplementation((_rule, _groupIdentifier, actions) => {
|
||||
|
||||
+22
-19
@@ -5,6 +5,7 @@ import { t } from '@grafana/i18n';
|
||||
import { FeatureBadge, useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { useAlertRule } from '../../components/rule-viewer/RuleContext';
|
||||
import { EnrichmentAction, useEnrichmentAbility } from '../../hooks/useAbilities';
|
||||
import { rulerRuleType } from '../../utils/rules';
|
||||
|
||||
type SetActiveTab = (tab: string) => void;
|
||||
@@ -14,7 +15,7 @@ type RuleViewTabBuilderArgs = {
|
||||
setActiveTab: SetActiveTab;
|
||||
};
|
||||
|
||||
type RuleViewTabBuilder = (args: RuleViewTabBuilderArgs) => NavModelItem;
|
||||
type RuleViewTabBuilder = (args: RuleViewTabBuilderArgs) => NavModelItem | null;
|
||||
type RuleViewTabBuilderConfig = {
|
||||
filterOnlyGrafanaAlertRules: boolean;
|
||||
ruleViewTabBuilder: RuleViewTabBuilder;
|
||||
@@ -29,21 +30,35 @@ function registerRuleViewTab(builder: RuleViewTabBuilder) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRuleViewExtensionTabs(args: RuleViewTabBuilderArgs): NavModelItem[] {
|
||||
const { rule } = useAlertRule();
|
||||
const isGrafanaAlertRule = rulerRuleType.grafana.alertingRule(rule.rulerRule);
|
||||
export function getRuleViewExtensionTabs(args: RuleViewTabBuilderArgs, isGrafanaAlertRule: boolean): NavModelItem[] {
|
||||
return ruleViewTabBuilders
|
||||
.filter((config) => {
|
||||
if (config.filterOnlyGrafanaAlertRules) {
|
||||
return isGrafanaAlertRule;
|
||||
// Check if rule type matches requirement
|
||||
if (config.filterOnlyGrafanaAlertRules && !isGrafanaAlertRule) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((config) => config.ruleViewTabBuilder(args));
|
||||
.map((config) => config.ruleViewTabBuilder(args))
|
||||
.filter((item): item is NavModelItem => item !== null);
|
||||
}
|
||||
|
||||
export function useRuleViewExtensionTabs(args: RuleViewTabBuilderArgs): NavModelItem[] {
|
||||
const { rule } = useAlertRule();
|
||||
const isGrafanaAlertRule = rulerRuleType.grafana.alertingRule(rule.rulerRule);
|
||||
|
||||
return getRuleViewExtensionTabs(args, isGrafanaAlertRule);
|
||||
}
|
||||
|
||||
export function addEnrichmentSection() {
|
||||
registerRuleViewTab(({ activeTab, setActiveTab }) => {
|
||||
const [, canReadEnrichments] = useEnrichmentAbility(EnrichmentAction.Read);
|
||||
|
||||
// Return null if user doesn't have permission (will be filtered out)
|
||||
if (!canReadEnrichments) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tabId = 'enrichment';
|
||||
return {
|
||||
text: t('alerting.use-page-nav.page-nav.text.enrichment', 'Alert enrichment'),
|
||||
@@ -59,18 +74,6 @@ export function __clearRuleViewTabsForTests() {
|
||||
ruleViewTabBuilders.splice(0, ruleViewTabBuilders.length);
|
||||
}
|
||||
|
||||
// ONLY FOR TESTS: non-hook version for testing
|
||||
export function getRuleViewExtensionTabs(args: RuleViewTabBuilderArgs, isGrafanaAlertRule: boolean): NavModelItem[] {
|
||||
return ruleViewTabBuilders
|
||||
.filter((config) => {
|
||||
if (config.filterOnlyGrafanaAlertRules) {
|
||||
return isGrafanaAlertRule;
|
||||
}
|
||||
return true;
|
||||
})
|
||||
.map((config) => config.ruleViewTabBuilder(args));
|
||||
}
|
||||
|
||||
function getStyles() {
|
||||
return {
|
||||
tabSuffix: css({
|
||||
|
||||
+9
-1
@@ -1,12 +1,20 @@
|
||||
import { beforeEach, describe, expect, it } from '@jest/globals';
|
||||
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
|
||||
|
||||
import { addRulePageEnrichmentSection } from '../../components/rule-viewer/tabs/extensions/RuleViewerExtension';
|
||||
import { useEnrichmentAbility } from '../../hooks/useAbilities';
|
||||
|
||||
import { __clearRuleViewTabsForTests, addEnrichmentSection, getRuleViewExtensionTabs } from './extensions';
|
||||
|
||||
jest.mock('../../hooks/useAbilities');
|
||||
|
||||
const mocks = {
|
||||
useEnrichmentAbility: jest.mocked(useEnrichmentAbility),
|
||||
};
|
||||
|
||||
describe('rule-view-page navigation', () => {
|
||||
beforeEach(() => {
|
||||
__clearRuleViewTabsForTests();
|
||||
mocks.useEnrichmentAbility.mockReturnValue([false, true]);
|
||||
});
|
||||
|
||||
it('does not include Alert enrichment tab when not registered', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { getWrapper, render, renderHook, screen, waitFor } from 'test/test-utils';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
|
||||
import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure';
|
||||
import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants';
|
||||
@@ -10,16 +11,21 @@ import { CombinedRule } from 'app/types/unified-alerting';
|
||||
|
||||
import { getCloudRule, getGrafanaRule, grantUserPermissions, mockDataSource } from '../mocks';
|
||||
import { AlertmanagerProvider } from '../state/AlertmanagerContext';
|
||||
import { grantPermissionsHelper } from '../test/test-utils';
|
||||
import { setupDataSources } from '../testSetup/datasources';
|
||||
import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
|
||||
import * as misc from '../utils/misc';
|
||||
|
||||
import {
|
||||
AlertRuleAction,
|
||||
AlertmanagerAction,
|
||||
EnrichmentAction,
|
||||
useAlertmanagerAbilities,
|
||||
useAlertmanagerAbility,
|
||||
useAllAlertRuleAbilities,
|
||||
useAllAlertmanagerAbilities,
|
||||
useEnrichmentAbilities,
|
||||
useEnrichmentAbility,
|
||||
} from './useAbilities';
|
||||
|
||||
/**
|
||||
@@ -213,6 +219,126 @@ describe('AlertRule abilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichment abilities', () => {
|
||||
setupMswServer();
|
||||
|
||||
const originalFeatureToggle = config.featureToggles.alertEnrichment;
|
||||
|
||||
beforeEach(() => {
|
||||
// Default to feature toggle enabled
|
||||
config.featureToggles.alertEnrichment = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles.alertEnrichment = originalFeatureToggle;
|
||||
});
|
||||
|
||||
it('should grant read and write permissions to admin users when feature is enabled', () => {
|
||||
grantPermissionsHelper([]);
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() });
|
||||
|
||||
const [readSupported, readAllowed] = result.current[EnrichmentAction.Read];
|
||||
const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write];
|
||||
|
||||
expect(readSupported).toBe(true);
|
||||
expect(readAllowed).toBe(true);
|
||||
expect(writeSupported).toBe(true);
|
||||
expect(writeAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should grant read permission when user has enrichments:read permission', () => {
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(false);
|
||||
grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead]);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() });
|
||||
|
||||
const [readSupported, readAllowed] = result.current[EnrichmentAction.Read];
|
||||
const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write];
|
||||
|
||||
expect(readSupported).toBe(true);
|
||||
expect(readAllowed).toBe(true);
|
||||
expect(writeSupported).toBe(true);
|
||||
expect(writeAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should grant write permission when user has enrichments:write permission', () => {
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(false);
|
||||
grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsWrite]);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() });
|
||||
|
||||
const [readSupported, readAllowed] = result.current[EnrichmentAction.Read];
|
||||
const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write];
|
||||
|
||||
expect(readSupported).toBe(true);
|
||||
expect(readAllowed).toBe(false);
|
||||
expect(writeSupported).toBe(true);
|
||||
expect(writeAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should grant both read and write permissions when user has both permissions', () => {
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(false);
|
||||
grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead, AccessControlAction.AlertingEnrichmentsWrite]);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() });
|
||||
|
||||
const [readSupported, readAllowed] = result.current[EnrichmentAction.Read];
|
||||
const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write];
|
||||
|
||||
expect(readSupported).toBe(true);
|
||||
expect(readAllowed).toBe(true);
|
||||
expect(writeSupported).toBe(true);
|
||||
expect(writeAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should deny all permissions when user is not admin and has no permissions', () => {
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(false);
|
||||
grantPermissionsHelper([]);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() });
|
||||
|
||||
const [readSupported, readAllowed] = result.current[EnrichmentAction.Read];
|
||||
const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write];
|
||||
|
||||
expect(readSupported).toBe(true);
|
||||
expect(readAllowed).toBe(false);
|
||||
expect(writeSupported).toBe(true);
|
||||
expect(writeAllowed).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct ability for specific action using useEnrichmentAbility', () => {
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(false);
|
||||
grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead]);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbility(EnrichmentAction.Read), { wrapper: wrapper() });
|
||||
|
||||
const [supported, allowed] = result.current;
|
||||
|
||||
expect(supported).toBe(true);
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
it('should report enrichments as not supported when feature toggle is disabled', () => {
|
||||
config.featureToggles.alertEnrichment = false;
|
||||
jest.spyOn(misc, 'isAdmin').mockReturnValue(true);
|
||||
grantPermissionsHelper([AccessControlAction.AlertingEnrichmentsRead, AccessControlAction.AlertingEnrichmentsWrite]);
|
||||
|
||||
const { result } = renderHook(() => useEnrichmentAbilities(), { wrapper: wrapper() });
|
||||
|
||||
const [readSupported, readAllowed] = result.current[EnrichmentAction.Read];
|
||||
const [writeSupported, writeAllowed] = result.current[EnrichmentAction.Write];
|
||||
|
||||
// Enrichments not supported when feature toggle is off
|
||||
expect(readSupported).toBe(false);
|
||||
expect(writeSupported).toBe(false);
|
||||
// Permissions would be granted if it were supported
|
||||
expect(readAllowed).toBe(true);
|
||||
expect(writeAllowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function createAlertmanagerWrapper(alertmanagerSourceName: string) {
|
||||
const ProviderWrapper = getWrapper({ renderWithRouter: true });
|
||||
const wrapperComponent = (props: PropsWithChildren) => (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { contextSrv as ctx } from 'app/core/services/context_srv';
|
||||
import { PERMISSIONS_CONTACT_POINTS_READ } from 'app/features/alerting/unified/components/contact-points/permissions';
|
||||
import {
|
||||
@@ -96,6 +97,12 @@ export enum AlertRuleAction {
|
||||
DeletePermanently = 'delete-alert-rule-permanently',
|
||||
}
|
||||
|
||||
// this enum lists all of the available actions we can perform with enrichments
|
||||
export enum EnrichmentAction {
|
||||
Read = 'read-enrichment',
|
||||
Write = 'write-enrichment',
|
||||
}
|
||||
|
||||
// this enum list all of the bulk actions we can perform on a folder
|
||||
export enum FolderBulkAction {
|
||||
Pause = 'pause-folder', // unpause permissions are the same as pause
|
||||
@@ -124,7 +131,7 @@ export enum AlertingAction {
|
||||
const AlwaysSupported = true;
|
||||
const NotSupported = false;
|
||||
|
||||
export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction;
|
||||
export type Action = AlertmanagerAction | AlertingAction | AlertRuleAction | FolderBulkAction | EnrichmentAction;
|
||||
|
||||
/**
|
||||
* Represents the ability to perform an action, with two distinct checks:
|
||||
@@ -189,6 +196,28 @@ export const useAlertingAbility = (action: AlertingAction): Ability => {
|
||||
return allAbilities[action];
|
||||
};
|
||||
|
||||
/**
|
||||
* This one will check for enrichment abilities
|
||||
*/
|
||||
export const useEnrichmentAbilities = (): Abilities<EnrichmentAction> => {
|
||||
const userIsAdmin = isAdmin();
|
||||
const hasReadPermission = ctx.hasPermission(AccessControlAction.AlertingEnrichmentsRead);
|
||||
const hasWritePermission = ctx.hasPermission(AccessControlAction.AlertingEnrichmentsWrite);
|
||||
|
||||
// Enrichments are only available when the feature toggle is enabled
|
||||
const enrichmentsSupported = Boolean(config.featureToggles.alertEnrichment);
|
||||
|
||||
return {
|
||||
[EnrichmentAction.Read]: [enrichmentsSupported, userIsAdmin || hasReadPermission],
|
||||
[EnrichmentAction.Write]: [enrichmentsSupported, userIsAdmin || hasWritePermission],
|
||||
};
|
||||
};
|
||||
|
||||
export const useEnrichmentAbility = (action: EnrichmentAction): Ability => {
|
||||
const allAbilities = useEnrichmentAbilities();
|
||||
return allAbilities[action];
|
||||
};
|
||||
|
||||
/**
|
||||
* This hook will check if we support the action and have sufficient permissions for it on a single alert rule
|
||||
*/
|
||||
|
||||
@@ -155,6 +155,10 @@ export enum AccessControlAction {
|
||||
AlertingTemplatesWrite = 'alert.notifications.templates:write',
|
||||
AlertingTemplatesDelete = 'alert.notifications.templates:delete',
|
||||
|
||||
// Alerting enrichments actions
|
||||
AlertingEnrichmentsRead = 'alert.enrichments:read',
|
||||
AlertingEnrichmentsWrite = 'alert.enrichments:write',
|
||||
|
||||
PluginsInstall = 'plugins:install',
|
||||
PluginsWrite = 'plugins:write',
|
||||
|
||||
|
||||
Reference in New Issue
Block a user