diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx index a36a1fada67..8834fd72580 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.tsx @@ -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 = ( <> diff --git a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx index 253418c3135..9e91bd34178 100644 --- a/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rule-viewer/RuleViewer.test.tsx @@ -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(); + }); }); }); diff --git a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx index a4be47bbf81..61c143ab843 100644 --- a/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesTable.test.tsx @@ -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) => { diff --git a/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx b/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx index a4d5034e103..bc366ec1cfe 100644 --- a/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx +++ b/public/app/features/alerting/unified/enterprise-components/rule-view-page/extensions.tsx @@ -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({ diff --git a/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts b/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts index 9fdaa2a74d9..b4ea7312d86 100644 --- a/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts +++ b/public/app/features/alerting/unified/enterprise-components/rule-view-page/navigation.test.ts @@ -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', () => { diff --git a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx index 3fdd05e6c63..b08d31a631c 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.test.tsx +++ b/public/app/features/alerting/unified/hooks/useAbilities.test.tsx @@ -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) => ( diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index 5635a3d666a..535d99d0bc0 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -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 => { + 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 */ diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index abbf8969b05..d09459b9f9b 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -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',