Authorization bypass / Privilege escalation
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{