Access Control / Authorization
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]