Medium severity5.4NVD Advisory· Published Mar 26, 2026· Updated Apr 14, 2026
CVE-2026-21724
CVE-2026-21724
Description
A vulnerability has been discovered in Grafana OSS where an authorization bypass in the provisioning contact points API allows users with Editor role to modify protected webhook URLs without the required alert.notifications.receivers.protected:write permission.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/grafana/grafanaGo | < 1.9.2-0.20260323180334-daffe750de85 | 1.9.2-0.20260323180334-daffe750de85 |
Affected products
1Patches
1daffe750de85[release-12.3.6] Alerting: Add protected fields authorization check to provisioning API (#120897)
12 files changed · +350 −48
pkg/services/ngalert/api/api_provisioning.go+2 −2 modified@@ -43,7 +43,7 @@ type ProvisioningSrv struct { type ContactPointService interface { GetContactPoints(ctx context.Context, q provisioning.ContactPointQuery, user identity.Requester) ([]definitions.EmbeddedContactPoint, error) CreateContactPoint(ctx context.Context, orgID int64, user identity.Requester, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) (definitions.EmbeddedContactPoint, error) - UpdateContactPoint(ctx context.Context, orgID int64, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) error + UpdateContactPoint(ctx context.Context, orgID int64, user identity.Requester, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) error DeleteContactPoint(ctx context.Context, orgID int64, uid string) error } @@ -184,7 +184,7 @@ func (srv *ProvisioningSrv) RoutePostContactPoint(c *contextmodel.ReqContext, cp func (srv *ProvisioningSrv) RoutePutContactPoint(c *contextmodel.ReqContext, cp definitions.EmbeddedContactPoint, UID string) response.Response { cp.UID = UID provenance := determineProvenance(c) - err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.GetOrgID(), cp, alerting_models.Provenance(provenance)) + err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.GetOrgID(), c.SignedInUser, cp, alerting_models.Provenance(provenance)) if errors.Is(err, provisioning.ErrValidation) { return ErrResp(http.StatusBadRequest, err, "") }
pkg/services/ngalert/api/api_provisioning_test.go+3 −2 modified@@ -2205,8 +2205,9 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi t.Helper() tracer := tracing.InitializeTracerForTest() configStore := legacy_storage.NewAlertmanagerConfigStore(env.configs, notifier.NewExtraConfigsCrypto(env.secrets)) + receiverAuthz := ac.NewReceiverAccess[*models.Receiver](env.ac, true) receiverSvc := notifier.NewReceiverService( - ac.NewReceiverAccess[*models.Receiver](env.ac, true), + receiverAuthz, configStore, env.prov, env.store, @@ -2219,7 +2220,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi return ProvisioningSrv{ log: env.log, policies: newFakeNotificationPolicyService(), - contactPointService: provisioning.NewContactPointService(configStore, env.secrets, env.prov, env.xact, receiverSvc, env.log, env.store, ngalertfakes.NewFakeReceiverPermissionsService()), + 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), alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.folderService, env.quotas, env.xact, 60, 10, 100, env.log, env.nsValidator, env.rulesAuthz),
pkg/services/ngalert/ngalert.go+3 −2 modified@@ -409,8 +409,9 @@ func (ng *AlertNG) init() error { ng.ResourcePermissions, ng.tracer, ) + provisioningReceiverAuthz := ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true) provisioningReceiverService := notifier.NewReceiverService( - ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true), + provisioningReceiverAuthz, configStore, ng.store, ng.store, @@ -423,7 +424,7 @@ func (ng *AlertNG) init() error { // Provisioning policyService := provisioning.NewNotificationPolicyService(configStore, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log) - contactPointService := provisioning.NewContactPointService(configStore, ng.SecretsService, ng.store, ng.store, provisioningReceiverService, ng.Log, ng.store, ng.ResourcePermissions) + 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) muteTimingService := provisioning.NewMuteTimingService(configStore, ng.store, ng.store, ng.Log, ng.store) alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.folderService, ng.QuotaService, ng.store,
pkg/services/ngalert/notifier/errors.go+43 −1 modified@@ -1,9 +1,51 @@ package notifier -import "github.com/grafana/grafana/pkg/apimachinery/errutil" +import ( + "context" + "errors" + "slices" + + "github.com/grafana/alerting/receivers/schema" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +// ErrUserRequired is returned when a nil user is passed to protected field authorization checks. +var ErrUserRequired = errors.New("user is required to check protected field authorization") + +// ProtectedFieldsAuthz defines the interface for checking protected field authorization. +// This is implemented by receiver access control services. +type ProtectedFieldsAuthz interface { + HasUpdateProtected(ctx context.Context, user identity.Requester, receiver *models.Receiver) (bool, error) + AuthorizeUpdateProtected(ctx context.Context, user identity.Requester, receiver *models.Receiver) error +} // WithPublicError sets the public message of an errutil error to the error message. func WithPublicError(err errutil.Error) error { err.PublicMessage = err.Error() return err } + +// MakeProtectedFieldsAuthzError appends fields that caused the error to public payload. +// If provided error is errutil.Error it adds the changed protected fields to the public payload. +func MakeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { + var authzErr errutil.Error + if !errors.As(err, &authzErr) { + return err + } + if authzErr.PublicPayload == nil { + authzErr.PublicPayload = map[string]interface{}{} + } + fields := make(map[string][]string, len(diff)) + for field, paths := range diff { + fields[field] = make([]string, len(paths)) + for i, path := range paths { + fields[field][i] = path.String() + } + slices.Sort(fields[field]) + } + authzErr.PublicPayload["changed_protected_fields"] = fields + return authzErr +}
pkg/services/ngalert/notifier/receiver_svc_err.go+0 −30 removed@@ -1,30 +0,0 @@ -package notifier - -import ( - "errors" - "slices" - - "github.com/grafana/alerting/receivers/schema" - - "github.com/grafana/grafana/pkg/apimachinery/errutil" -) - -func makeProtectedFieldsAuthzError(err error, diff map[string][]schema.IntegrationFieldPath) error { - var authzErr errutil.Error - if !errors.As(err, &authzErr) { - return err - } - if authzErr.PublicPayload == nil { - authzErr.PublicPayload = map[string]interface{}{} - } - fields := make(map[string][]string, len(diff)) - for field, paths := range diff { - fields[field] = make([]string, len(paths)) - for i, path := range paths { - fields[field][i] = path.String() - } - slices.Sort(fields[field]) - } - authzErr.PublicPayload["changed_protected_fields"] = fields - return authzErr -}
pkg/services/ngalert/notifier/receiver_svc.go+2 −2 modified@@ -451,14 +451,14 @@ func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receive return nil, err } - // if user does not have permissions to update protected, check the diff and return error if there is a change in protected fields + // If user does not have permissions to update protected, check the diff and return error if there is a change in protected fields canUpdateProtected, _ := rs.authz.HasUpdateProtected(ctx, user, r) if !canUpdateProtected { diff := models.HasReceiversDifferentProtectedFields(existing, r) if len(diff) > 0 { err = rs.authz.AuthorizeUpdateProtected(ctx, user, r) if err != nil { - return nil, makeProtectedFieldsAuthzError(err, diff) + return nil, MakeProtectedFieldsAuthzError(err, diff) } } }
pkg/services/ngalert/provisioning/compat.go+26 −0 modified@@ -1,9 +1,11 @@ package provisioning import ( + "fmt" "strings" alertingModels "github.com/grafana/alerting/models" + "github.com/grafana/alerting/receivers/schema" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" @@ -67,3 +69,27 @@ func GrafanaIntegrationConfigToEmbeddedContactPoint(r *models.Integration, prove Provenance: string(provenance), } } + +// EmbeddedContactPointToIntegration converts an EmbeddedContactPoint to a models.Integration. +// This is primarily used for protected field comparison during updates. +// Note: SecureSettings is not populated as the provisioning API doesn't expose encrypted values. +func EmbeddedContactPointToIntegration( + cp definitions.EmbeddedContactPoint, + typeSchema schema.IntegrationSchemaVersion, +) (*models.Integration, error) { + settings := make(map[string]any) + if cp.Settings != nil { + m, err := cp.Settings.Map() + if err != nil { + return nil, fmt.Errorf("failed to parse contact point settings: %w", err) + } + settings = m + } + return &models.Integration{ + UID: cp.UID, + Name: cp.Name, + Config: typeSchema, + DisableResolveMessage: cp.DisableResolveMessage, + Settings: settings, + }, nil +}
pkg/services/ngalert/provisioning/contactpoints.go+65 −1 modified@@ -18,6 +18,7 @@ import ( ac "github.com/grafana/grafana/pkg/services/accesscontrol" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" "github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/services/secrets" @@ -31,6 +32,7 @@ type AlertRuleNotificationSettingsStore interface { } type ContactPointService struct { + authz notifier.ProtectedFieldsAuthz configStore alertmanagerConfigStore encryptionService secrets.Service provenanceStore ProvisioningStore @@ -47,6 +49,7 @@ type receiverService interface { } func NewContactPointService( + authz notifier.ProtectedFieldsAuthz, store alertmanagerConfigStore, encryptionService secrets.Service, provenanceStore ProvisioningStore, @@ -57,6 +60,7 @@ func NewContactPointService( resourcePermissions ac.ReceiverPermissionsService, ) *ContactPointService { return &ContactPointService{ + authz: authz, configStore: store, receiverService: receiverService, encryptionService: encryptionService, @@ -238,7 +242,7 @@ func (ecp *ContactPointService) CreateContactPoint( return contactPoint, nil } -func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) error { +func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID int64, user identity.Requester, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) error { // set all redacted values with the latest known value from the store if contactPoint.Settings == nil { return fmt.Errorf("%w: %s", ErrValidation, "settings should not be empty") @@ -278,6 +282,11 @@ func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID in if storedProvenance != provenance && storedProvenance != models.ProvenanceNone { return fmt.Errorf("cannot change provenance from '%s' to '%s'", storedProvenance, provenance) } + + // Check protected fields authorization + if err := ecp.checkProtectedFields(ctx, user, typeSchema, rawContactPoint, contactPoint); err != nil { + return err + } // transform to internal model extractedSecrets, err := RemoveSecretsForContactPoint(&contactPoint) if err != nil { @@ -441,6 +450,61 @@ func (ecp *ContactPointService) encryptValue(value string) (string, error) { return base64.StdEncoding.EncodeToString(encryptedData), nil } +// checkProtectedFields checks if user has permission to modify protected fields in the contact point. +// It compares the existing contact point with the incoming one and returns an error if protected fields +// are modified without proper authorization. +func (ecp *ContactPointService) checkProtectedFields( + ctx context.Context, + user identity.Requester, + typeSchema schema.IntegrationSchemaVersion, + existing apimodels.EmbeddedContactPoint, + incoming apimodels.EmbeddedContactPoint, +) error { + if user == nil { + return notifier.ErrUserRequired + } + + // Create a receiver wrapper for authorization check. + // Use the existing receiver's name for UID derivation since authorization + // should be checked against the existing resource, not the potentially renamed one. + receiver := &models.Receiver{ + UID: legacy_storage.NameToUid(existing.Name), + Name: existing.Name, + } + + // Check if user has permission to update protected fields first, before doing + // expensive conversion and diff calculations. + canUpdateProtected, _ := ecp.authz.HasUpdateProtected(ctx, user, receiver) + if canUpdateProtected { + return nil + } + + // User doesn't have blanket permission, so we need to check if they're actually + // modifying any protected fields. + existingIntegration, err := EmbeddedContactPointToIntegration(existing, typeSchema) + if err != nil { + return fmt.Errorf("failed to convert existing contact point: %w", err) + } + incomingIntegration, err := EmbeddedContactPointToIntegration(incoming, typeSchema) + if err != nil { + return fmt.Errorf("failed to convert incoming contact point: %w", err) + } + + protectedFieldChanges := models.HasIntegrationsDifferentProtectedFields(existingIntegration, incomingIntegration) + if len(protectedFieldChanges) == 0 { + return nil + } + + // User is modifying protected fields without permission - return authorization error + err = ecp.authz.AuthorizeUpdateProtected(ctx, user, receiver) + if err != nil { + return notifier.MakeProtectedFieldsAuthzError(err, map[string][]schema.IntegrationFieldPath{ + incoming.UID: protectedFieldChanges, + }) + } + return nil +} + // stitchReceiver modifies a receiver, target, in an alertmanager configStore. It modifies the given configStore in-place. // Returns true if the configStore was altered in any way, and false otherwise. // If integration was moved to another group and it was the last in the previous group, the second parameter contains the name of the old group that is gone
pkg/services/ngalert/provisioning/contactpoints_test.go+198 −5 modified@@ -54,6 +54,16 @@ func TestIntegrationContactPointService(t *testing.T) { accesscontrol.ActionAlertingProvisioningReadSecrets: nil, }, }} + // adminUser has all permissions including updating protected fields + adminUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingProvisioningRead: nil, + accesscontrol.ActionAlertingProvisioningReadSecrets: nil, + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingReceiversUpdate: nil, + accesscontrol.ActionAlertingReceiversUpdateProtected: nil, + }, + }} t.Run("service gets contact points from AM config", func(t *testing.T) { sut := createContactPointServiceSut(t, secretsService) @@ -225,7 +235,7 @@ func TestIntegrationContactPointService(t *testing.T) { Provenance: newCp.Provenance, } tc.cp(&cp) - err = sut.UpdateContactPoint(context.Background(), 1, cp, models.ProvenanceAPI) + err = sut.UpdateContactPoint(context.Background(), 1, adminUser, cp, models.ProvenanceAPI) require.ErrorIs(t, err, ErrValidation) }) } @@ -238,7 +248,7 @@ func TestIntegrationContactPointService(t *testing.T) { require.NoError(t, err) newCp.Type = "Slack" - err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + err = sut.UpdateContactPoint(context.Background(), 1, adminUser, newCp, models.ProvenanceAPI) require.NoError(t, err) got, err := sut.GetContactPoints(context.Background(), cpsQueryWithName(1, newCp.Name), redactedUser) @@ -269,7 +279,7 @@ func TestIntegrationContactPointService(t *testing.T) { return nil } - err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + err = sut.UpdateContactPoint(context.Background(), 1, adminUser, newCp, models.ProvenanceAPI) require.NoError(t, err) parsed, err := legacy_storage.DeserializeAlertmanagerConfig([]byte(store.LastSaveCommand.AlertmanagerConfiguration)) @@ -352,7 +362,7 @@ func TestIntegrationContactPointService(t *testing.T) { require.Equal(t, newCp.UID, cps[0].UID) require.Equal(t, test.from, models.Provenance(cps[0].Provenance)) - err = sut.UpdateContactPoint(context.Background(), 1, newCp, test.to) + err = sut.UpdateContactPoint(context.Background(), 1, adminUser, newCp, test.to) if test.errNil { require.NoError(t, err) @@ -490,6 +500,187 @@ func TestIntegrationContactPointServiceDecryptRedact(t *testing.T) { }) } +func TestIntegrationContactPointServiceProtectedFields(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t))) + + // User with basic read/write permissions but without protected field update permission + basicUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingProvisioningRead: nil, + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingReceiversRead: {models.ScopeReceiversAll}, + accesscontrol.ActionAlertingReceiversUpdate: {models.ScopeReceiversAll}, + }, + }} + + // User with all permissions including protected field update + adminUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingProvisioningRead: nil, + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingReceiversRead: {models.ScopeReceiversAll}, + accesscontrol.ActionAlertingReceiversUpdate: {models.ScopeReceiversAll}, + accesscontrol.ActionAlertingReceiversUpdateProtected: {models.ScopeReceiversAll}, + }, + }} + + t.Run("user without protected permission cannot modify protected fields", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + // Create a webhook contact point (webhook has 'url' as a protected field) + settings, _ := simplejson.NewJson([]byte(`{"url":"https://example.com/webhook"}`)) + newCp := definitions.EmbeddedContactPoint{ + Name: "test-webhook", + Type: "webhook", + Settings: settings, + } + + created, err := sut.CreateContactPoint(context.Background(), 1, basicUser, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + // Try to update the protected field 'url' + updatedSettings, _ := simplejson.NewJson([]byte(`{"url":"https://malicious.com/webhook"}`)) + created.Settings = updatedSettings + + err = sut.UpdateContactPoint(context.Background(), 1, basicUser, created, models.ProvenanceAPI) + require.Error(t, err) + require.ErrorContains(t, err, "user is not authorized") + }) + + t.Run("user with protected permission can modify protected fields", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + // Create a webhook contact point + settings, _ := simplejson.NewJson([]byte(`{"url":"https://example.com/webhook"}`)) + newCp := definitions.EmbeddedContactPoint{ + Name: "test-webhook-admin", + Type: "webhook", + Settings: settings, + } + + created, err := sut.CreateContactPoint(context.Background(), 1, adminUser, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + // Update the protected field 'url' with admin user + updatedSettings, _ := simplejson.NewJson([]byte(`{"url":"https://newurl.com/webhook"}`)) + created.Settings = updatedSettings + + err = sut.UpdateContactPoint(context.Background(), 1, adminUser, created, models.ProvenanceAPI) + require.NoError(t, err) + }) + + t.Run("user without protected permission can modify non-protected fields", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + // Create a slack contact point + settings, _ := simplejson.NewJson([]byte(`{"recipient":"#channel","token":"secret-token"}`)) + newCp := definitions.EmbeddedContactPoint{ + Name: "test-slack", + Type: "slack", + Settings: settings, + } + + created, err := sut.CreateContactPoint(context.Background(), 1, basicUser, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + // Update non-protected field 'recipient' - keep token as redacted + updatedSettings, _ := simplejson.NewJson([]byte(`{"recipient":"#new-channel","token":"[REDACTED]"}`)) + created.Settings = updatedSettings + + err = sut.UpdateContactPoint(context.Background(), 1, basicUser, created, models.ProvenanceAPI) + require.NoError(t, err) + }) + + t.Run("update with nil user returns error", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + // Create a webhook contact point + settings, _ := simplejson.NewJson([]byte(`{"url":"https://example.com/webhook"}`)) + newCp := definitions.EmbeddedContactPoint{ + Name: "test-webhook-nil-user", + Type: "webhook", + Settings: settings, + } + + created, err := sut.CreateContactPoint(context.Background(), 1, adminUser, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + // Try to update with nil user (should fail for any protected field change) + updatedSettings, _ := simplejson.NewJson([]byte(`{"url":"https://new-url.com/webhook"}`)) + created.Settings = updatedSettings + + err = sut.UpdateContactPoint(context.Background(), 1, nil, created, models.ProvenanceAPI) + require.Error(t, err) + require.ErrorContains(t, err, "user is required") + }) + + t.Run("user without protected permission cannot rename and modify protected fields", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + // Create a webhook contact point + settings, _ := simplejson.NewJson([]byte(`{"url":"https://example.com/webhook"}`)) + newCp := definitions.EmbeddedContactPoint{ + Name: "test-webhook-rename", + Type: "webhook", + Settings: settings, + } + + created, err := sut.CreateContactPoint(context.Background(), 1, adminUser, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + // Try to rename AND change protected field + created.Name = "renamed-webhook" + updatedSettings, _ := simplejson.NewJson([]byte(`{"url":"https://malicious.com/webhook"}`)) + created.Settings = updatedSettings + + err = sut.UpdateContactPoint(context.Background(), 1, basicUser, created, models.ProvenanceAPI) + require.Error(t, err) + require.ErrorContains(t, err, "user is not authorized") + }) + + t.Run("file provisioner user can modify protected fields", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + // Simulate the file provisioner user with the same permissions as defined in rules_provisioner.go + // This ensures that file provisioning can update protected fields in contact points + fileProvisionerUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingProvisioningReadSecrets: nil, + accesscontrol.ActionAlertingProvisioningWrite: nil, + accesscontrol.ActionAlertingReceiversRead: {models.ScopeReceiversAll}, + accesscontrol.ActionAlertingReceiversUpdate: {models.ScopeReceiversAll}, + accesscontrol.ActionAlertingReceiversUpdateProtected: {models.ScopeReceiversAll}, + }, + }} + + // Create a webhook contact point with file provenance + settings, _ := simplejson.NewJson([]byte(`{"url":"https://example.com/webhook"}`)) + newCp := definitions.EmbeddedContactPoint{ + Name: "test-webhook-file-provisioner", + Type: "webhook", + Settings: settings, + } + + created, err := sut.CreateContactPoint(context.Background(), 1, fileProvisionerUser, newCp, models.ProvenanceFile) + require.NoError(t, err) + + // Update the protected field 'url' - this simulates reprovisioning with a changed webhook URL + updatedSettings, _ := simplejson.NewJson([]byte(`{"url":"https://new-webhook-url.com/webhook"}`)) + created.Settings = updatedSettings + + err = sut.UpdateContactPoint(context.Background(), 1, fileProvisionerUser, created, models.ProvenanceFile) + require.NoError(t, err, "file provisioner user should be able to update protected fields") + + // Verify the change was applied + cps, err := sut.GetContactPoints(context.Background(), ContactPointQuery{OrgID: 1, Name: created.Name}, fileProvisionerUser) + require.NoError(t, err) + require.Len(t, cps, 1) + // The URL should be redacted in the response, but we verified the update succeeded + }) +} + func TestRemoveSecretsForContactPoint(t *testing.T) { overrides := map[schema.IntegrationType]func(settings map[string]any){ "webhook": func(settings map[string]any) { // add additional field to the settings because valid config does not allow it to be specified along with password @@ -560,8 +751,9 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec xact := newNopTransactionManager() provisioningStore := fakes.NewFakeProvisioningStore() + receiverAuthz := ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true) receiverService := notifier.NewReceiverService( - ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true), + receiverAuthz, legacy_storage.NewAlertmanagerConfigStore(configStore, notifier.NewExtraConfigsCrypto(secretService)), provisioningStore, &fakeAlertRuleNotificationStore{}, @@ -573,6 +765,7 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec ) return NewContactPointService( + receiverAuthz, legacy_storage.NewAlertmanagerConfigStore(configStore, notifier.NewExtraConfigsCrypto(secretService)), secretService, provisioningStore,
pkg/services/provisioning/alerting/contact_point_provisioner.go+1 −1 modified@@ -48,7 +48,7 @@ func (c *defaultContactPointProvisioner) Provision(ctx context.Context, for _, fetchedCP := range cpsCache[contactPointsConfig.OrgID] { if fetchedCP.UID == contactPoint.UID { err := c.contactPointService.UpdateContactPoint(ctx, - contactPointsConfig.OrgID, contactPoint, models.ProvenanceFile) + contactPointsConfig.OrgID, provisionerUser(contactPointsConfig.OrgID), contactPoint, models.ProvenanceFile) if err != nil { return err }
pkg/services/provisioning/alerting/rules_provisioner.go+4 −0 modified@@ -168,6 +168,10 @@ var provisionerUser = func(orgID int64) identity.Requester { {Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll}, {Action: accesscontrol.ActionAlertingProvisioningReadSecrets, Scope: dashboards.ScopeFoldersAll}, {Action: accesscontrol.ActionAlertingProvisioningWrite, Scope: dashboards.ScopeFoldersAll}, + // Required for updating protected fields in contact points + {Action: accesscontrol.ActionAlertingReceiversRead, Scope: alert_models.ScopeReceiversAll}, + {Action: accesscontrol.ActionAlertingReceiversUpdate, Scope: alert_models.ScopeReceiversAll}, + {Action: accesscontrol.ActionAlertingReceiversUpdateProtected, Scope: alert_models.ScopeReceiversAll}, }, ) }
pkg/services/provisioning/provisioning.go+3 −2 modified@@ -322,8 +322,9 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error alertingauthz.NewRuleService(ps.ac), ) configStore := legacy_storage.NewAlertmanagerConfigStore(ps.alertingStore, notifier.NewExtraConfigsCrypto(ps.secretService)) + receiverAuthz := alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true) receiverSvc := notifier.NewReceiverService( - alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true), + receiverAuthz, configStore, ps.alertingStore, ps.alertingStore, @@ -333,7 +334,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error ps.resourcePermissions, ps.tracer, ) - contactPointService := provisioning.NewContactPointService(configStore, ps.secretService, + 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)
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-7g92-g4vh-hp84ghsaADVISORY
- grafana.com/security/security-advisories/cve-2026-21724nvdVendor AdvisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-21724ghsaADVISORY
- github.com/grafana/grafana/commit/daffe750de85b0dbf79f206a35835cf66a83d6caghsaWEB
- github.com/grafana/grafana/releases/tag/v12.3.6ghsaWEB
News mentions
0No linked articles in our index yet.