Authorization bypass / Privilege escalation (UI access control) in alerting UI

MEDIUM
grafana/grafana
Commit: d88f71a9fd04
Affected: 12.x releases prior to this commit (pre-d88f71a9fd0415a8c589d6e1cde4d0aa7676f5d9)
2026-06-10 12:02 UTC

Description

This commit hardens alerting UI authorization by migrating from ad-hoc permission checks and an Authorize wrapper to centralized ability hooks. It adjusts the visibility and availability of actions in the alerting UI (Silence, Manage silences, See alert rule, See source) to be governed by centralized abilities (RuleAction.View, SilenceAction.Create/Update, ContactPoint.View, etc.). This reduces the risk of UI-based privilege bypass where UI controls could be shown to users who shouldn't have them due to inconsistent or scattered permission checks. It appears to be a security-relevant refactor toward consistent auth checks, rather than a mere dependency bump; however backend permissions remain the authority for actual actions, so server-side checks are still essential. Overall this is a legitimate security hardening/fix rather than a pure cleanup.

Commit Details

Author: Gilles De Mey

Date: 2026-06-10 11:50 UTC

Message:

Alerting: Migrate alert groups to use centralized ability hooks (6/N) (#126063)

Triage Assessment

Vulnerability Type: Authorization bypass / Privilege escalation

Confidence: MEDIUM

Reasoning:

The commit replaces ad-hoc permission checks with centralized ability hooks to control UI actions (silence, manage silences, see alert rule/source, and contact point visibility). This strengthens authorization checks and reduces risk of UI-based privilege bypass, indicating a security relevance (authorization/authZ hardening).

Verification Assessment

Vulnerability Type: Authorization bypass / Privilege escalation (UI access control) in alerting UI

Confidence: MEDIUM

Affected Versions: 12.x releases prior to this commit (pre-d88f71a9fd0415a8c589d6e1cde4d0aa7676f5d9)

Code Diff

diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.test.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.test.tsx new file mode 100644 index 0000000000000..680e866ccd794 --- /dev/null +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.test.tsx @@ -0,0 +1,132 @@ +import { render, screen } from 'test/test-utils'; + +import { MIMIR_DATASOURCE_UID } from 'app/features/alerting/unified/mocks/server/constants'; +import { AlertState } from 'app/plugins/datasource/alertmanager/types'; +import { AccessControlAction } from 'app/types/accessControl'; + +import { + EXTERNAL_AM_VISIBILITY_PERMISSION, + GRAFANA_AM_VISIBILITY_PERMISSION, + setupGrafanaAlertmanager, + setupMimirAlertmanager, +} from '../../hooks/abilities/alertmanager/abilityTestUtils'; +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions, mockAlertmanagerAlert } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; + +import { AlertDetails } from './AlertDetails'; + +setupMswServer(); + +function renderAlertDetails(alert: ReturnType<typeof mockAlertmanagerAlert>, alertManagerSourceName: string) { + return render( + <AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}> + <AlertDetails alert={alert} alertManagerSourceName={alertManagerSourceName} /> + </AlertmanagerProvider> + ); +} + +describe('AlertDetails', () => { + describe('Silence button — Active alert', () => { + it('shows Silence button when user can create silences', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingInstanceCreate]); + const alert = mockAlertmanagerAlert({ status: { state: AlertState.Active, silencedBy: [], inhibitedBy: [] } }); + + renderAlertDetails(alert, amSource); + + expect(screen.getByRole('link', { name: /silence/i })).toBeInTheDocument(); + }); + + it('hides Silence button when user has no silence create permission', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION]); + const alert = mockAlertmanagerAlert({ status: { state: AlertState.Active, silencedBy: [], inhibitedBy: [] } }); + + renderAlertDetails(alert, amSource); + + expect(screen.queryByRole('link', { name: /silence/i })).not.toBeInTheDocument(); + }); + }); + + describe('Manage silences button — Suppressed alert', () => { + it('shows Manage silences button when user can create silences', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingInstanceCreate]); + const alert = mockAlertmanagerAlert({ + status: { state: AlertState.Suppressed, silencedBy: ['abc123'], inhibitedBy: [] }, + }); + + renderAlertDetails(alert, amSource); + + expect(screen.getByRole('link', { name: /manage silences/i })).toBeInTheDocument(); + }); + + it('shows Manage silences button when user can update silences but not create', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingInstanceUpdate]); + const alert = mockAlertmanagerAlert({ + status: { state: AlertState.Suppressed, silencedBy: ['abc123'], inhibitedBy: [] }, + }); + + renderAlertDetails(alert, amSource); + + expect(screen.getByRole('link', { name: /manage silences/i })).toBeInTheDocument(); + }); + + it('hides Manage silences button when user has neither create nor update silence permission', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION]); + const alert = mockAlertmanagerAlert({ + status: { state: AlertState.Suppressed, silencedBy: ['abc123'], inhibitedBy: [] }, + }); + + renderAlertDetails(alert, amSource); + + expect(screen.queryByRole('link', { name: /manage silences/i })).not.toBeInTheDocument(); + }); + }); + + describe('See alert rule button — Grafana source', () => { + it('shows See alert rule button when user has rule read permission', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingRuleRead]); + const alert = mockAlertmanagerAlert({ + status: { state: AlertState.Active, silencedBy: [], inhibitedBy: [] }, + generatorURL: 'https://play.grafana.com/alerting/grafana/rule/123/view', + }); + + renderAlertDetails(alert, amSource); + + expect(screen.getByRole('link', { name: /see alert rule/i })).toBeInTheDocument(); + }); + + it('hides See alert rule button when user lacks rule read permission', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION]); + const alert = mockAlertmanagerAlert({ + status: { state: AlertState.Active, silencedBy: [], inhibitedBy: [] }, + generatorURL: 'https://play.grafana.com/alerting/grafana/rule/123/view', + }); + + renderAlertDetails(alert, amSource); + + expect(screen.queryByRole('link', { name: /see alert rule/i })).not.toBeInTheDocument(); + }); + }); + + describe('See source button — external source', () => { + it('always shows See source button regardless of rule read permission', () => { + setupMimirAlertmanager(MIMIR_DATASOURCE_UID); + grantUserPermissions([EXTERNAL_AM_VISIBILITY_PERMISSION]); + const alert = mockAlertmanagerAlert({ + status: { state: AlertState.Active, silencedBy: [], inhibitedBy: [] }, + generatorURL: 'https://external-alertmanager.example.com', + }); + + renderAlertDetails(alert, MIMIR_DATASOURCE_UID); + + expect(screen.getByRole('link', { name: /see source/i })).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx index 42c996a43beb4..0982b05e23eae 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertDetails.tsx @@ -3,15 +3,15 @@ import { css } from '@emotion/css'; import { type GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { LinkButton, useStyles2 } from '@grafana/ui'; -import { contextSrv } from 'app/core/services/context_srv'; import { AlertState, type AlertmanagerAlert } from 'app/plugins/datasource/alertmanager/types'; -import { AccessControlAction } from 'app/types/accessControl'; -import { AlertmanagerAction } from '../../hooks/useAbilities'; +import { isGranted } from '../../hooks/abilities/abilityUtils'; +import { useSilenceAbility } from '../../hooks/abilities/alertmanager/useSilenceAbility'; +import { useGlobalRuleAbility } from '../../hooks/abilities/rules/ruleAbilities'; +import { RuleAction, SilenceAction } from '../../hooks/abilities/types'; import { isGrafanaRulesSource } from '../../utils/datasource'; import { makeAMLink, makeLabelBasedSilenceLink } from '../../utils/misc'; import { AnnotationDetailsField } from '../AnnotationDetailsField'; -import { Authorize } from '../Authorize'; interface AmNotificationsAlertDetailsProps { alertManagerSourceName: string; @@ -24,39 +24,36 @@ export const AlertDetails = ({ alert, alertManagerSourceName }: AmNotificationsA // For Grafana Managed alerts the Generator URL redirects to the alert rule edit page, so update permission is required // For external alert manager the Generator URL redirects to an external service which we don't control const isGrafanaSource = isGrafanaRulesSource(alertManagerSourceName); - const isSeeSourceButtonEnabled = isGrafanaSource - ? contextSrv.hasPermission(AccessControlAction.AlertingRuleRead) - : true; + const viewRuleAbility = useGlobalRuleAbility(RuleAction.View); + const isSeeSourceButtonEnabled = isGrafanaSource ? isGranted(viewRuleAbility) : true; + const canCreateSilence = isGranted(useSilenceAbility({ action: SilenceAction.Create })); + const canUpdateSilence = isGranted(useSilenceAbility({ action: SilenceAction.Update })); return ( <> <div className={styles.actionsRow}> - {alert.status.state === AlertState.Suppressed && ( - <Authorize actions={[AlertmanagerAction.CreateSilence, AlertmanagerAction.UpdateSilence]}> - <LinkButton - href={`${makeAMLink( - '/alerting/silences', - alertManagerSourceName - )}&silenceIds=${alert.status.silencedBy.join(',')}`} - className={styles.button} - icon={'bell'} - size={'sm'} - > - <Trans i18nKey="alerting.alert-details.manage-silences">Manage silences</Trans> - </LinkButton> - </Authorize> + {alert.status.state === AlertState.Suppressed && (canCreateSilence || canUpdateSilence) && ( + <LinkButton + href={`${makeAMLink( + '/alerting/silences', + alertManagerSourceName + )}&silenceIds=${alert.status.silencedBy.join(',')}`} + className={styles.button} + icon={'bell'} + size={'sm'} + > + <Trans i18nKey="alerting.alert-details.manage-silences">Manage silences</Trans> + </LinkButton> )} - {alert.status.state === AlertState.Active && ( - <Authorize actions={[AlertmanagerAction.CreateSilence]}> - <LinkButton - href={makeLabelBasedSilenceLink(alertManagerSourceName, alert.labels)} - className={styles.button} - icon={'bell-slash'} - size={'sm'} - > - <Trans i18nKey="alerting.alert-details.silence">Silence</Trans> - </LinkButton> - </Authorize> + {alert.status.state === AlertState.Active && canCreateSilence && ( + <LinkButton + href={makeLabelBasedSilenceLink(alertManagerSourceName, alert.labels)} + className={styles.button} + icon={'bell-slash'} + size={'sm'} + > + <Trans i18nKey="alerting.alert-details.silence">Silence</Trans> + </LinkButton> )} {isSeeSourceButtonEnabled && alert.generatorURL && ( <LinkButton className={styles.button} href={alert.generatorURL} icon={'chart-line'} size={'sm'}> diff --git a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.test.tsx b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.test.tsx new file mode 100644 index 0000000000000..b49833e9ba0bb --- /dev/null +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.test.tsx @@ -0,0 +1,48 @@ +import { render, screen } from 'test/test-utils'; + +import { + GRAFANA_AM_VISIBILITY_PERMISSION, + setupGrafanaAlertmanager, +} from '../../hooks/abilities/alertmanager/abilityTestUtils'; +import { setupMswServer } from '../../mockApi'; +import { grantUserPermissions, mockAlertGroup } from '../../mocks'; +import { AlertmanagerProvider } from '../../state/AlertmanagerContext'; + +import { AlertGroup } from './AlertGroup'; + +setupMswServer(); + +function renderAlertGroup(alertManagerSourceName: string) { + const group = mockAlertGroup(); + return render( + <AlertmanagerProvider accessType="notification" alertmanagerSourceName={alertManagerSourceName}> + <AlertGroup group={group} alertManagerSourceName={alertManagerSourceName} /> + </AlertmanagerProvider> + ); +} + +describe('AlertGroup', () => { + describe('contact point visibility', () => { + it('renders contact point name as a clickable link when user can view contact points', () => { + const amSource = setupGrafanaAlertmanager(); + // GRAFANA_AM_VISIBILITY_PERMISSION === AlertingNotificationsRead, which also gates contact point view + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION]); + + renderAlertGroup(amSource); + + expect(screen.getByRole('link', { name: /pagerduty/i })).toBeInTheDocument(); + }); + + it('renders contact point name as plain text with tooltip when user cannot view contact points', () => { + const amSource = setupGrafanaAlertmanager(); + // Granting no permissions: the Grafana AM does not resolve in available alertmanagers + // so contact point view falls back to external permissions, which are not held — denied. + grantUserPermissions([]); + + renderAlertGroup(amSource); + + expect(screen.queryByRole('link', { name: /pagerduty/i })).not.toBeInTheDocument(); + expect(screen.getByText(/delivered to pagerduty/i)).toBeInTheDocument(); + }); + }); +}); 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 b83d403d87895..82ff5197d1cfe 100644 --- a/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx +++ b/public/app/features/alerting/unified/components/alert-groups/AlertGroup.tsx @@ -7,7 +7,9 @@ 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 { isGranted } from '../../hooks/abilities/abilityUtils'; +import { useContactPointAbility } from '../../hooks/abilities/alertmanager/useContactPointAbility'; +import { ContactPointAction } from '../../hooks/abilities/types'; import { createContactPointSearchLink } from '../../utils/misc'; import { CollapseToggle } from '../CollapseToggle'; import { MetaText } from '../MetaText'; @@ -23,7 +25,7 @@ interface Props { export const AlertGroup = ({ alertManagerSourceName, group }: Props) => { const [isCollapsed, setIsCollapsed] = useState<boolean>(true); const styles = useStyles2(getStyles); - const canViewContactPoint = useCanViewContactPoints(); + const canViewContactPoint = isGranted(useContactPointAbility({ action: ContactPointAction.View })); // When group is grouped, receiver.name is 'NONE' as it can contain multiple receivers const receiverInGroup = group.receiver.name !== 'NONE';
← Back to Alerts View on GitHub →