Authorization / Access Control

MEDIUM
grafana/grafana
Commit: 02323990f489
Affected: Grafana 12.4.0 (and earlier 12.x releases) where silences UI relied on ad-hoc permission checks
2026-06-09 14:59 UTC

Description

The commit migrates silences-related UI to centralized ability hooks and replaces ad-hoc permission checks that relied on context_srv with a dedicated ability system (useSilenceAbility). It gates actions (e.g., creating silences) behind granted checks and disables or hides UI elements accordingly. This is an authorization/access-control hardening intended to reduce information exposure and improper actions. However, the enforcement semantics depend on backend authorization as well; if server-side checks were not updated to mirror the centralized UI permissions, an attacker could bypass UI restrictions via API calls. The change primarily covers frontend access control and may not fully mitigate backend-only abuse without accompanying server-side checks.

Proof of Concept

PoC rationale: - Before this fix, authorization for creating silences could be inconsistently enforced across UI code paths, potentially leaking UI affordances or allowing actions via non-UI vectors if backend checks were insufficient. - After this fix, UI actions are gated by centralized ability hooks and non-granted actions are disabled or hidden to reduce leakage. The actual security impact depends on backend enforcement as well; if the backend still validates permissions strictly, the risk is mitigated. Proof-of-concept (actionable steps to verify in a test environment): 1) Setup: Grafana 12.4.0 with Alertmanager integration. Create two users: - User A: Admin/Has silences.Create permission via the new ability system. - User B: Read-only on alerting/silences (no create permission). 2) UI verification (preference for User B): - Log in as User B and navigate to Alerting -> Silences. - Observe that the NoSilencesCTA now renders a Create Silence action as disabled or hidden depending on the ability state; there should be no ability to click through to create a silence. 3) API-based verification (both users): - Try to create a silence via the backend API using User B's credentials (e.g., a POST to the Grafana backend endpoint responsible for creating silences, such as /api/alertmanager/silences or the equivalent backend API used by the UI). - Expected result: HTTP 403 Forbidden or 401 Unauthorized if backend properly enforces permissions. - Repeat with User A's credentials; expected result: HTTP 200 OK and a silence being created in Alertmanager. 4) Negative test (incorrect backend enforcement): - If the API call with User B unexpectedly succeeds (HTTP 200), this indicates backend authorization is not aligned with the frontend ability checks, confirming a real vulnerability. This would demonstrate a server-side authorization gap requiring remediation. Example curl (adjust endpoint to Grafana deployment): # Attempt by a non-privileged user to create a silence (expected 403/401) curl -sS -X POST "https://grafana.example.com/api/alertmanager/silences" \ -H "Authorization: Bearer <USER_B_TOKEN>" \ -H "Content-Type: application/json" \ -d '{ "startsAt": "2026-06-09T12:00:00Z", "endsAt": "2026-06-09T13:00:00Z", "comment": "POC: unauthorized silence creation", "matchers": [{"name": "alertname", "value": "Test", "isRegex": false}] }' If the request returns 403/401 for User B and 200 for User A, the test confirms the authorization enforcement pattern introduced by the fix. If User B can still create a silence (200), it indicates backend authorization gaps that would still need remediation.

Commit Details

Author: Gilles De Mey

Date: 2026-06-09 14:33 UTC

Message:

