Privilege Escalation / Access Control
Description
The commit refactors authentication/authorization checks for Grafana alerting notification policies from scattered, separate permission lists (READ/READ_EXTERNAL and MODIFY) to centralized ability hooks. It introduces new ability hooks (useNotificationPolicyAbility, useTimeIntervalAbility, useAlertGroupAbility, etc.) and unifies route gating to rely on explicit abilities rather than legacy permission lists. This reduces the risk of authorization bypass due to inconsistent RBAC checks across routes and UI components. It is a real security fix addressing access control consistency for notification policies in the Alerting feature.
Commit Details
Author: Gilles De Mey
Date: 2026-06-03 17:35 UTC
Message:
Alerting: Migrate notification policies to use centralized ability hooks (3/N) (#125259)
Triage Assessment
Vulnerability Type: Privilege Escalation / Access Control
Confidence: MEDIUM
Reasoning:
The commit migrates notification policy access checks to centralized ability hooks and adjusts route and UI gating to rely on explicit abilities rather than older permission lists. This refactors RBAC checks and reduces the risk of improper access control by consolidating authorization logic, which constitutes a security fix to authorization bypass risks.
Verification Assessment
Vulnerability Type: Privilege Escalation / Access Control
Confidence: MEDIUM
Affected Versions: <=12.3.x (pre-fix); 12.4.0+ includes the fix
Code Diff
diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx
index 7e33fb926320f..d39af316227f1 100644
--- a/public/app/features/alerting/routes.tsx
+++ b/public/app/features/alerting/routes.tsx
@@ -10,12 +10,9 @@ import {
PERMISSIONS_TIME_INTERVALS_MODIFY,
PERMISSIONS_TIME_INTERVALS_READ,
} from './unified/components/mute-timings/permissions';
-import {
- PERMISSIONS_NOTIFICATION_POLICIES_MODIFY,
- PERMISSIONS_NOTIFICATION_POLICIES_READ,
-} from './unified/components/notification-policies/permissions';
import { PERMISSIONS_TEMPLATES } from './unified/components/templates/permissions';
import { shouldAllowRecoveringDeletedRules } from './unified/featureToggles';
+import { PERMISSIONS_NOTIFICATION_POLICIES } from './unified/hooks/abilities/alertmanager/useNotificationPolicyAbility';
import { evaluateAccess } from './unified/utils/access-control';
export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
@@ -44,8 +41,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
- ...PERMISSIONS_NOTIFICATION_POLICIES_READ,
- ...PERMISSIONS_NOTIFICATION_POLICIES_MODIFY,
+ ...PERMISSIONS_NOTIFICATION_POLICIES,
...PERMISSIONS_TIME_INTERVALS_READ,
...PERMISSIONS_TIME_INTERVALS_MODIFY,
]),
@@ -102,11 +98,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
},
{
path: '/alerting/routes/policy/:name/edit',
- roles: evaluateAccess([
- AccessControlAction.AlertingNotificationsRead,
- ...PERMISSIONS_NOTIFICATION_POLICIES_READ,
- ...PERMISSIONS_NOTIFICATION_POLICIES_MODIFY,
- ]),
+ roles: evaluateAccess([AccessControlAction.AlertingNotificationsRead, ...PERMISSIONS_NOTIFICATION_POLICIES]),
component: config.featureToggles.alertingMultiplePolicies
? importAlertingComponent(
() =>
diff --git a/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx b/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx
index e92459612a06f..b5da2d2e37aab 100644
--- a/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx
+++ b/public/app/features/alerting/unified/NotificationPoliciesPage.test.tsx
@@ -7,7 +7,7 @@ import { byLabelText, byRole, byTestId } from 'testing-library-selector';
import { config } from '@grafana/runtime';
import { mockComboboxRect } from '@grafana/test-utils';
import { AppNotificationList } from 'app/core/components/AppNotifications/AppNotificationList';
-import { PERMISSIONS_NOTIFICATION_POLICIES } from 'app/features/alerting/unified/components/notification-policies/permissions';
+import { PERMISSIONS_NOTIFICATION_POLICIES } from 'app/features/alerting/unified/hooks/abilities/alertmanager/useNotificationPolicyAbility';
import { setupMswServer } from 'app/features/alerting/unified/mockApi';
import {
getErrorResponse,
@@ -50,6 +50,7 @@ import {
deleteRoutingTree,
getRoutingTree,
resetRoutingTreeMap,
+ setAllRoutingTreePermissions,
setRoutingTree,
} from './mocks/server/entities/k8s/routingtrees';
import { ALERTMANAGER_NAME_QUERY_KEY } from './utils/constants';
@@ -181,7 +182,6 @@ describe.each([
{ testName: 'PolicyPage', renderPage: renderPolicyPage(OtherPolicyName), routeName: OtherPolicyName },
])('$testName - Policy: $routeName', ({ testName, renderPage, routeName }) => {
beforeAll(() => {
- // combobox hack :/
mockComboboxRect();
});
@@ -292,6 +292,9 @@ describe.each([
routes: [],
})
);
+ // createKubernetesRoutingTreeSpec builds a minimal tree without k8s access annotations.
+ // Set them explicitly so the entity-level canEditEntity / canAdminEntity checks pass.
+ setAllRoutingTreePermissions({ canWrite: true, canDelete: true, canAdmin: true });
const { user } = renderPage();
// Sanity check to make sure we actually have an undefined root route.
@@ -325,6 +328,11 @@ describe.each([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
]);
+ // Entity annotations must reflect RBAC: a user without write permission gets canWrite: false
+ // from the backend. Without this the mock is in an impossible state (entity canWrite: true
+ // for a user who has no write RBAC), which previously only happened to hide the button
+ // because of a now-removed redundant global-RBAC double-gate.
+ setAllRoutingTreePermissions({ canWrite: false, canDelete: false, canAdmin: false });
const { user } = renderPage();
diff --git a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx
index b68045d0f38c0..2ae99559c1555 100644
--- a/public/app/features/alerting/unified/NotificationPoliciesPage.tsx
+++ b/public/app/features/alerting/unified/NotificationPoliciesPage.tsx
@@ -19,7 +19,12 @@ import {
useCreatePolicyAction,
useListNotificationPolicyRoutes,
} from 'app/features/alerting/unified/components/notification-policies/useNotificationPolicyRoute';
-import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
+import { isGranted } from 'app/features/alerting/unified/hooks/abilities/abilityUtils';
+import {
+ AlertGroupAction,
+ NotificationPolicyAction,
+ TimeIntervalAction,
+} from 'app/features/alerting/unified/hooks/abilities/types';
import { useRouteGroupsMatcher } from 'app/features/alerting/unified/useRouteGroupsMatcher';
import { type ObjectMatcher } from 'app/plugins/datasource/alertmanager/types';
@@ -35,6 +40,9 @@ import {
trackNotificationPoliciesFilterPolicyTree,
trackNotificationPoliciesToggledAll,
} from './components/notification-policies/notificationPolicyAnalytics';
+import { useAlertGroupAbility } from './hooks/abilities/alertmanager/useAlertGroupAbility';
+import { useNotificationPolicyAbility } from './hooks/abilities/alertmanager/useNotificationPolicyAbility';
+import { useTimeIntervalAbility } from './hooks/abilities/alertmanager/useTimeIntervalAbility';
import { useNotificationPoliciesNav } from './navigation/useNotificationConfigNav';
import { useAlertmanager } from './state/AlertmanagerContext';
import { ROOT_ROUTE_NAME } from './utils/k8s/constants';
@@ -55,15 +63,19 @@ const NotificationPoliciesTabs = () => {
// Alertmanager logic and data hooks
const { selectedAlertmanager = '' } = useAlertmanager();
- const [policiesSupported, canSeePoliciesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationPolicyTree);
- const [timingsSupported, canSeeTimingsTab] = useAlertmanagerAbility(AlertmanagerAction.ViewTimeInterval);
+ const policiesAbility = useNotificationPolicyAbility({ action: NotificationPolicyAction.ViewTree });
+ const timingsAbility = useTimeIntervalAbility({ action: TimeIntervalAction.View });
+ const canAccessPolicies = isGranted(policiesAbility);
+ const canAccessTimings = isGranted(timingsAbility);
+
const availableTabs = [
- canSeePoliciesTab && ActiveTab.NotificationPolicies,
- canSeeTimingsTab && ActiveTab.TimeIntervals,
+ canAccessPolicies && ActiveTab.NotificationPolicies,
+ canAccessTimings && ActiveTab.TimeIntervals,
].filter((tab) => !!tab);
+
const { data: muteTimings = [] } = useMuteTimings({
alertmanager: selectedAlertmanager,
- skip: !canSeeTimingsTab,
+ skip: !canAccessTimings,
});
// Tab state management
@@ -93,7 +105,7 @@ const NotificationPoliciesTabs = () => {
<GrafanaAlertmanagerWarning currentAlertmanager={selectedAlertmanager} />
<InhibitionRulesAlert alertmanagerSourceName={selectedAlertmanager} />
<TabsBar>
- {policiesSupported && canSeePoliciesTab && (
+ {canAccessPolicies && (
<Tab
label={t('alerting.notification-policies-tabs.label-notification-policies', 'Notification Policies')}
active={policyTreeTabActive}
@@ -103,7 +115,7 @@ const NotificationPoliciesTabs = () => {
}}
/>
)}
- {timingsSupported && canSeeTimingsTab && (
+ {canAccessTimings && (
<Tab
label={t('alerting.notification-policies-tabs.label-time-intervals', 'Time intervals')}
active={muteTimingsTabActive}
@@ -133,7 +145,7 @@ const NotificationPoliciesTabs = () => {
*/
function PolicyTreeTab() {
const { selectedAlertmanager = '', isGrafanaAlertmanager } = useAlertmanager();
- const [, canSeeAlertGroups] = useAlertmanagerAbility(AlertmanagerAction.ViewAlertGroups);
+ const { granted: canSeeAlertGroups } = useAlertGroupAbility(AlertGroupAction.View);
// Single worker + alert groups query shared by all PoliciesTree instances
const { getRouteGroupsMap } = useRouteGroupsMatcher();
diff --git a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx
index afdda11247124..6ca2d6a46c819 100644
--- a/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx
+++ b/public/app/features/alerting/unified/components/notification-policies/EditNotificationPolicyForm.tsx
@@ -21,9 +21,10 @@ import {
import MuteTimingsSelector from 'app/features/alerting/unified/components/alertmanager-entities/MuteTimingsSelector';
import { ExternalAlertmanagerContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector';
import { handleContactPointSelect } from 'app/features/alerting/unified/components/notification-policies/utils';
-import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
+import { TimeIntervalAction } from 'app/features/alerting/unified/hooks/abilities/types';
import { MatcherOperator, type RouteWithID } from 'app/plugins/datasource/alertmanager/types';
+import { useTimeIntervalAbility } from '../../hooks/abilities/alertmanager/useTimeIntervalAbility';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { type FormAmRoute } from '../../types/amroutes';
import { matcherFieldOptions } from '../../utils/alertmanager';
@@ -53,7 +54,7 @@ export const AmRoutesExpandedForm = ({ actionButtons, route, onSubmit, defaults
const styles = useStyles2(getStyles);
const formStyles = useStyles2(getFormStyles);
const { selectedAlertmanager, isGrafanaAlertmanager } = useAlertmanager();
- const [, canSeeMuteTimings] = useAlertmanagerAbility(AlertmanagerAction.ViewTimeInterval);
+ const { granted: canSeeMuteTimings } = useTimeIntervalAbility({ action: TimeIntervalAction.View });
const [groupByOptions, setGroupByOptions] = useState(stringsToSelectableValues(route?.group_by));
const emptyMatcher = [{ name: '', operator: MatcherOperator.equal, value: '' }];
diff --git a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx
index 3bd5641f95ea2..f3c7bb6760efa 100644
--- a/public/app/features/alerting/unified/components/notification-policies/Filters.tsx
+++ b/public/app/features/alerting/unified/components/notification-policies/Filters.tsx
@@ -10,9 +10,10 @@ import { type RoutingTree } from '@grafana/api-clients/rtkq/notifications.alerti
import { Trans, t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Button, Field, Icon, Input, Label, Stack, Tooltip } from '@grafana/ui';
-import { AlertmanagerAction, useAlertmanagerAbility } from 'app/features/alerting/unified/hooks/useAbilities';
+import { ContactPointAction } from 'app/features/alerting/unified/hooks/abilities/types';
import { type ObjectMatcher, type RouteWithID } from 'app/plugins/datasource/alertmanager/types';
+import { useContactPointAbility } from '../../hooks/abilities/alertmanager/useContactPointAbility';
import { useURLSearchParams } from '../../hooks/useURLSearchParams';
import { useAlertmanager } from '../../state/AlertmanagerContext';
import { matcherToObjectMatcher } from '../../utils/alertmanager';
@@ -31,7 +32,7 @@ interface NotificationPoliciesFilterProps {
}
const NotificationPoliciesFilter = ({ onChangeReceiver, onChangeMatchers }: NotificationPoliciesFilterProps) => {
- const [contactPointsSupported, canSeeContactPoints] = useAlertmanagerAbility(AlertmanagerAction.ViewContactPoint);
+ const { granted: canSeeContactPoints } = useContactPointAbility({ action: ContactPointAction.View });
const { isGrafanaAlertmanager } = useAlertmanager();
const [searchParams, setSearchParams] = useURLSearchParams();
const { queryString, contactPoint } = getNotificationPoliciesFilters(searchParams);
@@ -108,7 +109,7 @@ const NotificationPoliciesFilter = ({ onChangeReceiver, onChangeMatchers }: Noti
value={queryString ?? ''}
/>
</Field>
- {contactPointsSupported && canSeeContactPoints && (
+ {canSeeContactPoints && (
<Field
label={t('alerting.notification-policies-filter.label-search-by-contact-point', 'Contact point')}
noMargin
diff --git a/public/app/features/alerting/unified/components/notification-policies/PoliciesList.test.tsx b/public/app/features/alerting/unified/components/notification-policies/PoliciesList.test.tsx
index 4da6ffd37d747..d73303ea7b4f0 100644
--- a/public/app/features/alerting/unified/components/notification-policies/PoliciesList.test.tsx
+++ b/public/app/features/alerting/unified/components/notification-policies/PoliciesList.test.tsx
@@ -10,9 +10,22 @@ import { setupDataSources } from 'app/features/alerting/unified/testSetup/dataso
import { AccessControlAction } from '../../../../../types/accessControl';
import NotificationPolicies from '../../NotificationPoliciesPage';
-import { AlertmanagerAction, useAlertmanagerAbilities, useAlertmanagerAbility } from '../../hooks/useAbilities';
+import { useContactPointAbility } from '../../hooks/abilities/alertmanager/useContactPointAbility';
+import { useNotificationPolicyAbility } from '../../hooks/abilities/alertmanager/useNotificationPolicyAbility';
+import {
+ type Ability,
+ Granted,
+ InsufficientPermissions,
+ NotSupported,
+ NotificationPolicyAction,
+} from '../../hooks/abilities/types';
import { grantUserPermissions, mockDataSource } from '../../mocks';
-import { getRoutingTree, getRoutingTreeList, resetRoutingTreeMap } from '../../mocks/server/entities/k8s/routingtrees';
+import {
+ getRoutingTree,
+ getRoutingTreeList,
+ resetRoutingTreeMap,
+ setAllRoutingTreePermissions,
+} from '../../mocks/server/entities/k8s/routingtrees';
import { KnownProvenance } from '../../types/knownProvenanc
... [truncated]