Information disclosure / Access control
Description
The commit adds UI-level RBAC checks to hide or disable access to contact point information and certain plugin pages unless the user has permission. It introduces a canAccessPluginPage helper and uses it to conditionally render links or show tooltips, mitigating information disclosure via the UI (e.g., contact point search links) and gating access to plugin-related pages in the Alerts UI. This is a security-related improvement focused on the presentation layer and does not appear to enforce backend access control beyond the UI. It is a genuine fix for information disclosure via the UI, with medium confidence given potential gaps in backend enforcement.
Proof of Concept
Proof-of-concept (exploit path):
Background: In Grafana 12.x prior to this patch, alerting UI could leak contact point details by rendering a Delivered to link that navigates to a contact point search, and certain plugin pages could be accessed or presented in the UI without proper per-page RBAC checks. The patch adds UI guards to hide/disable those links and introduces canAccessPluginPage to gate per-plugin page access based on action/role hints in plugin includes.
Goal: Demonstrate information disclosure and improper access to plugin pages via the UI for a user without the necessary permissions.
Prerequisites:
- Grafana 12.x instance with Incident plugin (or similar plugin with includes that gate pages).
- A user account with limited UI permissions (e.g., OrgRole Viewer or a user lacking AlertingNotificationsRead / AlertingReceiversRead or specific plugin actions).
- An alert group with a receiver/contact point that would previously render a link to view the contact point.
Steps to reproduce (pre-fix behavior, i.e., without this patch):
1) Log in as a restricted user (no permission to view contact points).
2) Open an alert group detail view that shows a section like:
Delivered to John Doe
(previous UI would render 'John Doe' as a clickable link to a contact point search).
3) Click the Delivered-to contact point link or inspect the element; you should be able to navigate to a contact point search and view details about the contact point, causing information disclosure.
Steps to reproduce (post-fix behavior, with this patch):
1) Log in as the same restricted user.
2) Open the same alert group detail view.
3) Observe that the Delivered to line now renders without a usable link or shows a disabled state with a tooltip such as: "You do not have permission to view contact points". The contact point link is not navigable and the actual contact point details are not exposed in the UI.
Optional automated PoC (Playwright-based):
// POC script sketch (fill in exact selectors/paths for your environment)
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000/login');
await page.fill('#login', process.env.GRAFANA_USER);
await page.fill('#password', process.env.GRAFANA_PASSWORD);
await page.click('button[type="submit"]');
await page.waitForNavigation();
// Navigate to an alerting group detail page that contains a Delivered-to contact point
await page.goto('http://localhost:3000/grafana/alerting/group/EXAMPLE');
// Pre-fix-ish expectation: presence of a link to view the contact point
const linkVisible = await page.$('a[href*="contact-point-search"]') !== null;
console.log('Contact point link visible (should be false after patch):', linkVisible);
// If the UI renders a non-link with a tooltip when lacking permission, you might instead assert disabled state
// Example:
// const isDisabled = await page.$('text=Delivered to JOHN_DOE') && await page.$('span[aria-disabled="true"]');
// console.log('Contact point is disabled with tooltip (expected after patch):', !!isDisabled);
await browser.close();
})();
Notes:
- The exact selectors and URL paths depend on your Grafana deployment and the alerting plugin configuration. Replace EXAMPLE with a real alert group ID and adjust selectors accordingly.
- The key proof is that, after this patch, restricted users should no longer have an accessible link to view the contact point; either the link is removed or rendered with a disabled state and a tooltip explaining the permission requirement.
Commit Details
Author: Lauren
Date: 2026-04-01 11:32 UTC
Message:
Alerting: Improve alerts activity UI RBAC (#121458)
Triage Assessment
Vulnerability Type: Information disclosure / Access control
Confidence: MEDIUM
Reasoning:
The commit adds UI-level RBAC checks to hide or disable access to contact point information and plugin pages unless the user has permission. This reduces information disclosure and unauthorized access by not exposing contact point details and by gating access to certain plugin pages. It introduces a canAccessPluginPage helper and uses it to conditionally render links or show tooltips, indicating an access control/security-related improvement.
Verification Assessment
Vulnerability Type: Information disclosure / Access control
Confidence: MEDIUM
Affected Versions: <= 12.4.0 (Grafana 12.x line, tracked version 12.4.0)
Code Diff
diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx
index 5c5164d1398b8..b83d403d87895 100644
--- a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx
+++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx
@@ -3,10 +3,11 @@ import { useState } from 'react';
import { AlertLabels } from '@grafana/alerting/unstable';
import { type GrafanaTheme2 } from '@grafana/data';
-import { Trans } from '@grafana/i18n';
-import { Stack, TextLink, useStyles2 } from '@grafana/ui';
+import { Trans, t } from '@grafana/i18n';
+import { Stack, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
import { type AlertmanagerGroup } from 'app/plugins/datasource/alertmanager/types';
+import { useCanViewContactPoints } from '../../hooks/useAbilities';
import { createContactPointSearchLink } from '../../utils/misc';
import { CollapseToggle } from '../CollapseToggle';
import { MetaText } from '../MetaText';
@@ -22,6 +23,7 @@ interface Props {
export const AlertGroup = ({ alertManagerSourceName, group }: Props) => {
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
const styles = useStyles2(getStyles);
+ const canViewContactPoint = useCanViewContactPoints();
// When group is grouped, receiver.name is 'NONE' as it can contain multiple receivers
const receiverInGroup = group.receiver.name !== 'NONE';
@@ -43,17 +45,32 @@ export const AlertGroup = ({ alertManagerSourceName, group }: Props) => {
{receiverInGroup && (
<MetaText icon="at">
- <Trans i18nKey="alerting.alert-group.delivered-to" values={{ name: group.receiver.name }}>
- Delivered to{' '}
- <TextLink
- href={createContactPointSearchLink(contactPoint, alertManagerSourceName)}
- variant="bodySmall"
- color="primary"
- inline={false}
+ {canViewContactPoint ? (
+ <Trans i18nKey="alerting.alert-group.delivered-to" values={{ name: group.receiver.name }}>
+ Delivered to{' '}
+ <TextLink
+ href={createContactPointSearchLink(contactPoint, alertManagerSourceName)}
+ variant="bodySmall"
+ color="primary"
+ inline={false}
+ >
+ {'{{name}}'}
+ </TextLink>
+ </Trans>
+ ) : (
+ <Tooltip
+ content={t(
+ 'alerting.alert-group.view-contact-point-no-permission',
+ 'You do not have permission to view contact points'
+ )}
>
- {'{{name}}'}
- </TextLink>
- </Trans>
+ <span>
+ {t('alerting.alert-group.delivered-to-disabled', 'Delivered to {{name}}', {
+ name: contactPoint,
+ })}
+ </span>
+ </Tooltip>
+ )}
</MetaText>
)}
</Stack>
@@ -92,4 +109,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
flexDirection: 'row',
alignItems: 'center',
}),
+ disabledContactPointName: css({
+ opacity: 0.7,
+ cursor: 'not-allowed',
+ }),
});
diff --git a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx
index c469aea8c09aa..789ef18d71168 100644
--- a/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx
+++ b/public/app/features/alerting/unified/components/bridges/DeclareIncidentButton.tsx
@@ -1,7 +1,7 @@
import { t } from '@grafana/i18n';
import { Menu, Tooltip } from '@grafana/ui';
-import { useIrmPlugin } from '../../hooks/usePluginBridge';
+import { canAccessPluginPage, useIrmPlugin } from '../../hooks/usePluginBridge';
import { SupportedPlugin } from '../../types/pluginBridges';
import { createBridgeURL } from '../PluginBridge';
@@ -13,12 +13,14 @@ interface Props {
export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }: Props) => {
const { pluginId, loading, installed, settings } = useIrmPlugin(SupportedPlugin.Incident);
+ const incidentPath = '/incidents/declare';
- const bridgeURL = createBridgeURL(pluginId, '/incidents/declare', {
+ const bridgeURL = createBridgeURL(pluginId, incidentPath, {
title,
severity,
url,
});
+ const hasAccess = settings ? canAccessPluginPage(settings, createBridgeURL(pluginId, incidentPath)) : false;
return (
<>
@@ -43,7 +45,21 @@ export const DeclareIncidentMenuItem = ({ title = '', severity = '', url = '' }:
/>
</Tooltip>
)}
- {settings && (
+ {settings && !hasAccess && (
+ <Tooltip
+ content={t(
+ 'alerting.declare-incident-menu-item.content-you-do-not-have-permission-to-access-incident',
+ 'You do not have permission to access Incident'
+ )}
+ >
+ <Menu.Item
+ label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')}
+ icon="fire"
+ disabled
+ />
+ </Tooltip>
+ )}
+ {settings && hasAccess && (
<Menu.Item
label={t('alerting.declare-incident-menu-item.label-declare-incident', 'Declare incident')}
url={bridgeURL}
diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts
index 885c85c063108..716ad36acd939 100644
--- a/public/app/features/alerting/unified/hooks/useAbilities.ts
+++ b/public/app/features/alerting/unified/hooks/useAbilities.ts
@@ -197,6 +197,26 @@ export const useAlertingAbility = (action: AlertingAction): Ability => {
return allAbilities[action];
};
+/**
+ * UI-only permission helper for places that need contact point visibility checks
+ * without requiring AlertmanagerContext.
+ */
+export function useCanViewContactPoints(): boolean {
+ return useMemo(
+ () =>
+ ctx.hasPermission(AccessControlAction.AlertingNotificationsRead) ||
+ ctx.hasPermission(AccessControlAction.AlertingReceiversRead),
+ []
+ );
+}
+
+/**
+ * UI-only permission helper for actions that create silences.
+ */
+export function useCanCreateSilences(): boolean {
+ return useMemo(() => ctx.hasPermission(AccessControlAction.AlertingInstanceCreate), []);
+}
+
/**
* This one will check for enrichment abilities
*/
diff --git a/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx b/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx
index ef180c8e26339..c4c6c0a38607d 100644
--- a/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx
+++ b/public/app/features/alerting/unified/hooks/usePluginBridge.test.tsx
@@ -1,10 +1,13 @@
import { renderHook, waitFor } from '@testing-library/react';
+import { OrgRole, PluginIncludeType } from '@grafana/data';
+import { contextSrv } from 'app/core/services/context_srv';
+
import { useGetPluginSettingsQuery } from '../api/pluginsApi';
import { pluginMeta } from '../testSetup/plugins';
import { SupportedPlugin } from '../types/pluginBridges';
-import { useIrmPlugin } from './usePluginBridge';
+import { canAccessPluginPage, useIrmPlugin } from './usePluginBridge';
jest.mock('../api/pluginsApi');
@@ -238,3 +241,69 @@ describe('useIrmPlugin', () => {
expect(result.current.settings).toEqual(pluginMeta[SupportedPlugin.Irm]);
});
});
+
+describe('canAccessPluginPage', () => {
+ const previousRole = contextSrv.user.orgRole;
+ const previousPermissions = contextSrv.user.permissions;
+ const previousIsEditor = contextSrv.isEditor;
+ const previousIsGrafanaAdmin = contextSrv.isGrafanaAdmin;
+
+ afterEach(() => {
+ contextSrv.user.orgRole = previousRole;
+ contextSrv.user.permissions = previousPermissions;
+ contextSrv.isEditor = previousIsEditor;
+ contextSrv.isGrafanaAdmin = previousIsGrafanaAdmin;
+ });
+
+ it('returns false when include requires action and user lacks permission', () => {
+ contextSrv.user.permissions = {};
+ const settings = {
+ ...pluginMeta[SupportedPlugin.Incident],
+ includes: [
+ {
+ type: PluginIncludeType.page,
+ name: 'Declare incident',
+ path: '/a/grafana-incident-app/incidents/declare',
+ action: 'grafana-incident-app.incidents:write',
+ },
+ ],
+ };
+
+ expect(canAccessPluginPage(settings, '/a/grafana-incident-app/incidents/declare')).toBe(false);
+ });
+
+ it('returns true when include requires action and user has permission', () => {
+ contextSrv.user.permissions = { 'grafana-incident-app.incidents:write': true };
+ const settings = {
+ ...pluginMeta[SupportedPlugin.Incident],
+ includes: [
+ {
+ type: PluginIncludeType.page,
+ name: 'Declare incident',
+ path: '/a/grafana-incident-app/incidents/declare',
+ action: 'grafana-incident-app.incidents:write',
+ },
+ ],
+ };
+
+ expect(canAccessPluginPage(settings, '/a/grafana-incident-app/incidents/declare')).toBe(true);
+ });
+
+ it('returns false when include role is editor and user is viewer', () => {
+ contextSrv.user.orgRole = OrgRole.Viewer;
+ contextSrv.isEditor = false;
+ const settings = {
+ ...pluginMeta[SupportedPlugin.Incident],
+ includes: [
+ {
+ type: PluginIncludeType.page,
+ name: 'Declare incident',
+ path: '/a/grafana-incident-app/incidents/declare',
+ role: OrgRole.Editor,
+ },
+ ],
+ };
+
+ expect(canAccessPluginPage(settings, '/a/grafana-incident-app/incidents/declare')).toBe(false);
+ });
+});
diff --git a/public/app/features/alerting/unified/hooks/usePluginBridge.ts b/public/app/features/alerting/unified/hooks/usePluginBridge.ts
index 8e96c5743784e..1f5f9afed47b4 100644
--- a/public/app/features/alerting/unified/hooks/usePluginBridge.ts
+++ b/public/app/features/alerting/unified/hooks/usePluginBridge.ts
@@ -1,5 +1,6 @@
-import { type PluginMeta } from '@grafana/data';
+import { OrgRole, type PluginMeta } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
+import { contextSrv } from 'app/core/services/context_srv';
import { useGetPluginSettingsQuery } from '../api/pluginsApi';
import { type PluginID } from '../components/PluginBridge';
@@ -45,6 +46,34 @@ export interface PluginBridgeResult {
error?: Error;
settings?: PluginMeta<{}>;
}
+
+/**
+ * Checks access to a specific plugin page path using the same include role/action
+ * semantics as the core app plugin route guard.
+ */
+export function canAccessPluginPage(settings: PluginMeta<{}>, pluginPagePath: string): boolean {
+ const requestedPath = pluginPagePath.split('?')[0];
+ const pluginInclude = settings.includes?.find((include) => include.path === requestedPath);
+
+ if (!pluginInclude) {
+ return true;
+ }
+
+ if (pluginInclude.action) {
+ return contextSrv.hasPermission(pluginInclude.action);
+ }
+
+ if (contextSrv.isGrafanaAdmin || contextSrv.user.orgRole === OrgRole.Admin) {
+ return true;
+ }
+
+ const includeRole = pluginInclude.role ?? '';
+ if (!includeRole || (contextSrv.isEditor && includeRole === OrgRole.Viewer)) {
+ return true;
+ }
+
+ return contextSrv.hasRole(includeRole);
+}
/**
* Hook that checks for IRM plugin first, falls back to specified plugin.
* IRM replaced both OnCall and Incident - this provides backward compatibility.
diff --git a/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx b/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx
index f392bd4352a8a..863616e96ae1e 100644
--- a/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx
+++ b/public/app/features/alerting/unified/notifications/NotificationDetailActions.tsx
@@ -2,11 +2,12 @@ import { type CreateNotificationqueryNotificationEntry } from '@grafana/api-clie
import { useAssistant } from '@grafana/assistant';
import { AppEvents } from '@grafana/data';
import { t } from '@grafana/i18n';
-import { Dropdown, Menu } from '@grafana/ui';
+import { Dropdown, Menu, Tooltip } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import MoreButton from '../components/MoreButton';
import { DeclareIncidentMenuItem } from '../components/bridges/DeclareIncidentButton';
+import { useCanCreateSilences, useCanViewContactPoints } from '../hooks/useAbilities';
import { isLocalDevEnv, isOpenSourceEdition, makeLabelBasedSilenceLink } from '../utils/misc';
import { createRelativeUrl } from '../utils/url';
@@ -18,6 +19,8 @@ interface NotificationActionsMenuProps {
export function NotificationActionsMenu({ notification }: NotificationActionsMenuProps) {
const { isAvailable: isAssistantAvailable, openAssistant } = useAssistant();
+ const canViewContactPoint = useCanViewContactPoints();
+ const canSilence = useCanCreateSilences();
const shouldShowDeclareIncident = !isOpenSourceEdition() || isLocalDevEnv();
@@ -27,11 +30,26 @@ export function NotificationActionsMenu({ notification }: NotificationActionsMen
const menuItems = (
<>
- <Menu.Item
- label={t('alerting.notification-detail.menu-view-contact-point', 'View contact point')}
- icon="at"
- url={createRelativeUrl(`/alerting/notifications?search=${encodeURIComponent(notification.receiver)}`)}
- />
+ {canViewContactPoint ? (
+ <Menu.Item
+ label={t('alerting.notification-detail.menu-view-contact-point', 'View contact point')}
+ icon="at"
+ url={createRelativeUrl(`/alerting/notifications?search=${encodeURIComponent(notification.receiver)}`)}
+ />
+ ) : (
+ <Tooltip
+ content={t(
+ 'alerting.notification-detail.menu-view-contact-point-no-permission',
+ 'You do not have permission to view contact points'
+ )}
+ >
+ <Menu.Item
+ label={t('alerting.notification-detail.menu-view-contact-point', 'View contact point')}
+ icon="at"
+ disabled
+ />
+ </Tooltip>
+ )}
{ruleUIDs.length === 1 && (
<Menu.Item
label={t('alerting.notification-detail.menu-view-rule', 'View alert rule')}
@@ -51,13 +69,27 @@ export function NotificationActionsMenu({ notification }: NotificationActionsMen
/>
))}
<Menu.Divider />
-
... [truncated]