Access Control / Authorization

MEDIUM
grafana/grafana
Commit: b13f508094fa
Affected: <=12.4.0
2026-04-29 08:22 UTC

Description

Patch introduces UI-level read-only gating for editing alert rule groups that are plugin-provided, provisioned, or federated. getRulerGroupReadOnlyStatus/getPromGroupReadOnlyStatus are used to determine readOnly status; editing is blocked in the UI for such groups and informative alerts are shown instead. Additionally, failed update attempts log richer backend context for actionable debugging. This addresses an authorization/access-control concern where externally managed rule groups could be edited via Grafana UI (and potentially API paths) before the fix.

Proof of Concept

Proof-of-concept (PoC) to reproduce the issue prior to the fix (UI gating): Prereqs: - Grafana instance with an alerting rule group that is managed by a plugin or provisioned source. - A user with permissions to edit rule groups in Grafana. - A plugin-provided or provisioned rule group containing a rule with provenance/plugin origin (e.g., __grafana_origin=plugin/*). Steps: 1) Navigate to the Alerting > Unified > Rule Groups list. 2) Open a group that is plugin-provided or provisioned. 3) Observe that the Edit button is visible and the group can be opened in an edit form. 4) Edit the group (e.g., change a rule or the group name) and save. 5) Observe that the update succeeds, allowing modification of an externally managed group. This PoC demonstrates the pre-fix behavior where UI allowed editing of plugin-managed or provisioned rule groups, enabling an authorization bypass vector if server-side protections were not enforced. Post-fix expectation: - The Edit button is hidden or the edit form is replaced with a read-only/info alert for such groups, preventing edits via the Grafana UI. - Any attempt to update a read-only group will be rejected by the UI, and, if attempted via API, will surface a meaningful error/status captured in logs (as implemented in the commit). Code-like PoC snippet (API-oriented, endpoint names may vary by Grafana version): # Attempt to update a plugin-provided group via Grafana API (illustrative; exact endpoint may differ) curl -X PUT \ "https://grafana.example/api/alerting/ruler-groups/plugin-group-1" \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{ "name": "PluginGroup", "rules": [ { /* modified rule payload */ } ] }' Expected result prior to fix: HTTP 200 with the group updated. Expected result after the fix: HTTP 403/400 with a read-only/error message, or UI prevents editing altogether.

Commit Details

Author: Konrad Lalik

Date: 2026-04-29 08:07 UTC

Message:

Alerting: Block editing plugin-provided and provisioned rule groups (#123214) * Alerting: Block editing plugin-provided and provisioned rule groups Hide the group Edit button on the rule list and group details pages when any rule in the group carries `__grafana_origin=plugin/*` or a provenance marker, and show a non-editable alert on the group edit page itself for deep-link access. Also log HTTP status and backend message when the update does fail so Faro captures actionable context instead of a bare 'Failed to update rule group'. * Alerting: Fix CI (prettier, typecheck, i18n extraction) * Unify rule group read-only status checks Replaces scattered federated, provisioned, and plugin checks with getRulerGroupReadOnlyStatus and getPromGroupReadOnlyStatus helpers. The new status objects provide a readOnly boolean and a specific reason, simplifying edit gating and enabling targeted informational alerts in the UI. * Alerting: address review fixes for rule group read-only refactor - Add missing plugin > federated precedence test to getRulerGroupReadOnlyStatus - Add default case to GroupEditBody switch to render the edit form as fallback * Fix lint errors

Triage Assessment

Vulnerability Type: Access Control / Authorization bypass

Confidence: MEDIUM

Reasoning:

The changes enforce read-only gating for editing alert rule groups that are plugin-provided, provisioned, or federated, preventing edits via Grafana UI. This mitigates risks where external-managed rule groups could be tampered with, addressing authorization/access-control concerns around who can modify such groups.

Verification Assessment

Vulnerability Type: Access Control / Authorization

Confidence: MEDIUM

Affected Versions: <=12.4.0

Code Diff

diff --git a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx index dd9d2536d9297..cf6ed1932e758 100644 --- a/public/app/features/alerting/unified/components/rules/RulesGroup.tsx +++ b/public/app/features/alerting/unified/components/rules/RulesGroup.tsx @@ -13,7 +13,13 @@ import { useRulesAccess } from '../../utils/accessControlHooks'; import { GRAFANA_RULES_SOURCE_NAME, getRulesSourceName, isCloudRulesSource } from '../../utils/datasource'; import { makeFolderLink } from '../../utils/misc'; import { groups } from '../../utils/navigation'; -import { isFederatedRuleGroup, isPluginProvidedRule, isUngroupedRuleGroup, rulerRuleType } from '../../utils/rules'; +import { + getPromGroupReadOnlyStatus, + getRulerGroupReadOnlyStatus, + isFederatedRuleGroup, + isUngroupedRuleGroup, + rulerRuleType, +} from '../../utils/rules'; import { CollapseToggle } from '../CollapseToggle'; import { RuleLocation } from '../RuleLocation'; import { GrafanaRuleFolderExporter } from '../export/GrafanaRuleFolderExporter'; @@ -60,13 +66,19 @@ export const RulesGroup = React.memo(({ group, namespace, expandAll, viewMode }: const isDeleting = hasRuler && rulerRulesLoaded && !group.rules.find((rule) => !!rule.rulerRule); const isFederated = isFederatedRuleGroup(group); - // check if group has provisioned items + // check if group has provisioned items (badge-only — edit gating uses the status helper below) const isProvisioned = group.rules.some((rule) => { return rulerRuleType.grafana.rule(rule.rulerRule) && rule.rulerRule.grafana_alert.provenance; }); - const isPluginProvided = group.rules.some((rule) => isPluginProvidedRule(rule.rulerRule ?? rule.promRule)); - const canEditGroup = hasRuler && !isProvisioned && !isFederated && !isPluginProvided && canEditRules(rulesSourceName); + // CombinedRule wraps both API views of the same logical rule. Check each side with its own + // typed helper and OR the booleans — the consumer only needs the boolean for edit gating. + const rulerRules = group.rules.flatMap((r) => (r.rulerRule ? [r.rulerRule] : [])); + const promRules = group.rules.flatMap((r) => (r.promRule ? [r.promRule] : [])); + const groupReadOnly = + getRulerGroupReadOnlyStatus({ rules: rulerRules, source_tenants: group.source_tenants }).readOnly || + getPromGroupReadOnlyStatus({ rules: promRules }).readOnly; + const canEditGroup = hasRuler && !groupReadOnly && canEditRules(rulesSourceName); // check what view mode we are in const isListView = viewMode === 'list'; diff --git a/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx b/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx index 476b72cd3a2f9..ec818ecc0b871 100644 --- a/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx +++ b/public/app/features/alerting/unified/group-details/GroupDetailsPage.tsx @@ -22,7 +22,7 @@ import { useRulesAccess } from '../utils/accessControlHooks'; import { GRAFANA_RULES_SOURCE_NAME, getDataSourceByUid } from '../utils/datasource'; import { makeFolderLink, stringifyErrorLike } from '../utils/misc'; import { createListFilterLink, groups } from '../utils/navigation'; -import { isFederatedRuleGroup, isProvisionedRuleGroup } from '../utils/rules'; +import { getRulerGroupReadOnlyStatus } from '../utils/rules'; import { formatPrometheusDuration } from '../utils/time'; import { Title } from './Title'; @@ -192,15 +192,9 @@ function GroupActions({ dsFeatures, namespaceId, groupName, folder, rulerGroup } const isGrafanaSource = dsFeatures.uid === GRAFANA_RULES_SOURCE_NAME; const canSaveInFolder = isGrafanaSource ? Boolean(folder?.canSave) : true; - const isFederated = rulerGroup ? isFederatedRuleGroup(rulerGroup) : false; - const isProvisioned = rulerGroup ? isProvisionedRuleGroup(rulerGroup) : false; + const readOnly = rulerGroup ? getRulerGroupReadOnlyStatus(rulerGroup).readOnly : false; - const canEdit = - Boolean(dsFeatures.rulerConfig) && - canEditRules(dsFeatures.name) && - canSaveInFolder && - !isFederated && - !isProvisioned; + const canEdit = Boolean(dsFeatures.rulerConfig) && canEditRules(dsFeatures.name) && canSaveInFolder && !readOnly; return ( <> diff --git a/public/app/features/alerting/unified/group-details/GroupEditPage.tsx b/public/app/features/alerting/unified/group-details/GroupEditPage.tsx index e10866fd06d2f..87c83b2b0e20e 100644 --- a/public/app/features/alerting/unified/group-details/GroupEditPage.tsx +++ b/public/app/features/alerting/unified/group-details/GroupEditPage.tsx @@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom-v5-compat'; import { type GrafanaTheme2, type NavModelItem } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { locationService } from '@grafana/runtime'; +import { isFetchError, locationService } from '@grafana/runtime'; import { Alert, Button, @@ -45,6 +45,7 @@ import { DEFAULT_GROUP_EVALUATION_INTERVAL } from '../rule-editor/formDefaults'; import { ruleGroupIdentifierV2toV1 } from '../utils/groupIdentifier'; import { stringifyErrorLike } from '../utils/misc'; import { alertListPageLink, createListFilterLink, groups } from '../utils/navigation'; +import { getRulerGroupReadOnlyStatus } from '../utils/rules'; import { DraggableRulesTable } from './components/DraggableRulesTable'; import { evaluateEveryValidationOptions } from './validation'; @@ -152,7 +153,7 @@ function GroupEditPage() { </Alert> )} </> - {rulerGroup && <GroupEditForm rulerGroup={rulerGroup} groupIdentifier={groupIdentifier} />} + {rulerGroup && <GroupEditBody rulerGroup={rulerGroup} groupIdentifier={groupIdentifier} />} {!rulerGroup && <EntityNotFound entity={`${namespaceId}/${groupName}`} />} </AlertingPageWrapper> ); @@ -160,6 +161,52 @@ function GroupEditPage() { export default withErrorBoundary(GroupEditPage, { style: 'page' }); +interface GroupEditBodyProps { + rulerGroup: RulerRuleGroupDTO; + groupIdentifier: RuleGroupIdentifierV2; +} + +function GroupEditBody({ rulerGroup, groupIdentifier }: GroupEditBodyProps) { + const status = getRulerGroupReadOnlyStatus(rulerGroup); + + if (!status.readOnly) { + return <GroupEditForm rulerGroup={rulerGroup} groupIdentifier={groupIdentifier} />; + } + + switch (status.reason) { + case 'plugin': + return ( + <Alert + title={t('alerting.group-edit.group-plugin-provided', 'This rule group is managed by a plugin')} + severity="info" + > + <Trans i18nKey="alerting.group-edit.group-plugin-provided-description"> + Rule groups provisioned by a plugin cannot be edited from Grafana. Manage them from the plugin that owns + them. + </Trans> + </Alert> + ); + case 'provisioned': + return ( + <Alert title={t('alerting.group-edit.group-provisioned', 'This rule group is provisioned')} severity="info"> + <Trans i18nKey="alerting.group-edit.group-provisioned-description"> + Provisioned rule groups cannot be edited from Grafana. Update the source provisioning configuration instead. + </Trans> + </Alert> + ); + case 'federated': + return ( + <Alert title={t('alerting.group-edit.group-federated', 'This rule group is federated')} severity="info"> + <Trans i18nKey="alerting.group-edit.group-federated-description"> + Federated rule groups cannot be edited from Grafana. + </Trans> + </Alert> + ); + default: + return <GroupEditForm rulerGroup={rulerGroup} groupIdentifier={groupIdentifier} />; + } +} + interface GroupEditFormProps { rulerGroup: RulerRuleGroupDTO; groupIdentifier: RuleGroupIdentifierV2; @@ -233,11 +280,18 @@ function GroupEditForm({ rulerGroup, groupIdentifier }: GroupEditFormProps) { setMatchingGroupPageUrl(updatedGroupIdentifier); } catch (error) { - logError(error instanceof Error ? error : new Error('Failed to update rule group')); - appInfo.error( - t('alerting.group-edit.form.update-error', 'Failed to update rule group'), - stringifyErrorLike(error) - ); + const message = stringifyErrorLike(error); + const loggedError = error instanceof Error ? error : new Error(message); + logError(loggedError, { + operation: 'updateRuleGroup', + message, + ...(isFetchError(error) && { + status: String(error.status), + statusText: error.statusText ?? '', + url: error.config?.url ?? '', + }), + }); + appInfo.error(t('alerting.group-edit.form.update-error', 'Failed to update rule group'), message); } }; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx index dc073ccc50fb5..be053935644f6 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx @@ -13,6 +13,7 @@ import { type PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { useHasRulerV2 } from '../hooks/useHasRuler'; import { groups } from '../utils/navigation'; +import { getPromGroupReadOnlyStatus } from '../utils/rules'; import { DataSourceGroupLoader } from './DataSourceGroupLoader'; import { DataSourceSection, type DataSourceSectionProps } from './components/DataSourceSection'; @@ -190,6 +191,7 @@ function RuleGroupListItem({ rulesSourceIdentifier, group, namespaceName }: Rule dsUid={rulesSourceIdentifier.uid} namespaceName={namespaceName} groupName={group.name} + readOnly={getPromGroupReadOnlyStatus(group).readOnly} /> } > @@ -202,12 +204,13 @@ interface DataSourceGroupActionsProps { dsUid: string; namespaceName: string; groupName: string; + readOnly: boolean; } -function DataSourceGroupActions({ dsUid, namespaceName, groupName }: DataSourceGroupActionsProps) { +function DataSourceGroupActions({ dsUid, namespaceName, groupName, readOnly }: DataSourceGroupActionsProps) { const { hasRuler } = useHasRulerV2(dsUid); const [editRuleSupported, editRuleAllowed] = useAlertingAbility(AlertingAction.UpdateExternalAlertRule); - const canEdit = editRuleSupported && editRuleAllowed; + const canEdit = editRuleSupported && editRuleAllowed && !readOnly; if (!hasRuler || !canEdit) { return null; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index c8f55840cc1db..f131ad43d3ee9 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -15,7 +15,7 @@ import { AlertingAction, useAlertingAbility } from '../hooks/useAbilities'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { makeFolderAlertsLink } from '../utils/misc'; import { groups } from '../utils/navigation'; -import { isUngroupedRuleGroup } from '../utils/rules'; +import { getPromGroupReadOnlyStatus, isUngroupedRuleGroup } from '../utils/rules'; import { GrafanaGroupLoader } from './GrafanaGroupLoader'; import { DataSourceSection } from './components/DataSourceSection'; @@ -206,7 +206,13 @@ export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGr key={group.name} name={groupDisplayName} metaRight={<GroupIntervalIndicator seconds={group.interval} />} - actions={<GrafanaGroupActions folderUid={group.folderUid} groupName={group.name} />} + actions={ + <GrafanaGroupActions + folderUid={group.folderUid} + groupName={group.name} + readOnly={getPromGroupReadOnlyStatus(group).readOnly} + /> + } href={detailsLink} isOpen={false} > @@ -218,15 +224,16 @@ export function GrafanaRuleGroupListItem({ group, namespaceName }: GrafanaRuleGr interface GrafanaGroupActionsProps { folderUid: string; groupName: string; + readOnly: boolean; } -function GrafanaGroupActions({ folderUid, groupName }: GrafanaGroupActionsProps) { +function GrafanaGroupActions({ folderUid, groupName, readOnly }: GrafanaGroupActionsProps) { const [showExportDrawer, setShowExportDrawer] = useState(false); const [editRuleSupported, editRuleAllowed] = useAlertingAbility(AlertingAction.UpdateAlertRule); const [exportRulesSupported, exportRulesAllowed] = useAlertingAbility(AlertingAction.ExportGrafanaManagedRules); - const canEdit = editRuleSupported && editRuleAllowed; + const canEdit = editRuleSupported && editRuleAllowed && !readOnly; const canExport = exportRulesSupported && exportRulesAllowed; if (!canEdit && !canExport) { diff --git a/public/app/features/alerting/unified/utils/rules.test.ts b/public/app/features/alerting/unified/utils/rules.test.ts index 3d7c4fc7a04b2..148e9e7be7679 100644 --- a/public/app/features/alerting/unified/utils/rules.test.ts +++ b/public/app/features/alerting/unified/utils/rules.test.ts @@ -6,6 +6,7 @@ import { mockCombinedCloudRuleNamespace, mockCombinedRule, mockCombinedRuleGroup, + mockGrafanaPromAlertingRule, mockGrafanaRulerRule, mockPromAlertingRule, mockRuleWithLocation, @@ -14,9 +15,11 @@ import { import { GRAFANA_ORIGIN_LABEL } from './labels'; import { + getPromGroupReadOnlyStatus, getRuleGroupLocationFromCombinedRule, getRuleGroupLocationFromRuleWithLocation, getRulePluginOrigin, + getRulerGroupReadOnlyStatus, isUngroupedRuleGroup, } from './rules'; @@ -147,3 +150,101 @@ describe('isUngroupedRuleGroup', () => { expect(isUngroupedRuleGroup('MyGroup_no_group_for_rule_')).toBe(false); }); }); + +describe('getRulerGroupReadOnlyStatus', () => { + it('reports readOnly=false for an editable Ruler group', () => { + const group = { + rules: [mockRulerAlertingRule()], + }; + expect(getRulerGroupReadOnlyStatus(group)).toEqual({ readOnly: false }); + }); + + it('reports reason=plugin when any rule carries the plugin origin label', () => { + const group = { + rules: [mockRulerAlertingRule({ labels: { [GRAFANA_ORIGIN_LABEL]: 'plugin/grafana-test-app' } })], + }; + expect(getRulerGroupReadOnlyStatus(group)).toEqual({ readOnly: true, reason: 'plugin' }); + }); + + it('reports reason=provisioned for a Grafana-managed rule with provenance', () => { + const group = { + rules: [ + { ...mockGrafanaRulerRule(), grafana_alert: { ...mockGrafanaRulerRule().grafana_alert, provenance: 'api' } }, + ], + }; + expect(getRulerGroupReadOnl ... [truncated]
← Back to Alerts View on GitHub →