Authorization bypass / RBAC naming consistency

HIGH
grafana/grafana
Commit: 6a7421ba16a9
Affected: < 12.4.0 (prior Grafana 12.x releases before this fix)
2026-06-19 17:47 UTC

Description

The commit adds a canonical alias for the default routing tree name and canonicalization helpers, wiring them into authorization/identity checks and API responses. It ensures that the default routing tree can be referenced via both the legacy name and the new alias without changing the underlying RBAC identity, and it reserves both names to prevent creation as managed routes. This addresses potential RBAC/name-based authorization inconsistencies where clients could refer to the default routing tree using different names, leading to inconsistent authorization behavior or unintended access. The changes stabilize RBAC scopes by enforcing a single internal identity for the default routing tree while preserving the canonical input/output experience for clients.

Commit Details

Author: Matthew Jacobson

Date: 2026-06-19 17:35 UTC

Message:

Alerting: add "default" as alias for default routing tree (#126841) * Alerting: add "default" alias for default routing tree - Add `DefaultRoutingTreeNameAlias` ("default") as alternative name for default routing tree - Introduce `IsDefaultRoutingTreeName()` and `CanonicalizeRoutingTreeName()` helper functions - Canonicalize routing tree names in authorization and identity operations for stable RBAC scopes - Echo requested name in API responses while maintaining canonical identity internally - Update validation to recognize both canonical name and alias as available routes - Reserve both names to prevent creation as managed routes * Remove redundant legacy_storage.UserDefinedRoutingTreeName * Remove redundant v1beta1.UserDefinedRoutingTreeName

Triage Assessment

Vulnerability Type: Authorization bypass / RBAC naming consistency

Confidence: HIGH

Reasoning:

The commit introduces a canonical alias for the default routing tree name and uses it to stabilize RBAC scopes and authorization checks. It adjusts authorization paths to use the canonical name, canonicalizes input names, and reserves both names to prevent misidentification or bypass through name variations. These changes directly address potential RBAC authorization issues and name-based access control inconsistencies.

Verification Assessment

Vulnerability Type: Authorization bypass / RBAC naming consistency

Confidence: HIGH

Affected Versions: < 12.4.0 (prior Grafana 12.x releases before this fix)

Code Diff

diff --git a/apps/alerting/notifications/pkg/apis/alertingnotifications/v1beta1/routingtree_ext.go b/apps/alerting/notifications/pkg/apis/alertingnotifications/v1beta1/routingtree_ext.go index d9430d19a5a08..81ed4b19e0a4e 100644 --- a/apps/alerting/notifications/pkg/apis/alertingnotifications/v1beta1/routingtree_ext.go +++ b/apps/alerting/notifications/pkg/apis/alertingnotifications/v1beta1/routingtree_ext.go @@ -1,7 +1,5 @@ package v1beta1 -const UserDefinedRoutingTreeName = "user-defined" - func (o *RoutingTree) GetProvenanceStatus() string { if o == nil || o.Annotations == nil { return ProvenanceStatusNone diff --git a/pkg/services/ngalert/accesscontrol/routes.go b/pkg/services/ngalert/accesscontrol/routes.go index 3b01d23ec4055..0222c77883e8c 100644 --- a/pkg/services/ngalert/accesscontrol/routes.go +++ b/pkg/services/ngalert/accesscontrol/routes.go @@ -104,7 +104,7 @@ func defaultRouteOnly[T models.Identified](eval ac.Evaluator) actionAccess[T] { authorizeSome: eval, // satisfies pre-condition — user can access at least the default route authorizeAll: ac.EvalPermission(""), // never satisfied — does NOT grant access to all routes authorizeOne: func(route models.Identified) ac.Evaluator { - if route.GetUID() == legacy_storage.UserDefinedRoutingTreeName { + if models.IsDefaultRoutingTreeName(route.GetUID()) { return eval } return ac.EvalPermission("") // never satisfied — does not apply to non-default routes @@ -252,17 +252,17 @@ func (s RouteAccess[T]) AuthorizeDelete(ctx context.Context, user identity.Reque // AuthorizeDeleteByUID checks if user has access to delete a route by name. func (s RouteAccess[T]) AuthorizeDeleteByUID(ctx context.Context, user identity.Requester, name string) error { - return s.delete.Authorize(ctx, user, identified{uid: name}) + return s.delete.Authorize(ctx, user, identified{uid: models.CanonicalizeRoutingTreeName(name)}) } // AuthorizeUpdateByUID checks if user has access to update a route by name. func (s RouteAccess[T]) AuthorizeUpdateByUID(ctx context.Context, user identity.Requester, name string) error { - return s.update.Authorize(ctx, user, identified{uid: name}) + return s.update.Authorize(ctx, user, identified{uid: models.CanonicalizeRoutingTreeName(name)}) } // AuthorizeReadByUID checks if user has access to read a route by name. func (s RouteAccess[T]) AuthorizeReadByUID(ctx context.Context, user identity.Requester, name string) error { - return s.read.Authorize(ctx, user, identified{uid: name}) + return s.read.Authorize(ctx, user, identified{uid: models.CanonicalizeRoutingTreeName(name)}) } func (s RouteAccess[T]) DeleteAllPermissions(ctx context.Context, orgID int64, route *legacy_storage.ManagedRoute) error { diff --git a/pkg/services/ngalert/accesscontrol/routes_test.go b/pkg/services/ngalert/accesscontrol/routes_test.go index c07da14d30a04..7aa21d9c0c2b1 100644 --- a/pkg/services/ngalert/accesscontrol/routes_test.go +++ b/pkg/services/ngalert/accesscontrol/routes_test.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/ngalert/models" - "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" "github.com/grafana/grafana/pkg/services/org" ) @@ -23,7 +22,7 @@ func (r testRoute) GetUID() string { } func TestRouteAccess(t *testing.T) { - defaultRoute := testRoute{name: legacy_storage.UserDefinedRoutingTreeName} + defaultRoute := testRoute{name: models.DefaultRoutingTreeName} route1 := testRoute{name: "route-1"} route2 := testRoute{name: "route-2"} @@ -387,7 +386,7 @@ func TestRouteAccess(t *testing.T) { } func TestRouteAccessFilterRead(t *testing.T) { - defaultRoute := testRoute{name: legacy_storage.UserDefinedRoutingTreeName} + defaultRoute := testRoute{name: models.DefaultRoutingTreeName} route1 := testRoute{name: "route-1"} route2 := testRoute{name: "route-2"} @@ -486,7 +485,7 @@ func TestRouteAccessFilterRead(t *testing.T) { } func TestRouteAccessAuthorizeLegacy(t *testing.T) { - defaultRoute := testRoute{name: legacy_storage.UserDefinedRoutingTreeName} + defaultRoute := testRoute{name: models.DefaultRoutingTreeName} route1 := testRoute{name: "route-1"} user := func(perms ...ac.Permission) identity.Requester { @@ -880,7 +879,7 @@ func TestRouteAccessAuthorizeScoped(t *testing.T) { } func TestRouteAccessAuthorizeProvisioning(t *testing.T) { - defaultRoute := testRoute{name: legacy_storage.UserDefinedRoutingTreeName} + defaultRoute := testRoute{name: models.DefaultRoutingTreeName} route1 := testRoute{name: "route-1"} user := func(perms ...ac.Permission) identity.Requester { diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index d18fd040a7bed..35b68ec84d11e 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -2095,7 +2095,7 @@ func TestApiNotificationPolicyExportSnapshot(t *testing.T) { p := newFakeNotificationPolicyService(rev) sut.policies = p - policies := []string{legacy_storage.UserDefinedRoutingTreeName} //nolint:prealloc + policies := []string{models.DefaultRoutingTreeName} //nolint:prealloc for policy := range policy_exports.Config().ManagedRoutes { policies = append(policies, policy) } diff --git a/pkg/services/ngalert/models/notifications.go b/pkg/services/ngalert/models/notifications.go index d1e0fb9e4bfbb..c2af26181606c 100644 --- a/pkg/services/ngalert/models/notifications.go +++ b/pkg/services/ngalert/models/notifications.go @@ -17,9 +17,35 @@ import ( // GroupByAll is a special value defined by alertmanager that can be used in a Route's GroupBy field to aggregate by all possible labels. const GroupByAll = "..." +// DefaultRoutingTreeName is the name the API uses for the default (root) routing tree in both +// responses (e.g. the default entry in LIST) and as its stable identity for RBAC scopes. +// The future canonical name is accepted on input via DefaultRoutingTreeNameAlias. A later change can flip this +// to the alias once clients recognize it. const DefaultRoutingTreeName = "user-defined" + +// DefaultRoutingTreeNameAlias is the future canonical name for the default (root) routing tree. +// It is accepted on API input as an alias for DefaultRoutingTreeName so newer clients can use it, +// but it is not yet emitted in LIST responses or used as an RBAC identifier. +const DefaultRoutingTreeNameAlias = "default" + const NamedRouteLabel = "__grafana_managed_route__" +// IsDefaultRoutingTreeName reports whether name refers to the default (root) routing tree, +// accepting both the emitted name and the alias. +func IsDefaultRoutingTreeName(name string) bool { + return name == DefaultRoutingTreeName || name == DefaultRoutingTreeNameAlias +} + +// CanonicalizeRoutingTreeName maps the default-tree alias to the emitted default-tree name and +// returns any other name unchanged. Used as a stable identity for RBAC scope, so both names resolve to the same +// permission. +func CanonicalizeRoutingTreeName(name string) string { + if name == DefaultRoutingTreeNameAlias { + return DefaultRoutingTreeName + } + return name +} + // NotificationSettingsType is an enum of the notification settings configurations a rule may have. // It is used by ListAlertRulesQuery to filter rules by the type of notification settings configured. type NotificationSettingsType string @@ -325,14 +351,14 @@ func (s *PolicyRouting) Validate() error { if s.Policy == "" { return errors.New("policy must be specified") } - if s.Policy == DefaultRoutingTreeName { + if IsDefaultRoutingTreeName(s.Policy) { return fmt.Errorf("policy routing should not explicitly point to the default tree: %q", DefaultRoutingTreeName) } return nil } func (s *PolicyRouting) IsDefault() bool { - return s.Policy == "" || s.Policy == DefaultRoutingTreeName + return s.Policy == "" || IsDefaultRoutingTreeName(s.Policy) } func (s *PolicyRouting) Equals(other *PolicyRouting) bool { diff --git a/pkg/services/ngalert/notifier/legacy_storage/routes.go b/pkg/services/ngalert/notifier/legacy_storage/routes.go index 4ca27af004160..29507639b6204 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/routes.go +++ b/pkg/services/ngalert/notifier/legacy_storage/routes.go @@ -21,7 +21,6 @@ import ( "github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert" ) -const UserDefinedRoutingTreeName = models.DefaultRoutingTreeName const NamedRouteMatcher = models.NamedRouteLabel type ManagedRoute struct { @@ -56,7 +55,7 @@ func (r *ManagedRoute) GeneratedSubRoute() *v1.Route { ri := model.Duration(defaultOpts.RepeatInterval) amRoute.RepeatInterval = &ri } - if r.Name != UserDefinedRoutingTreeName { + if !models.IsDefaultRoutingTreeName(r.Name) { // Set label matcher. amRoute.ObjectMatchers = v1.ObjectMatchers{managedRouteMatcher(r.Name)} } @@ -64,7 +63,9 @@ func (r *ManagedRoute) GeneratedSubRoute() *v1.Route { } func (r *ManagedRoute) GetUID() string { - return r.Name + // Canonicalize so the default tree has a single stable identity regardless of whether + // it was addressed by its canonical name or the legacy alias. This identity backs RBAC scopes. + return models.CanonicalizeRoutingTreeName(r.Name) } func (r *ManagedRoute) ResourceType() string { @@ -72,8 +73,8 @@ func (r *ManagedRoute) ResourceType() string { } func (r *ManagedRoute) ResourceID() string { - if r.Name == UserDefinedRoutingTreeName { - // Backwards compatibility with legacy user-defined routing tree. + if models.IsDefaultRoutingTreeName(r.Name) { + // Backwards compatibility with the legacy default (root) routing tree. return "" } return r.Name @@ -107,12 +108,12 @@ func managedRouteMatcher(name string) *labels.Matcher { type ManagedRoutes []*ManagedRoute func (m ManagedRoutes) Sort() { - // Sort the keys of the map to ensure consistent ordering. Always ensure that the legacy user-defined routing tree is last. + // Sort the keys of the map to ensure consistent ordering. Always ensure that the default routing tree is last. slices.SortFunc(m, func(a, b *ManagedRoute) int { - if a.Name == UserDefinedRoutingTreeName { + if models.IsDefaultRoutingTreeName(a.Name) { return 1 } - if b.Name == UserDefinedRoutingTreeName { + if models.IsDefaultRoutingTreeName(b.Name) { return -1 } return strings.Compare(a.Name, b.Name) @@ -137,21 +138,23 @@ func WithManagedRoutes(root *v1.Route, managedRoutes map[string]*v1.Route) *v1.R newManagedRoutes := make([]*v1.Route, 0, len(newRoot.Routes)+len(managedRoutes)) for _, k := range slices.Sorted(maps.Keys(managedRoutes)) { // On the off chance that the route is nil or invalid managed route with the restricted name, we skip it. - if managedRoutes[k] == nil || k == UserDefinedRoutingTreeName { + if managedRoutes[k] == nil || models.IsDefaultRoutingTreeName(k) { continue } newManagedRoutes = append(newManagedRoutes, NewManagedRoute(k, managedRoutes[k]).GeneratedSubRoute()) } - // Add the user-defined routing tree at the end. + // Add the default routing tree at the end. newManagedRoutes = append(newManagedRoutes, newRoot.Routes...) newRoot.Routes = newManagedRoutes return &newRoot } func (rev *ConfigRevision) GetManagedRoute(name string) *ManagedRoute { - if name == UserDefinedRoutingTreeName { - return NewManagedRoute(UserDefinedRoutingTreeName, rev.Config.AlertmanagerConfig.Route) + if models.IsDefaultRoutingTreeName(name) { + // Echo the requested name (canonical or alias) so the response preserves the name + // the client used, while GetUID/ResourceID canonicalize for identity purposes. + return NewManagedRoute(name, rev.Config.AlertmanagerConfig.Route) } route, ok := rev.Config.ManagedRoutes[name] if !ok { @@ -165,13 +168,13 @@ func (rev *ConfigRevision) GetManagedRoutes(includeManagedRoutes bool) ManagedRo if includeManagedRoutes { for _, k := range slices.Sorted(maps.Keys(rev.Config.ManagedRoutes)) { // On the off chance that the route is nil or invalid managed route with the restricted name, we skip it. - if rev.Config.ManagedRoutes[k] == nil || k == UserDefinedRoutingTreeName { + if rev.Config.ManagedRoutes[k] == nil || models.IsDefaultRoutingTreeName(k) { continue } managedRoutes = append(managedRoutes, NewManagedRoute(k, rev.Config.ManagedRoutes[k])) } } - managedRoutes = append(managedRoutes, NewManagedRoute(UserDefinedRoutingTreeName, rev.Config.AlertmanagerConfig.Route)) + managedRoutes = append(managedRoutes, NewManagedRoute(models.DefaultRoutingTreeName, rev.Config.AlertmanagerConfig.Route)) return managedRoutes } @@ -203,8 +206,8 @@ func (rev *ConfigRevision) CreateManagedRoute(name string, subtree v1.Route) (*M return nil, models.MakeErrRouteInvalidFormat(err) } - if name == UserDefinedRoutingTreeName { - return nil, models.ErrRouteExists.Errorf("cannot create a managed route with the name %q, this name is reserved for the user-defined routing tree", UserDefinedRoutingTreeName) + if models.IsDefaultRoutingTreeName(name) { + return nil, models.ErrRouteExists.Errorf("cannot create a managed route with the name %q, this name is reserved for the default routing tree", name) } if _, exists := rev.Config.ManagedRoutes[name]; exists { @@ -244,7 +247,7 @@ func (rev *ConfigRevision) UpdateNamedRoute(name string, subtree v1.Route) (*Man return nil, models.MakeErrRouteInvalidFormat(err) } - if name == UserDefinedRoutingTreeName { + if models.IsDefaultRoutingTreeName(name) { rev.Config.AlertmanagerConfig.Route = &amRoute } else { if rev.Config.ManagedRoutes == nil { @@ -273,7 +276,7 @@ func (rev *ConfigRevision) ResetUserDefinedRoute(defaultCfg *v1.AMConfigV1) (*Ma rev.Config.AlertmanagerConfig.Receivers = append(rev.Config.AlertmanagerConfig.Receivers, defaultRcv) } - return rev.UpdateNamedRoute(UserDefinedRoutingTreeName, *defaultCfg.AlertmanagerConfig.Route) + return rev.UpdateNamedRoute(models.DefaultRoutingTreeName, *defaultCfg.AlertmanagerConfig.Route) } func (rev *ConfigRevision) ValidateRoute(route v1.Route) error { diff --git a/pkg/services/ngalert/notifier/legacy_storage/routes_test.go b/pkg/services/ngalert/notifier/legacy_storage/routes_test.go index 272fb4979d2a4..ffe703d2084e4 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/routes_test.go +++ b/pkg/services/ngalert/notifier/legacy_storage/routes_test.go @@ -37,7 +37,7 @@ func TestManagedRoute_GeneratedSubRoute_DefaultsAndMatcher(t *testing.T) { func TestManagedRoute_GeneratedSubRoute_UserDefinedHasNoMatcher(t *testing.T) { mr := &ManagedRoute{ - Name: UserDefinedRoutingTreeName, + Name: models.DefaultRoutingTreeName, Receiver: "receiver" ... [truncated]
← Back to Alerts View on GitHub →