Authorization bypass / Privilege escalation
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]