Alerting: Migrate silences to use centralized ability hooks (4/N) (#125971)

Triage Assessment

Vulnerability Type: Authorization / Access Control

Confidence: MEDIUM

Reasoning:

The commit refactors silences-related UI to use centralized ability hooks for access control, replacing previous ad-hoc permission checks. It gates actions (e.g., creating silences) behind granted checks and disables links when not allowed, reducing the risk of unauthorized actions and information exposure. This constitutes an authorization/access-control fix rather than a pure feature or performance change.

Verification Assessment

Vulnerability Type: Authorization / Access Control

Confidence: MEDIUM

Affected Versions: Grafana 12.4.0 (and earlier 12.x releases) where silences UI relied on ad-hoc permission checks

Code Diff

diff --git a/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx b/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx index 902b374a44002..16a29844e7293 100644 --- a/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx +++ b/public/app/features/alerting/unified/components/silences/NoSilencesCTA.tsx @@ -1,8 +1,9 @@ import { Trans, t } from '@grafana/i18n'; import { CallToActionCard, EmptyState, LinkButton } from '@grafana/ui'; -import { contextSrv } from 'app/core/services/context_srv'; -import { getInstancesPermissions } from '../../utils/access-control'; +import { isGranted, isNotSupported } from '../../hooks/abilities/abilityUtils'; +import { useSilenceAbility } from '../../hooks/abilities/alertmanager/useSilenceAbility'; +import { SilenceAction } from '../../hooks/abilities/types'; import { makeAMLink } from '../../utils/misc'; type Props = { @@ -10,14 +11,22 @@ type Props = { }; export const NoSilencesSplash = ({ alertManagerSourceName }: Props) => { - const permissions = getInstancesPermissions(alertManagerSourceName); + const createAbility = useSilenceAbility({ action: SilenceAction.Create }); - if (contextSrv.hasPermission(permissions.create)) { + // Show the button for every cause except NOT_SUPPORTED (wrong AM type). + // While the AM is still resolving (LOADING), the button is rendered disabled + // rather than hidden so the empty state doesn't flash a different layout. + if (!isNotSupported(createAbility)) { return ( <EmptyState variant="call-to-action" button={ - <LinkButton href={makeAMLink('alerting/silence/new', alertManagerSourceName)} icon="bell-slash" size="lg"> + <LinkButton + href={makeAMLink('alerting/silence/new', alertManagerSourceName)} + icon="bell-slash" + size="lg" + disabled={!isGranted(createAbility)} + > <Trans i18nKey="silences.empty-state.button-title">Create silence</Trans> </LinkButton> } diff --git a/public/app/features/alerting/unified/components/silences/SilenceViewContent.tsx b/public/app/features/alerting/unified/components/silences/SilenceViewContent.tsx index 39d9290f033d5..a350bc3a64dfb 100644 --- a/public/app/features/alerting/unified/components/silences/SilenceViewContent.tsx +++ b/public/app/features/alerting/unified/components/silences/SilenceViewContent.tsx @@ -21,6 +21,10 @@ export function SilenceViewContent({ silence, silencedAlerts }: SilenceViewConte const returnTo = createReturnTo(); const ruleUid = metadata?.rule_uid; + const alertRuleHref = ruleUid + ? `/alerting/grafana/${encodeURIComponent(ruleUid)}/view?${new URLSearchParams({ returnTo }).toString()}` + : ''; + return ( <Stack direction="column" gap={2}> {ruleUid && ( @@ -28,12 +32,8 @@ export function SilenceViewContent({ silence, silencedAlerts }: SilenceViewConte <Text variant="bodySmall" color="secondary"> <Trans i18nKey="alerting.silence-view.alert-rule">Alert rule</Trans> </Text> - {metadata.rule_title ? ( - <TextLink - href={`/alerting/grafana/${encodeURIComponent(ruleUid)}/view?${new URLSearchParams({ returnTo }).toString()}`} - > - {metadata.rule_title} - </TextLink> + {metadata?.rule_title ? ( + <TextLink href={alertRuleHref}>{metadata.rule_title}</TextLink> ) : ( <MissingAlertRuleWarning ruleUid={ruleUid} /> )} diff --git a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx index 60ea3e13207ea..1d7d6dd99251d 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesEditor.tsx @@ -33,7 +33,8 @@ import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid } from 'app/features/ale import { MatcherOperator, type SilenceCreatePayload } from 'app/plugins/datasource/alertmanager/types'; import { contextSrv } from '../../../../../core/services/context_srv'; -import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; +import { useSilenceAbility } from '../../hooks/abilities/alertmanager/useSilenceAbility'; +import { SilenceAction } from '../../hooks/abilities/types'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { type SilenceFormFields } from '../../types/silence-form'; import { matcherFieldToMatcher } from '../../utils/alertmanager'; @@ -131,7 +132,6 @@ type SilencesEditorProps = { onSilenceCreated?: (response: SilenceCreatedResponse) => void; onCancel?: () => void; ruleUid?: string; - showCancelButton?: boolean; }; /** @@ -144,15 +144,11 @@ export const SilencesEditor = ({ onSilenceCreated, onCancel, ruleUid, - showCancelButton = true, }: SilencesEditorProps) => { - const [previewAlertsSupported, previewAlertsAllowed] = useAlertmanagerAbility( - AlertmanagerAction.PreviewSilencedInstances - ); - const canPreview = previewAlertsSupported && previewAlertsAllowed; + const { granted: canPreview } = useSilenceAbility({ action: SilenceAction.Preview }); const [createSilence, { isLoading }] = alertSilencesApi.endpoints.createSilence.useMutation(); - const formAPI = useForm<SilenceFormFields>({ defaultValues: formValues }); + const formAPI = useForm({ defaultValues: formValues }); const styles = useStyles2(getStyles); const { register, handleSubmit, formState, watch, setValue, clearErrors } = formAPI; @@ -300,11 +296,9 @@ export const SilencesEditor = ({ <Trans i18nKey="alerting.silences-editor.save-silence">Save silence</Trans> </Button> )} - {showCancelButton && ( - <LinkButton onClick={onCancelHandler} variant={'secondary'}> - <Trans i18nKey="alerting.common.cancel">Cancel</Trans> - </LinkButton> - )} + <LinkButton onClick={onCancelHandler} variant={'secondary'}> + <Trans i18nKey="alerting.common.cancel">Cancel</Trans> + </LinkButton> </Stack> </form> </FormProvider> diff --git a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx index 48ac3a83a3470..72a208c8dafef 100644 --- a/public/app/features/alerting/unified/components/silences/SilencesTable.tsx +++ b/public/app/features/alerting/unified/components/silences/SilencesTable.tsx @@ -22,13 +22,14 @@ import { GRAFANA_RULES_SOURCE_NAME, getDatasourceAPIUid } from 'app/features/ale import { type AlertmanagerAlert, type Silence, SilenceState } from 'app/plugins/datasource/alertmanager/types'; import { alertmanagerApi } from '../../api/alertmanagerApi'; -import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities'; +import { isGranted, isSupported } from '../../hooks/abilities/abilityUtils'; +import { useSilenceAbility } from '../../hooks/abilities/alertmanager/useSilenceAbility'; +import { SilenceAction } from '../../hooks/abilities/types'; import { useAlertmanager } from '../../state/AlertmanagerContext'; import { parsePromQLStyleMatcherLooseSafe } from '../../utils/matchers'; import { getSilenceFiltersFromUrlParams, makeAMLink, stringifyErrorLike } from '../../utils/misc'; import { withPageErrorBoundary } from '../../withPageErrorBoundary'; import { AlertmanagerPageWrapper } from '../AlertingPageWrapper'; -import { Authorize } from '../Authorize'; import { DynamicTable, type DynamicTableColumnProps, type DynamicTableItemProps } from '../DynamicTable'; import { GrafanaAlertmanagerWarning } from '../GrafanaAlertmanagerWarning'; @@ -50,10 +51,8 @@ const API_QUERY_OPTIONS = { pollingInterval: SILENCES_POLL_INTERVAL_MS, refetchO const SilencesTable = () => { const { selectedAlertmanager: alertManagerSourceName = '' } = useAlertmanager(); - const [previewAlertsSupported, previewAlertsAllowed] = useAlertmanagerAbility( - AlertmanagerAction.PreviewSilencedInstances - ); - const canPreview = previewAlertsSupported && previewAlertsAllowed; + const { granted: canPreview } = useSilenceAbility({ action: SilenceAction.Preview }); + const canCreateSilence = isGranted(useSilenceAbility({ action: SilenceAction.Create })); const { data: alertManagerAlerts = [], isLoading: amAlertsIsLoading } = alertmanagerApi.endpoints.getAlertmanagerAlerts.useQuery( @@ -151,13 +150,13 @@ const SilencesTable = () => { {!!silences.length && ( <Stack direction="column"> <SilencesFilter silences={silences} /> - <Authorize actions={[AlertmanagerAction.CreateSilence]}> + {canCreateSilence && ( <Stack justifyContent="end"> <LinkButton href={makeAMLink('/alerting/silence/new', alertManagerSourceName)} icon="plus"> <Trans i18nKey="silences.table.add-silence-button">Add Silence</Trans> </LinkButton> </Stack> - </Authorize> + )} <SilenceList items={itemsNotExpired} alertManagerSourceName={alertManagerSourceName} @@ -281,7 +280,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ }); function useColumns(alertManagerSourceName: string) { - const [updateSupported, updateAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateSilence); + const updateAbility = useSilenceAbility({ action: SilenceAction.Update }); const [expireSilence] = alertSilencesApi.endpoints.expireSilence.useMutation(); const isGrafanaFlavoredAlertmanager = alertManagerSourceName === GRAFANA_RULES_SOURCE_NAME; @@ -307,18 +306,15 @@ function useColumns(alertManagerSourceName: string) { if (!ruleUid) { return 'None'; } - - const ruleTitle = metadata.rule_title; - if (ruleTitle) { + if (metadata.rule_title) { return ( <Link href={`/alerting/grafana/${encodeURIComponent(ruleUid)}/view?returnTo=${encodeURIComponent('/alerting/silences')}`} > - {ruleTitle} + {metadata.rule_title} </Link> ); } - return <MissingAlertRuleWarning ruleUid={ruleUid} />; }, size: 8, @@ -368,8 +364,14 @@ function useColumns(alertManagerSourceName: string) { const canCreate = silence?.accessControl?.create; const canWrite = silence?.accessControl?.write; - const canRecreate = updateSupported && isExpired && (isGrafanaFlavoredAlertmanager ? canCreate : updateAllowed); - const canEdit = updateSupported && !isExpired && (isGrafanaFlavoredAlertmanager ? canWrite : updateAllowed); + const canRecreate = + isSupported(updateAbility) && + isExpired && + (isGrafanaFlavoredAlertmanager ? canCreate : updateAbility.granted); + const canEdit = + isSupported(updateAbility) && + !isExpired && + (isGrafanaFlavoredAlertmanager ? canWrite : updateAbility.granted); return ( <Stack gap={0.5} wrap="wrap"> @@ -421,7 +423,7 @@ function useColumns(alertManagerSourceName: string) { size: 5, }); return columns; - }, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAllowed, updateSupported]); + }, [alertManagerSourceName, expireSilence, isGrafanaFlavoredAlertmanager, updateAbility]); } function SilencesTablePage() { diff --git a/public/app/features/alerting/unified/hooks/abilities/alertmanager/useSilenceAbility.test.tsx b/public/app/features/alerting/unified/hooks/abilities/alertmanager/useSilenceAbility.test.tsx index 568d299094831..00f0745434d48 100644 --- a/public/app/features/alerting/unified/hooks/abilities/alertmanager/useSilenceAbility.test.tsx +++ b/public/app/features/alerting/unified/hooks/abilities/alertmanager/useSilenceAbility.test.tsx @@ -60,6 +60,22 @@ describe('useSilenceAbility', () => { expect(result.current.preview.granted).toBe(true); }); + it('should grant View and Preview when only AlertingSilenceRead is held', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingSilenceRead]); + + const { result } = renderHook( + () => ({ + view: useSilenceAbility({ action: SilenceAction.View }), + preview: useSilenceAbility({ action: SilenceAction.Preview }), + }), + { wrapper: createAlertmanagerWrapper(amSource) } + ); + + expect(result.current.view.granted).toBe(true); + expect(result.current.preview.granted).toBe(true); + }); + it('should deny Update when accessControl.write is false on the silence entity', () => { const amSource = setupGrafanaAlertmanager(); grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingInstanceUpdate]); @@ -81,6 +97,28 @@ describe('useSilenceAbility', () => { expect(result.current.updateDenied.granted).toBe(false); expect(result.current.updateAllowed.granted).toBe(true); }); + + it('should grant Update when only AlertingSilenceUpdate is held', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingSilenceUpdate]); + + const { result } = renderHook(() => useSilenceAbility({ action: SilenceAction.Update }), { + wrapper: createAlertmanagerWrapper(amSource), + }); + + expect(result.current.granted).toBe(true); + }); + + it('should grant Create when only AlertingSilenceCreate is held', () => { + const amSource = setupGrafanaAlertmanager(); + grantUserPermissions([GRAFANA_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingSilenceCreate]); + + const { result } = renderHook(() => useSilenceAbility({ action: SilenceAction.Create }), { + wrapper: createAlertmanagerWrapper(amSource), + }); + + expect(result.current.granted).toBe(true); + }); }); describe('external (Mimir) alertmanager', () => { @@ -105,6 +143,82 @@ describe('useSilenceAbility', () => { expect(result.current.granted).toBe(false); }); + + it('should grant View and Preview when AlertingInstancesExternalRead is held', () => { + setupMimirAlertmanager(MIMIR_DATASOURCE_UID); + grantUserPermissions([EXTERNAL_AM_VISIBILITY_PERMISSION, AccessControlAction.AlertingInstancesExternalRead]); + + const { result } = renderHook( + () => ({ + view: useSilenceAbility({ action: SilenceAction.View }), + preview: useSilenceAbility({ action: SilenceAction.Preview }), + ... [truncated]
← Back to Alerts View on GitHub →