Authorization bypass / Privilege escalation

MEDIUM
grafana/grafana
Commit: b89b76c78cd8
Affected: <=12.4.0
2026-07-17 20:16 UTC

Description

The commit tightens access control for the /alerting/import-to-gma route by replacing a blanket Admin-only gate with a more granular evaluation that requires specific alerting permissions (AlertingRuleCreate, AlertingProvisioningSetStatus, AlertingNotificationsWrite). This prevents potential authorization bypass to the Grafana Migrate/Import to GMA flow by users who should not have access. The change includes code updates to routes, related components, and tests to verify permissions and feature flag gating. This is a genuine security improvement (authorization/authZ fix) rather than a mere dependency bump or cleanup.

Commit Details

Author: Rodrigo Vasconcelos de Barros

Date: 2026-07-17 19:16 UTC

Message:

Alerting: Add import to GMA promote banner for external AM users (#128412) * Add import to GMA promote banner for external AM users Adds a banner to promote the import to GMA wizard in relevant pages when: - The user selects an external alertmanager from the dropdown in the notification screen pages - The user is on the alert rules list and has external alert managers configured * Address code review comments * Address review comments

Triage Assessment

Vulnerability Type: Authorization bypass / Privilege escalation

Confidence: MEDIUM

Reasoning:

The commit adjusts route access for the /alerting/import-to-gma page to require specific permissions (AlertingRuleCreate, AlertingProvisioningSetStatus, AlertingNotificationsWrite), tightening authorization and preventing access by unauthorized users. This mitigates potential authorization bypass to the GMA import flow.

Verification Assessment

Vulnerability Type: Authorization bypass / Privilege escalation

Confidence: MEDIUM

Affected Versions: <=12.4.0

Code Diff

diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx index 266d42a1068a7..d0898a7f19727 100644 --- a/public/app/features/alerting/routes.tsx +++ b/public/app/features/alerting/routes.tsx @@ -307,7 +307,11 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] { }, { path: '/alerting/import-to-gma', - roles: () => ['Admin'], + roles: evaluateAccess([ + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingProvisioningSetStatus, + AccessControlAction.AlertingNotificationsWrite, + ]), component: config.featureToggles.alertingMigrationWizardUI ? importAlertingComponent( () => diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.test.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.test.tsx index d5f72119871dd..c1ca89c64566e 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.test.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.test.tsx @@ -1,13 +1,16 @@ -import { render, screen } from 'test/test-utils'; +import { render, screen, testWithFeatureToggles, waitFor } from 'test/test-utils'; +import { byRole } from 'testing-library-selector'; -import { type NavModelItem } from '@grafana/data'; -import { type AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types'; +import { type NavModelItem, OrgRole } from '@grafana/data'; +import { type AlertManagerDataSourceJsonData, AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types/accessControl'; import { setupMswServer } from '../mockApi'; -import { grantUserPermissions, mockDataSource } from '../mocks'; +import { grantUserPermissions, grantUserRole, mockDataSource } from '../mocks'; +import { setupAdminConfigGet } from '../mocks/server/configure/admin_config'; import { setupDataSources } from '../testSetup/datasources'; -import { DataSourceType } from '../utils/datasource'; +import { ALERTMANAGER_NAME_QUERY_KEY } from '../utils/constants'; +import { DataSourceType, GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { AlertmanagerPageWrapper } from './AlertingPageWrapper'; @@ -17,7 +20,7 @@ const testPageNav: NavModelItem = { url: '/alerting', }; -setupMswServer(); +const server = setupMswServer(); describe('AlertmanagerPageWrapper', () => { afterEach(() => { @@ -163,4 +166,132 @@ describe('AlertmanagerPageWrapper', () => { expect(screen.queryByTestId('alertmanager-picker')).not.toBeInTheDocument(); }); }); + + describe('ImportToGMA banner', () => { + testWithFeatureToggles({ enable: ['alertingMigrationWizardUI'] }); + + const importBanner = byRole('link', { name: /import to grafana alerting/i }); + + const setupExternalAlertmanager = () => { + const alertmanagerDataSource = mockDataSource<AlertManagerDataSourceJsonData>({ + name: 'external-alertmanager', + uid: 'external-alertmanager-uid', + type: DataSourceType.Alertmanager, + }); + setupDataSources(alertmanagerDataSource); + return alertmanagerDataSource; + }; + + const renderWithSelectedAlertmanager = (alertmanagerName: string) => + render( + <AlertmanagerPageWrapper accessType="notification" pageNav={testPageNav}> + <div>Test content</div> + </AlertmanagerPageWrapper>, + { + historyOptions: { + initialEntries: [`/alerting/notifications?${ALERTMANAGER_NAME_QUERY_KEY}=${alertmanagerName}`], + }, + } + ); + + it('shows the import banner when an external Alertmanager is selected and the user can import notifications', async () => { + const externalAm = setupExternalAlertmanager(); + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + + renderWithSelectedAlertmanager(externalAm.name); + + expect(await importBanner.find()).toBeInTheDocument(); + }); + + it('does not show the import banner when the Grafana Alertmanager is selected', async () => { + setupExternalAlertmanager(); + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + + renderWithSelectedAlertmanager(GRAFANA_RULES_SOURCE_NAME); + + expect(await screen.findByText('Test content')).toBeInTheDocument(); + expect(importBanner.query()).not.toBeInTheDocument(); + }); + + it('does not show the import banner when the user cannot import notifications', async () => { + const externalAm = setupExternalAlertmanager(); + // Read-only external access, no notifications write permission. + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + ]); + + renderWithSelectedAlertmanager(externalAm.name); + + expect(await screen.findByText('Test content')).toBeInTheDocument(); + expect(importBanner.query()).not.toBeInTheDocument(); + }); + + describe('while Mimir Alertmanager auto-sync is active', () => { + testWithFeatureToggles({ enable: ['alertingMigrationWizardUI', 'alerting.syncExternalAlertmanager'] }); + + it('does not show the import banner', async () => { + const externalAm = setupExternalAlertmanager(); + grantUserRole(OrgRole.Admin); + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + setupAdminConfigGet(server, { + alertmanagersChoice: AlertmanagerChoice.Internal, + external_alertmanager_uid: 'mimir-uid', + }); + + renderWithSelectedAlertmanager(externalAm.name); + + expect(await screen.findByText('Test content')).toBeInTheDocument(); + // The banner renders before the admin_config query resolves, then hides once auto-sync + // is known to be active, so wait for it to be removed. + await waitFor(() => { + expect(importBanner.query()).not.toBeInTheDocument(); + }); + }); + }); + }); + + describe('ImportToGMA banner with feature toggle disabled', () => { + testWithFeatureToggles({ enable: [] }); + + it('does not show the import banner when the feature toggle is off', async () => { + const alertmanagerDataSource = mockDataSource<AlertManagerDataSourceJsonData>({ + name: 'external-alertmanager', + uid: 'external-alertmanager-uid', + type: DataSourceType.Alertmanager, + }); + setupDataSources(alertmanagerDataSource); + grantUserPermissions([ + AccessControlAction.AlertingNotificationsRead, + AccessControlAction.AlertingNotificationsExternalRead, + AccessControlAction.AlertingNotificationsWrite, + ]); + + render( + <AlertmanagerPageWrapper accessType="notification" pageNav={testPageNav}> + <div>Test content</div> + </AlertmanagerPageWrapper>, + { + historyOptions: { + initialEntries: [`/alerting/notifications?${ALERTMANAGER_NAME_QUERY_KEY}=${alertmanagerDataSource.name}`], + }, + } + ); + + expect(await screen.findByText('Test content')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /import to grafana alerting/i })).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx index cacb38be38de8..9bd0288a02273 100644 --- a/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx +++ b/public/app/features/alerting/unified/components/AlertingPageWrapper.tsx @@ -1,14 +1,18 @@ import { type PropsWithChildren, type ReactNode, useMemo } from 'react'; import { useLocation } from 'react-use'; +import { config } from '@grafana/runtime'; import { Page } from 'app/core/components/Page/Page'; import { type PageProps } from 'app/core/components/Page/types'; +import { useImportEntrypointState } from '../hooks/useImportEntrypointState'; import { AlertmanagerProvider, useAlertmanager } from '../state/AlertmanagerContext'; import { getAlertManagerDataSourcesByPermission } from '../utils/datasource'; import { AlertManagerPicker } from './AlertManagerPicker'; import { NoAlertManagerWarning } from './NoAlertManagerWarning'; +import { ImportToGMABanner } from './import-to-gma/ImportToGMABanner'; +import { useCanImportToGMA } from './import-to-gma/useCanImportToGMA'; /** * This is the main alerting page wrapper, used by the alertmanager page wrapper and the alert rules list view @@ -76,5 +80,32 @@ const AlertManagerPagePermissionsCheck = ({ children }: PropsWithChildren) => { return <NoAlertManagerWarning availableAlertManagers={availableAlertManagers} />; } - return <>{children}</>; + return ( + <> + <ImportToGMABannerForAlertmanager /> + {children} + </> + ); }; + +/** + * Invites the user to import an external Alertmanager's configuration into + * Grafana-managed alerting. Only rendered once a valid Alertmanager is selected. Suppressed on edit/new forms, where the Alertmanager picker is disabled and a + * promo banner is noise. + */ +function ImportToGMABannerForAlertmanager() { + const { isGrafanaAlertmanager } = useAlertmanager(); + const { canImportNotifications } = useCanImportToGMA(); + const isEditOrNewForm = useIsDisabledAlertmanagerSelection(); + const { disabled: importDisabled, isLoading: importStateLoading } = useImportEntrypointState(); + + const showBanner = + Boolean(config.featureToggles.alertingMigrationWizardUI) && + !isGrafanaAlertmanager && + canImportNotifications && + !isEditOrNewForm && + !importDisabled && + !importStateLoading; + + return showBanner ? <ImportToGMABanner /> : null; +} diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMA.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMA.tsx index 7970da4a9f822..a8ef1c175b383 100644 --- a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMA.tsx +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMA.tsx @@ -21,8 +21,6 @@ import { useStyles2, } from '@grafana/ui'; import { useAppNotification } from 'app/core/copy/appNotification'; -import { contextSrv } from 'app/core/services/context_srv'; -import { AccessControlAction } from 'app/types/accessControl'; import { type RulerRulesConfigDTO } from 'app/types/unified-alerting-dto'; import { @@ -60,6 +58,7 @@ import { Step2Content, useStep2Validation } from './steps/Step2AlertRules'; import { StepImportMethod, isAutoSyncSegmentEnabled } from './steps/StepImportMethod'; import { StepReviewEnableAutoSync } from './steps/StepReviewEnableAutoSync'; import { type DryRunValidationResult, type PromoteStatsSummary } from './types'; +import { useCanImportToGMA } from './useCanImportToGMA'; import { buildRoutingParams, filterRulerRulesConfig, @@ -241,10 +240,7 @@ function ImportWizardContent() { const [step1Completed, step1Skipped] = watch(['step1Completed', 'step1Skipped']); // Permission checks aligned with backend authorization.go - const canImportNotifications = contextSrv.hasPermission(AccessControlAction.AlertingNotificationsWrite); - const canImportRules = - contextSrv.hasPermission(AccessControlAction.AlertingRuleCreate) && - contextSrv.hasPermission(AccessControlAction.AlertingProvisioningSetStatus); + const { canImportNotifications, canImportRules } = useCanImportToGMA(); // Trigger dry-run validation (called automatically by Step1 when source changes) const handleTriggerDryRun = useCallback(() => { diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMABanner.test.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMABanner.test.tsx new file mode 100644 index 0000000000000..87d1e08306a67 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMABanner.test.tsx @@ -0,0 +1,53 @@ +import { render } from 'test/test-utils'; +import { byRole } from 'testing-library-selector'; + +import { config } from '@grafana/runtime'; + +import { ImportToGMABanner } from './ImportToGMABanner'; +import { getImportToGMABannerDismissedKey } from './useImportToGMABannerPrefs'; + +const dismissedKey = getImportToGMABannerDismissedKey(config.bootData.user.orgId); + +const ui = { + banner: byRole('status'), + openButton: byRole('link', { name: /import to grafana alerting/i }), + closeButton: byRole('button', { name: /close/i }), +}; + +describe('ImportToGMABanner', () => { + beforeEach(() => { + localStorage.removeItem(dismissedKey); + }); + + it('renders the banner with a CTA to the import wizard', () => { + render(<ImportToGMABanner />); + + expect(ui.banner.get()).toBeInTheDocument(); + expect(ui.openButton.get()).toHaveAttribute('href', expect.stringContaining('/alerting/import-to-gma')); + }); + + it('persists dismissal and hides the banner when dismissed', async () => { + const { user } = render(<ImportToGMABanner />); + + await user.click(ui.closeButton.get()); + + expect(ui.banner.query()).not.toBeInTheDocument(); + expect(localStorage.getItem(dismissedKey)).toBeTruthy(); + }); + + it('does not render when it was previously dismissed', () => { + localStorage.setItem(dismissedKey, 'true'); + + render(<ImportToGMABanner />); + + expect(ui.banner.query()).not.toBeInTheDocument(); + }); + + it('still renders when only another org dismissed the banner', () => { + localStorage.setItem(getImportToGMABannerDismissedKey(config.bootData.user.orgId + 1), 'true'); + + render(<ImportToGMABanner />); + + expect(ui.banner.get()).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/alerting/unified/components/import-to-gma/ImportToGMABanner.tsx b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMABanner.tsx new file mode 100644 index 0000000000000..f9e6e53d027e7 --- /dev/null +++ b/public/app/features/alerting/unified/components/import-to-gma/ImportToGMABanner.tsx @@ -0,0 +1,42 @@ +import { Trans, t } from '@grafana/i18n'; +import { Alert, LinkButton, Stack, Text } from '@grafana/ui'; + +import { ALERTING_PATHS } from '../../utils/navigation'; +import { createRelativeUrl } from '../../utils/url'; + +import { useImportToGMABannerPrefs } from './useImportToGMABannerPre ... [truncated]
← Back to Alerts View on GitHub →