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:
Sonia Aguilar
2025-11-26 11:17:51 +01:00
committed by GitHub
parent 513b81a531
commit 5538dfe73d
8 changed files with 244 additions and 22 deletions
@@ -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) => {
@@ -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({
@@ -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
*/
+4
View File
@@ -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',