Authorization bypass / Privilege escalation

MEDIUM
grafana/grafana
Commit: 5685f70978d8
Affected: Pre-12.4.0 (versions prior to this commit in the 12.x line)
2026-04-03 20:36 UTC

Description

The commit addresses an authorization bypass risk around exporting or retrieving a managed route by moving the retrieval path behind the provisioning-aware service layer. Previously, access to the managed route could be performed via routeService without consistently enforcing provisioning-based permissions, potentially allowing a user without provisioning rights to access sensitive route definitions. The change introduces a path where GetManagedRoute is exposed through the NotificationPolicyService (which is wired with provisioning context) and delegated to a managedRoutesService, ensuring provisioning-based authorization is applied when exporting/viewing managed routes. This reduces the risk of privilege escalation via route exposure.

Proof of Concept

PoC outline (exploit attempts before vs after the fix): Prerequisites: - Grafana instance 12.x prior to this commit with NGAlert provisioning enabled. - Two API tokens or identities: - Admin/provisioning token with provisioning permissions. - Viewer/non-provisioning token without provisioning permissions. - A managed route already created in the system. Steps to reproduce (pre-fix behavior, unlikely after the fix): 1) Using the viewer token, request the export of a managed route exposed by the provisioning API endpoint (path is implementation-specific, e.g. /api/ngalert/provisioning/export/managed-route?orgId=1&name=RouteName). 2) Observe that the response returns the managed route data (HTTP 200) including route definitions and potentially sensitive provisioning-related details, indicating an authorization bypass. Steps to reproduce (post-fix behavior): 1) Use the same viewer token to request the same managed route export endpoint. 2) The API should respond with HTTP 403 Forbidden (or 401 Unauthorized) or a similar access-denied error, indicating that provisioning permissions are required. Code-oriented PoC (conceptual, for tests): - In a test environment, authenticate as a user without provisioning rights and call the provisioning export path for a managed route. Expect a denial (403). - Then assert that an identity with provisioning rights can access the data. This demonstrates the access control gating introduced by routing through the provisioning policy service. Go test pseudo-snippet (illustrative, not runnable here due to environment differences): // as non-provisioning user resp, err := http.Get("https://grafana.example/api/ngalert/provisioning/export/managed-route?orgId=1&name=RouteA") // include Authorization: Bearer <viewer-token> // expect 403 // as provisioning user resp, err := http.Get("https://grafana.example/api/ngalert/provisioning/export/managed-route?orgId=1&name=RouteA") // include Authorization: Bearer <admin-token> // expect 200 with route payload

Commit Details

Author: Yuri Tseretyan

Date: 2026-03-17 19:42 UTC

Message:

Alerting: move get managed route to the service so it can use provisioning permissions (#120541) move get managed route to the service so it can use provisioning permissions

Triage Assessment

Vulnerability Type: Authorization bypass / Privilege escalation

Confidence: MEDIUM

Reasoning:

The commit moves access to the managed route into a service path that uses provisioning permissions, enabling proper access control during route retrieval/export. This reduces a potential authorization bypass where managed routes could be accessed without the provisioning-based permissions. The changes align with enforcing security boundaries around provisioning and route exposure.

Verification Assessment

Vulnerability Type: Authorization bypass / Privilege escalation

Confidence: MEDIUM

Affected Versions: Pre-12.4.0 (versions prior to this commit in the 12.x line)

Code Diff

diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 8628759eba997..7688a0260ed7c 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -187,7 +187,6 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { api.RegisterProvisioningApiEndpoints(NewProvisioningApi(&ProvisioningSrv{ log: logger, policies: api.Policies, - routeService: api.RouteService, contactPointService: api.ContactPointService, templates: api.Templates, muteTimings: api.MuteTimings, diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index da5a2dafc8d77..5fb354151259a 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -29,7 +29,6 @@ const disableProvenanceHeaderName = "X-Disable-Provenance" type ProvisioningSrv struct { log log.Logger policies NotificationPolicyService - routeService routeService contactPointService ContactPointService templates TemplateService muteTimings MuteTimingService @@ -58,9 +57,6 @@ type NotificationPolicyService interface { GetPolicyTree(ctx context.Context, orgID int64) (definitions.Route, string, error) UpdatePolicyTree(ctx context.Context, orgID int64, tree definitions.Route, p alerting_models.Provenance, version string) (definitions.Route, string, error) ResetPolicyTree(ctx context.Context, orgID int64, provenance alerting_models.Provenance) (definitions.Route, error) -} - -type routeService interface { GetManagedRoute(ctx context.Context, orgID int64, name string, user identity.Requester) (legacy_storage.ManagedRoute, error) } @@ -118,7 +114,7 @@ func (srv *ProvisioningSrv) RouteGetPolicyTreeExport(c *contextmodel.ReqContext) return exportResponse(c, e) } - managedRoute, err := srv.routeService.GetManagedRoute(c.Req.Context(), c.GetOrgID(), routeName, c.SignedInUser) + managedRoute, err := srv.policies.GetManagedRoute(c.Req.Context(), c.GetOrgID(), routeName, c.SignedInUser) if err != nil { return response.ErrOrFallback(http.StatusInternalServerError, "failed to export notification policy tree", err) } diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index a84535e6b08c8..ccb71de61af01 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -22,6 +22,7 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/bus" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" @@ -2081,8 +2082,8 @@ func TestApiNotificationPolicyExportSnapshot(t *testing.T) { rev := legacy_storage.ConfigRevision{ Config: policy_exports.Config(), } - sut.policies = newFakeNotificationPolicyService(rev) - sut.routeService = routes.NewFakeService(rev) + p := newFakeNotificationPolicyService(rev) + sut.policies = p policies := []string{legacy_storage.UserDefinedRoutingTreeName} //nolint:prealloc for policy := range policy_exports.Config().ManagedRoutes { @@ -2312,7 +2313,6 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi return ProvisioningSrv{ log: env.log, policies: newFakeNotificationPolicyService(rev), - routeService: rs, contactPointService: provisioning.NewContactPointService(receiverAuthz, configStore, env.secrets, env.prov, env.xact, receiverSvc, env.log, env.store, ngalertfakes.NewFakeReceiverPermissionsService()), templates: provisioning.NewTemplateService(configStore, env.prov, env.xact, env.log), muteTimings: provisioning.NewMuteTimingService(configStore, env.prov, env.xact, env.log, env.store, rs), @@ -2342,7 +2342,6 @@ func createTestRequestCtx() contextmodel.ReqContext { } type fakeNotificationPolicyService struct { - NotificationPolicyService config legacy_storage.ConfigRevision prov models.Provenance } @@ -2363,6 +2362,10 @@ func createFakeNotificationPolicyService() *fakeNotificationPolicyService { } } +func (f *fakeNotificationPolicyService) GetManagedRoute(ctx context.Context, orgID int64, name string, user identity.Requester) (legacy_storage.ManagedRoute, error) { + return routes.NewFakeService(f.config).GetManagedRoute(ctx, orgID, name, user) +} + func (f *fakeNotificationPolicyService) GetPolicyTree(ctx context.Context, orgID int64) (definitions.Route, string, error) { if orgID != 1 { return definitions.Route{}, "", store.ErrNoAlertmanagerConfiguration diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index 7e34faf3e667d..88086ec1a4b3d 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -514,7 +514,7 @@ func (ng *AlertNG) init() error { } // Provisioning - policyService := provisioning.NewNotificationPolicyService(configStore, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log) + policyService := provisioning.NewNotificationPolicyService(configStore, ng.store, ng.store, routeService, ng.Cfg.UnifiedAlerting, ng.Log) contactPointService := provisioning.NewContactPointService(provisioningReceiverAuthz, configStore, ng.SecretsService, ng.store, ng.store, provisioningReceiverService, ng.Log, ng.store, ng.ResourcePermissions) templateService := provisioning.NewTemplateService(configStore, ng.store, ng.store, ng.Log) templateServiceWithLimits := templateService.WithLimitsProvider(limitsProvider) diff --git a/pkg/services/ngalert/provisioning/notification_policies.go b/pkg/services/ngalert/provisioning/notification_policies.go index a80a39e1b7469..72ceddb6799b7 100644 --- a/pkg/services/ngalert/provisioning/notification_policies.go +++ b/pkg/services/ngalert/provisioning/notification_policies.go @@ -7,6 +7,7 @@ import ( "github.com/grafana/alerting/definition" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" @@ -15,6 +16,10 @@ import ( "github.com/grafana/grafana/pkg/setting" ) +type managedRoutesService interface { + GetManagedRoute(ctx context.Context, orgID int64, name string, user identity.Requester) (legacy_storage.ManagedRoute, error) +} + type NotificationPolicyService struct { configStore alertmanagerConfigStore provenanceStore ProvisioningStore @@ -22,10 +27,11 @@ type NotificationPolicyService struct { log log.Logger settings setting.UnifiedAlertingSettings validator validation.ProvenanceStatusTransitionValidator + routeService managedRoutesService } func NewNotificationPolicyService(am alertmanagerConfigStore, prov ProvisioningStore, - xact TransactionManager, settings setting.UnifiedAlertingSettings, log log.Logger) *NotificationPolicyService { + xact TransactionManager, routeService managedRoutesService, settings setting.UnifiedAlertingSettings, log log.Logger) *NotificationPolicyService { return &NotificationPolicyService{ configStore: am, provenanceStore: prov, @@ -33,6 +39,7 @@ func NewNotificationPolicyService(am alertmanagerConfigStore, prov ProvisioningS log: log, settings: settings, validator: validation.ValidateProvenanceRelaxed, + routeService: routeService, } } @@ -165,3 +172,9 @@ func (nps *NotificationPolicyService) checkOptimisticConcurrency(current definit } return nil } + +// GetManagedRoute returns managed route by name. +func (nps *NotificationPolicyService) GetManagedRoute(ctx context.Context, orgID int64, name string, user identity.Requester) (legacy_storage.ManagedRoute, error) { + // This is a workaround for exporting managed routes to include provisioning permissions to access authorization. + return nps.routeService.GetManagedRoute(ctx, orgID, name, user) +} diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index b82bacce97b8a..663e157abb48e 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -325,7 +325,16 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error features = ps.alertingStore.FeatureToggles } configStore := legacy_storage.NewAlertmanagerConfigStore(ps.alertingStore, notifier.NewExtraConfigsCrypto(ps.secretService), features) - routeService := routes.NewService(configStore, ps.alertingStore, ps.alertingStore, ps.Cfg.UnifiedAlerting, features, ps.log, validation.ValidateProvenanceRelaxed, ps.tracer) + routeService := routes.NewService( + configStore, + ps.alertingStore, + ps.alertingStore, + ps.Cfg.UnifiedAlerting, + features, + ps.log, + validation.ValidateProvenanceRelaxed, + ps.tracer, + ) receiverAuthz := alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true) receiverSvc := notifier.NewReceiverService( receiverAuthz, @@ -343,7 +352,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error contactPointService := provisioning.NewContactPointService(receiverAuthz, configStore, ps.secretService, ps.alertingStore, ps.SQLStore, receiverSvc, ps.log, ps.alertingStore, ps.resourcePermissions) notificationPolicyService := provisioning.NewNotificationPolicyService(configStore, - ps.alertingStore, ps.SQLStore, ps.Cfg.UnifiedAlerting, ps.log) + ps.alertingStore, ps.SQLStore, routeService, ps.Cfg.UnifiedAlerting, ps.log) mutetimingsService := provisioning.NewMuteTimingService(configStore, ps.alertingStore, ps.alertingStore, ps.log, ps.alertingStore, routeService) templateService := provisioning.NewTemplateService(configStore, ps.alertingStore, ps.alertingStore, ps.log) cfg := prov_alerting.ProvisionerConfig{
← Back to Alerts View on GitHub →