Alerting: Receiver API complete core implementation (#91738)

* Replace global authz abstraction with one compatible with uid scope

* Replace GettableApiReceiver with models.Receiver in receiver_svc

* GrafanaIntegrationConfig -> models.Integration

* Implement Create/Update methods

* Add optimistic concurrency to receiver API

* Add scope to ReceiversRead & ReceiversReadSecrets

migrates existing permissions to include implicit global scope

* Add receiver create, update, delete actions

* Check if receiver is used by rules before delete

* On receiver name change update in routes and notification settings

* Improve errors

* Linting

* Include read permissions are requirements for create/update/delete

* Alias ngalert/models to ngmodels to differentiate from v0alpha1 model

* Ensure integration UIDs are valid, unique, and generated if empty

* Validate integration settings on create/update

* Leverage UidToName to GetReceiver instead of GetReceivers

* Remove some unnecessary uses of simplejson

* alerting.notifications.receiver -> alerting.notifications.receivers

* validator -> provenanceValidator

* Only validate the modified receiver

stops existing invalid receivers from preventing modification of a valid
receiver.

* Improve error in Integration.Encrypt

* Remove scope from alert.notifications.receivers:create

* Add todos for receiver renaming

* Use receiverAC precondition checks in k8s api

* Linting

* Optional optimistic concurrency for delete

* make update-workspace

* More specific auth checks in k8s authorize.go

* Add debug log when delete optimistic concurrency is skipped

* Improve error message on authorizer.DecisionDeny

* Keep error for non-forbidden errutil errors
This commit is contained in:
Matthew Jacobson 2024-08-26 10:47:53 -04:00 committed by GitHub
parent 22ad1cc16f
commit 32f06c6d9c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 3585 additions and 550 deletions

View File

@ -2,14 +2,26 @@ package receiver
import (
"context"
"errors"
"fmt"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
)
func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
// AccessControlService provides access control for receivers.
type AccessControlService interface {
AuthorizeReadSome(ctx context.Context, user identity.Requester) error
AuthorizeReadByUID(context.Context, identity.Requester, string) error
AuthorizeCreate(context.Context, identity.Requester) error
AuthorizeUpdateByUID(context.Context, identity.Requester, string) error
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
}
func Authorize(ctx context.Context, ac AccessControlService, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
if attr.GetResource() != resourceInfo.GroupResource().Resource {
return authorizer.DecisionNoOpinion, "", nil
}
@ -18,36 +30,55 @@ func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authori
return authorizer.DecisionDeny, "valid user is required", err
}
var action accesscontrol.Evaluator
switch attr.GetVerb() {
case "patch":
fallthrough
case "create":
fallthrough // TODO: Add alert.notifications.receivers:create permission
case "update":
action = accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:write permission
)
case "deletecollection":
fallthrough
case "delete":
action = accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:delete permission
)
uid := attr.GetName()
deny := func(err error) (authorizer.Decision, string, error) {
var utilErr errutil.Error
if errors.As(err, &utilErr) && utilErr.Reason.Status() == errutil.StatusForbidden {
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
return authorizer.DecisionDeny, fmt.Sprintf("required permissions: %s", utilErr.PublicPayload["permissions"]), nil
}
return authorizer.DecisionDeny, utilErr.PublicMessage, nil
}
eval := accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversRead),
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversReadSecrets),
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead),
)
if action != nil {
eval = accesscontrol.EvalAll(eval, action)
}
ok, err := ac.Evaluate(ctx, user, eval)
if ok {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "", err
}
switch attr.GetVerb() {
case "get":
if uid == "" {
return authorizer.DecisionDeny, "", nil
}
if err := ac.AuthorizeReadByUID(ctx, user, uid); err != nil {
return deny(err)
}
case "list":
if err := ac.AuthorizeReadSome(ctx, user); err != nil { // Preconditions, further checks are done downstream.
return deny(err)
}
case "create":
if err := ac.AuthorizeCreate(ctx, user); err != nil {
return deny(err)
}
case "patch":
fallthrough
case "update":
if uid == "" {
return deny(err)
}
if err := ac.AuthorizeUpdateByUID(ctx, user, uid); err != nil {
return deny(err)
}
case "delete":
if uid == "" {
return deny(err)
}
if err := ac.AuthorizeDeleteByUID(ctx, user, uid); err != nil {
return deny(err)
}
default:
return authorizer.DecisionNoOpinion, "", nil
}
return authorizer.DecisionAllow, "", nil
}

View File

@ -1,26 +1,19 @@
package receiver
import (
"encoding/json"
"fmt"
"maps"
"github.com/prometheus/alertmanager/config"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
)
func getUID(t definitions.GettableApiReceiver) string {
return legacy_storage.NameToUid(t.Name)
}
func convertToK8sResources(orgID int64, receivers []definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) {
func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) {
result := &model.ReceiverList{
Items: make([]model.Receiver, 0, len(receivers)),
}
@ -34,67 +27,54 @@ func convertToK8sResources(orgID int64, receivers []definitions.GettableApiRecei
return result, nil
}
func convertToK8sResource(orgID int64, receiver definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.Receiver, error) {
func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.Receiver, error) {
spec := model.ReceiverSpec{
Title: receiver.Receiver.Name,
}
provenance := definitions.Provenance(models.ProvenanceNone)
for _, integration := range receiver.GrafanaManagedReceivers {
if integration.Provenance != receiver.GrafanaManagedReceivers[0].Provenance {
return nil, fmt.Errorf("all integrations must have the same provenance")
}
provenance = integration.Provenance
unstruct := common.Unstructured{}
err := json.Unmarshal(integration.Settings, &unstruct)
if err != nil {
return nil, fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", integration.Type, receiver.Name, err)
Title: receiver.Name,
}
for _, integration := range receiver.Integrations {
spec.Integrations = append(spec.Integrations, model.Integration{
Uid: &integration.UID,
Type: integration.Type,
Type: integration.Config.Type,
DisableResolveMessage: &integration.DisableResolveMessage,
Settings: unstruct,
SecureFields: integration.SecureFields,
Settings: common.Unstructured{Object: maps.Clone(integration.Settings)},
SecureFields: integration.SecureFields(),
})
}
uid := getUID(receiver) // TODO replace to stable UID when we switch to normal storage
r := &model.Receiver{
TypeMeta: resourceInfo.TypeMeta(),
ObjectMeta: metav1.ObjectMeta{
UID: types.UID(uid), // This is needed to make PATCH work
Name: uid, // TODO replace to stable UID when we switch to normal storage
UID: types.UID(receiver.GetUID()), // This is needed to make PATCH work
Name: receiver.GetUID(),
Namespace: namespacer(orgID),
ResourceVersion: "", // TODO: Implement optimistic concurrency.
ResourceVersion: receiver.Version,
},
Spec: spec,
}
r.SetProvenanceStatus(string(provenance))
r.SetProvenanceStatus(string(receiver.Provenance))
return r, nil
}
func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) {
// TODO: Using GettableApiReceiver instead of PostableApiReceiver so that SecureFields type matches.
gettable := definitions.GettableApiReceiver{
Receiver: config.Receiver{
func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) {
domain := &ngmodels.Receiver{
UID: legacy_storage.NameToUid(receiver.Spec.Title),
Name: receiver.Spec.Title,
},
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{},
},
Integrations: make([]*ngmodels.Integration, 0, len(receiver.Spec.Integrations)),
Version: receiver.ResourceVersion,
Provenance: ngmodels.ProvenanceNone,
}
storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations))
for _, integration := range receiver.Spec.Integrations {
data, err := integration.Settings.MarshalJSON()
config, err := ngmodels.IntegrationConfigFromType(integration.Type)
if err != nil {
return definitions.GettableApiReceiver{}, fmt.Errorf("integration '%s' of receiver '%s' is invalid: failed to convert unstructured data to bytes: %w", integration.Type, receiver.Name, err)
return nil, nil, err
}
grafanaIntegration := definitions.GettableGrafanaReceiver{
grafanaIntegration := ngmodels.Integration{
Name: receiver.Spec.Title,
Type: integration.Type,
Settings: definitions.RawMessage(data),
SecureFields: integration.SecureFields,
Provenance: definitions.Provenance(models.ProvenanceNone),
Config: config,
Settings: maps.Clone(integration.Settings.UnstructuredContent()),
SecureSettings: make(map[string]string),
}
if integration.Uid != nil {
grafanaIntegration.UID = *integration.Uid
@ -102,8 +82,20 @@ func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiRece
if integration.DisableResolveMessage != nil {
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
}
gettable.GettableGrafanaReceivers.GrafanaManagedReceivers = append(gettable.GettableGrafanaReceivers.GrafanaManagedReceivers, &grafanaIntegration)
domain.Integrations = append(domain.Integrations, &grafanaIntegration)
if grafanaIntegration.UID != "" {
// This is an existing integration, so we track the secure fields being requested to copy over from existing values.
secureFields := make([]string, 0, len(integration.SecureFields))
for k, isSecure := range integration.SecureFields {
if isSecure {
secureFields = append(secureFields, k)
}
}
storedSecureFields[grafanaIntegration.UID] = secureFields
}
}
return gettable, nil
return domain, storedSecureFields, nil
}

View File

@ -15,7 +15,8 @@ import (
grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
)
var (
@ -25,11 +26,11 @@ var (
var resourceInfo = notifications.ReceiverResourceInfo
type ReceiverService interface {
GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error)
GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error)
CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
UpdateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
DeleteReceiver(ctx context.Context, name string, orgID int64, provenance definitions.Provenance, version string) error
GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error)
GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error)
CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
DeleteReceiver(ctx context.Context, name string, provenance definitions.Provenance, version string, orgID int64, user identity.Requester) error
}
type legacyStorage struct {
@ -66,12 +67,12 @@ func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions
return nil, err
}
q := models.GetReceiversQuery{
q := ngmodels.GetReceiversQuery{
OrgID: orgId,
Decrypt: false,
//Names: ctx.QueryStrings("names"), // TODO: Query params.
//Limit: ctx.QueryInt("limit"),
//Offset: ctx.QueryInt("offset"),
//Decrypt: ctx.QueryBool("decrypt"),
}
user, err := identity.GetRequester(ctx)
@ -93,9 +94,14 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
return nil, err
}
q := models.GetReceiversQuery{
name, err := legacy_storage.UidToName(uid)
if err != nil {
return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid)
}
q := ngmodels.GetReceiverQuery{
OrgID: info.OrgID,
//Decrypt: ctx.QueryBool("decrypt"), // TODO: Query params.
Name: name,
Decrypt: false,
}
user, err := identity.GetRequester(ctx)
@ -103,19 +109,12 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
return nil, err
}
res, err := s.service.GetReceivers(ctx, q, user)
r, err := s.service.GetReceiver(ctx, q, user)
if err != nil {
return nil, err
}
for _, r := range res {
if getUID(r) == uid {
return convertToK8sResource(info.OrgID, r, s.namespacer)
}
}
return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid)
}
func (s *legacyStorage) Create(ctx context.Context,
obj runtime.Object,
@ -138,11 +137,17 @@ func (s *legacyStorage) Create(ctx context.Context,
if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user
return nil, errors.NewBadRequest("object's metadata.name should be empty")
}
model, err := convertToDomainModel(p)
model, _, err := convertToDomainModel(p)
if err != nil {
return nil, err
}
out, err := s.service.CreateReceiver(ctx, model, info.OrgID)
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
out, err := s.service.CreateReceiver(ctx, model, info.OrgID, user)
if err != nil {
return nil, err
}
@ -162,6 +167,11 @@ func (s *legacyStorage) Update(ctx context.Context,
return nil, false, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, false, err
}
old, err := s.Get(ctx, uid, nil)
if err != nil {
return old, false, err
@ -179,16 +189,16 @@ func (s *legacyStorage) Update(ctx context.Context,
if !ok {
return nil, false, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
}
model, err := convertToDomainModel(p)
model, storedSecureFields, err := convertToDomainModel(p)
if err != nil {
return old, false, err
}
if p.ObjectMeta.Name != getUID(model) {
if p.ObjectMeta.Name != model.GetUID() {
return nil, false, errors.NewBadRequest("title cannot be changed. Consider creating a new resource.")
}
updated, err := s.service.UpdateReceiver(ctx, model, info.OrgID)
updated, err := s.service.UpdateReceiver(ctx, model, storedSecureFields, info.OrgID, user)
if err != nil {
return nil, false, err
}
@ -203,6 +213,12 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
if err != nil {
return nil, false, err
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, false, err
}
old, err := s.Get(ctx, uid, nil)
if err != nil {
return old, false, err
@ -217,7 +233,7 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
version = *options.Preconditions.ResourceVersion
}
err = s.service.DeleteReceiver(ctx, uid, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
err = s.service.DeleteReceiver(ctx, uid, definitions.Provenance(ngmodels.ProvenanceNone), version, info.OrgID, user) // TODO add support for dry-run option
return old, false, err // false - will be deleted async
}

View File

@ -24,6 +24,8 @@ import (
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/ngalert"
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/setting"
)
@ -32,6 +34,7 @@ var _ builder.APIGroupBuilder = (*NotificationsAPIBuilder)(nil)
// This is used just so wire has something unique to return
type NotificationsAPIBuilder struct {
authz accesscontrol.AccessControl
receiverAuth receiver.AccessControlService
ng *ngalert.AlertNG
namespacer request.NamespaceMapper
gv schema.GroupVersion
@ -51,6 +54,7 @@ func RegisterAPIService(
namespacer: request.GetNamespaceMapper(cfg),
gv: notificationsModels.SchemeGroupVersion,
authz: ng.Api.AccessControl,
receiverAuth: ac.NewReceiverAccess[*ngmodels.Receiver](ng.Api.AccessControl, false),
}
apiregistration.RegisterAPI(builder)
return builder
@ -128,7 +132,7 @@ func (t *NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource:
return timeInterval.Authorize(ctx, t.authz, a)
case notificationsModels.ReceiverResourceInfo.GroupResource().Resource:
return receiver.Authorize(ctx, t.authz, a)
return receiver.Authorize(ctx, t.receiverAuth, a)
}
return authorizer.DecisionNoOpinion, "", nil
})

View File

@ -10,7 +10,7 @@ import (
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
)
func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TimeIntervalList, error) {
@ -78,7 +78,7 @@ func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInt
}
result.Version = interval.ResourceVersion
result.UID = interval.ObjectMeta.Name
result.Provenance = definitions.Provenance(models.ProvenanceNone)
result.Provenance = definitions.Provenance(ngmodels.ProvenanceNone)
err = result.Validate()
if err != nil {
return definitions.MuteTimeInterval{}, err

View File

@ -14,7 +14,7 @@ import (
grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
)
var (
@ -195,7 +195,7 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
return nil, false, fmt.Errorf("expected time-interval but got %s", old.GetObjectKind().GroupVersionKind())
}
err = s.service.DeleteMuteTiming(ctx, p.ObjectMeta.Name, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
err = s.service.DeleteMuteTiming(ctx, p.ObjectMeta.Name, info.OrgID, definitions.Provenance(ngmodels.ProvenanceNone), version) // TODO add support for dry-run option
return old, false, err // false - will be deleted async
}

View File

@ -447,6 +447,9 @@ const (
ActionAlertingReceiversList = "alert.notifications.receivers:list"
ActionAlertingReceiversRead = "alert.notifications.receivers:read"
ActionAlertingReceiversReadSecrets = "alert.notifications.receivers.secrets:read"
ActionAlertingReceiversCreate = "alert.notifications.receivers:create"
ActionAlertingReceiversUpdate = "alert.notifications.receivers:write"
ActionAlertingReceiversDelete = "alert.notifications.receivers:delete"
// External alerting rule actions. We can only narrow it down to writes or reads, as we don't control the atomicity in the external system.
ActionAlertingRuleExternalWrite = "alert.rules.external:write"

View File

@ -4,6 +4,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/org"
)
@ -133,6 +134,7 @@ var (
},
{
Action: accesscontrol.ActionAlertingReceiversRead,
Scope: ac.ScopeReceiversAll,
},
},
},
@ -152,6 +154,18 @@ var (
Action: accesscontrol.ActionAlertingNotificationsExternalWrite,
Scope: datasources.ScopeAll,
},
{
Action: accesscontrol.ActionAlertingReceiversCreate,
Scope: ac.ScopeReceiversAll,
},
{
Action: accesscontrol.ActionAlertingReceiversUpdate,
Scope: ac.ScopeReceiversAll,
},
{
Action: accesscontrol.ActionAlertingReceiversDelete,
Scope: ac.ScopeReceiversAll,
},
}),
},
}

View File

@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
var (
@ -30,7 +31,7 @@ func NewAuthorizationErrorGeneric(action string) error {
}
// actionAccess is a helper struct that provides common access control methods for a specific resource type and action.
type actionAccess[T any] struct {
type actionAccess[T models.Identified] struct {
genericService
// authorizeSome evaluates to true if user has access to some (any) resources.
@ -41,7 +42,7 @@ type actionAccess[T any] struct {
authorizeAll ac.Evaluator
// authorizeOne returns an evaluator that checks if user has access to a specific resource.
authorizeOne func(T) ac.Evaluator
authorizeOne func(models.Identified) ac.Evaluator
// action is the action that user is trying to perform on the resource. Used in error messages.
action string
@ -53,7 +54,10 @@ type actionAccess[T any] struct {
// Filter filters the given list of resources based on access control permissions of the user.
// This method is preferred when many resources need to be checked.
func (s actionAccess[T]) Filter(ctx context.Context, user identity.Requester, resources ...T) ([]T, error) {
canAll, err := s.authorizePreConditions(ctx, user)
if err := s.AuthorizePreConditions(ctx, user); err != nil {
return nil, err
}
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
if err != nil {
return nil, err
}
@ -70,8 +74,11 @@ func (s actionAccess[T]) Filter(ctx context.Context, user identity.Requester, re
}
// Authorize checks if user has access to a resource. Returns an error if user does not have access.
func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester, resource T) error {
canAll, err := s.authorizePreConditions(ctx, user)
func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester, resource models.Identified) error {
if err := s.AuthorizePreConditions(ctx, user); err != nil {
return err
}
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
if canAll || err != nil { // Return early if user can either access all or there is an error.
return err
}
@ -80,8 +87,11 @@ func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester,
}
// Has checks if user has access to a resource. Returns false if user does not have access.
func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resource T) (bool, error) {
canAll, err := s.authorizePreConditions(ctx, user)
func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resource models.Identified) (bool, error) {
if err := s.AuthorizePreConditions(ctx, user); err != nil {
return false, err
}
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
if canAll || err != nil { // Return early if user can either access all or there is an error.
return canAll, err
}
@ -89,32 +99,28 @@ func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resou
return s.has(ctx, user, resource)
}
// authorizePreConditions checks necessary preconditions for resources. Returns true if user has access for all
// resources. Returns error if user does not have access to on any resources.
func (s actionAccess[T]) authorizePreConditions(ctx context.Context, user identity.Requester) (bool, error) {
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
if canAll || err != nil { // Return early if user can either access all or there is an error.
return canAll, err
// AuthorizeAll checks if user has access to all resources. Returns error if user does not have access to all resources.
func (s actionAccess[T]) AuthorizeAll(ctx context.Context, user identity.Requester) error {
return s.HasAccessOrError(ctx, user, s.authorizeAll, func() string {
return fmt.Sprintf("%s all %ss", s.action, s.resource)
})
}
can, err := s.HasAccess(ctx, user, s.authorizeSome)
if err != nil {
return false, err
}
if !can { // User does not have any resource permissions at all.
return false, NewAuthorizationErrorWithPermissions(fmt.Sprintf("%s any %s", s.action, s.resource), s.authorizeSome)
}
return false, nil
// AuthorizePreConditions checks necessary preconditions for resources. Returns error if user does not have access to any resources.
func (s actionAccess[T]) AuthorizePreConditions(ctx context.Context, user identity.Requester) error {
return s.HasAccessOrError(ctx, user, s.authorizeSome, func() string {
return fmt.Sprintf("%s any %s", s.action, s.resource)
})
}
// authorize checks if user has access to a specific resource given precondition checks have already passed. Returns an error if user does not have access.
func (s actionAccess[T]) authorize(ctx context.Context, user identity.Requester, resource T) error {
func (s actionAccess[T]) authorize(ctx context.Context, user identity.Requester, resource models.Identified) error {
return s.HasAccessOrError(ctx, user, s.authorizeOne(resource), func() string {
return fmt.Sprintf("%s %s", s.action, s.resource)
})
}
// has checks if user has access to a specific resource given precondition checks have already passed. Returns false if user does not have access.
func (s actionAccess[T]) has(ctx context.Context, user identity.Requester, resource T) (bool, error) {
func (s actionAccess[T]) has(ctx context.Context, user identity.Requester, resource models.Identified) (bool, error) {
return s.HasAccess(ctx, user, s.authorizeOne(resource))
}

View File

@ -2,13 +2,21 @@ package accesscontrol
import (
"context"
"fmt"
"github.com/grafana/grafana/pkg/apimachinery/identity"
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
const (
ScopeReceiversRoot = "receivers"
)
var (
ScopeReceiversProvider = ac.NewScopeProvider(ScopeReceiversRoot)
ScopeReceiversAll = ScopeReceiversProvider.GetResourceAllScope()
)
var (
// Asserts pre-conditions for read access to redacted receivers. If this evaluates to false, the user cannot read any redacted receivers.
readRedactedReceiversPreConditionsEval = ac.EvalAny(
@ -24,23 +32,19 @@ var (
// Asserts read-only access to all redacted receivers.
readRedactedAllReceiversEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
// TODO: The following should be scoped, but are currently interpreted as global. Needs a db migration when scope is added.
ac.EvalPermission(ac.ActionAlertingReceiversRead), // TODO: Add global scope with fgac.
ac.EvalPermission(ac.ActionAlertingReceiversRead, ScopeReceiversAll),
readDecryptedAllReceiversEval,
)
// Asserts read-only access to all decrypted receivers.
readDecryptedAllReceiversEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), // TODO: Add global scope with fgac.
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets, ScopeReceiversAll),
)
// Asserts read-only access to a specific redacted receiver.
readRedactedReceiverEval = func(uid string) ac.Evaluator {
return ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
// TODO: The following should be scoped, but are currently interpreted as global. Needs a db migration when scope is added.
ac.EvalPermission(ac.ActionAlertingReceiversRead), // TODO: Add uid scope with fgac.
ac.EvalPermission(ac.ActionAlertingReceiversRead, ScopeReceiversProvider.GetResourceScopeUID(uid)),
readDecryptedReceiverEval(uid),
)
}
@ -48,7 +52,7 @@ var (
// Asserts read-only access to a specific decrypted receiver.
readDecryptedReceiverEval = func(uid string) ac.Evaluator {
return ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), // TODO: Add uid scope with fgac.
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets, ScopeReceiversProvider.GetResourceScopeUID(uid)),
)
}
@ -68,16 +72,95 @@ var (
provisioningExtraReadDecryptedPermissions = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets), // Global provisioning action for all AM config + secrets. Org scope.
)
// Create
// Asserts pre-conditions for create access to receivers. If this evaluates to false, the user cannot create any receivers.
// Create has no scope, so these permissions are both necessary and sufficient to create any and all receivers.
createReceiversEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite), // Global action for all AM config. Org scope.
ac.EvalPermission(ac.ActionAlertingReceiversCreate), // Action for receivers. Org scope.
)
// Update
// Asserts pre-conditions for update access to receivers. If this evaluates to false, the user cannot update any receivers.
updateReceiversPreConditionsEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite), // Global action for all AM config. Org scope.
ac.EvalPermission(ac.ActionAlertingReceiversUpdate), // Action for receivers. UID scope.
)
// Asserts update access to all receivers.
updateAllReceiversEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
ac.EvalPermission(ac.ActionAlertingReceiversUpdate, ScopeReceiversAll),
)
// Asserts update access to a specific receiver.
updateReceiverEval = func(uid string) ac.Evaluator {
return ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
ac.EvalPermission(ac.ActionAlertingReceiversUpdate, ScopeReceiversProvider.GetResourceScopeUID(uid)),
)
}
// Delete
// Asserts pre-conditions for delete access to receivers. If this evaluates to false, the user cannot delete any receivers.
deleteReceiversPreConditionsEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite), // Global action for all AM config. Org scope.
ac.EvalPermission(ac.ActionAlertingReceiversDelete), // Action for receivers. UID scope.
)
// Asserts delete access to all receivers.
deleteAllReceiversEval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
ac.EvalPermission(ac.ActionAlertingReceiversDelete, ScopeReceiversAll),
)
// Asserts delete access to a specific receiver.
deleteReceiverEval = func(uid string) ac.Evaluator {
return ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
ac.EvalPermission(ac.ActionAlertingReceiversDelete, ScopeReceiversProvider.GetResourceScopeUID(uid)),
)
}
)
type ReceiverAccess[T models.Identified] struct {
read actionAccess[T]
readDecrypted actionAccess[T]
create actionAccess[T]
update actionAccess[T]
delete actionAccess[models.Identified]
}
// NewReceiverAccess creates a new ReceiverAccess service. If includeProvisioningActions is true, the service will include
// permissions specific to the provisioning API.
func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvisioningActions bool) *ReceiverAccess[T] {
// If this service is meant for the provisioning API, we include the provisioning actions as possible permissions.
// TODO: Improve this monkey patching.
readRedactedReceiversPreConditionsEval := readRedactedReceiversPreConditionsEval
readDecryptedReceiversPreConditionsEval := readDecryptedReceiversPreConditionsEval
readRedactedReceiverEval := readRedactedReceiverEval
readDecryptedReceiverEval := readDecryptedReceiverEval
readRedactedAllReceiversEval := readRedactedAllReceiversEval
readDecryptedAllReceiversEval := readDecryptedAllReceiversEval
if includeProvisioningActions {
readRedactedReceiversPreConditionsEval = ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedReceiversPreConditionsEval)
readDecryptedReceiversPreConditionsEval = ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedReceiversPreConditionsEval)
readRedactedReceiverEval = func(uid string) ac.Evaluator {
return ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedReceiverEval(uid))
}
readDecryptedReceiverEval = func(uid string) ac.Evaluator {
return ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedReceiverEval(uid))
}
readRedactedAllReceiversEval = ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedAllReceiversEval)
readDecryptedAllReceiversEval = ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedAllReceiversEval)
}
rcvAccess := &ReceiverAccess[T]{
read: actionAccess[T]{
genericService: genericService{
@ -86,7 +169,7 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision
resource: "receiver",
action: "read",
authorizeSome: readRedactedReceiversPreConditionsEval,
authorizeOne: func(receiver T) ac.Evaluator {
authorizeOne: func(receiver models.Identified) ac.Evaluator {
return readRedactedReceiverEval(receiver.GetUID())
},
authorizeAll: readRedactedAllReceiversEval,
@ -98,27 +181,47 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision
resource: "decrypted receiver",
action: "read",
authorizeSome: readDecryptedReceiversPreConditionsEval,
authorizeOne: func(receiver T) ac.Evaluator {
authorizeOne: func(receiver models.Identified) ac.Evaluator {
return readDecryptedReceiverEval(receiver.GetUID())
},
authorizeAll: readDecryptedAllReceiversEval,
},
}
// If this service is meant for the provisioning API, we include the provisioning actions as possible permissions.
if includeProvisioningActions {
rcvAccess.read.authorizeSome = ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeSome)
rcvAccess.readDecrypted.authorizeSome = ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeSome)
rcvAccess.read.authorizeOne = func(receiver T) ac.Evaluator {
return ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeOne(receiver))
}
rcvAccess.readDecrypted.authorizeOne = func(receiver T) ac.Evaluator {
return ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeOne(receiver))
}
rcvAccess.read.authorizeAll = ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeAll)
rcvAccess.readDecrypted.authorizeAll = ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeAll)
create: actionAccess[T]{
genericService: genericService{
ac: a,
},
resource: "receiver",
action: "create",
authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval),
authorizeOne: func(receiver models.Identified) ac.Evaluator {
return ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval)
},
authorizeAll: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval),
},
update: actionAccess[T]{
genericService: genericService{
ac: a,
},
resource: "receiver",
action: "update",
authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, updateReceiversPreConditionsEval),
authorizeOne: func(receiver models.Identified) ac.Evaluator {
return ac.EvalAll(readRedactedReceiverEval(receiver.GetUID()), updateReceiverEval(receiver.GetUID()))
},
authorizeAll: ac.EvalAll(readRedactedAllReceiversEval, updateAllReceiversEval),
},
delete: actionAccess[models.Identified]{
genericService: genericService{
ac: a,
},
resource: "receiver",
action: "delete",
authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, deleteReceiversPreConditionsEval),
authorizeOne: func(receiver models.Identified) ac.Evaluator {
return ac.EvalAll(readRedactedReceiverEval(receiver.GetUID()), deleteReceiverEval(receiver.GetUID()))
},
authorizeAll: ac.EvalAll(readRedactedAllReceiversEval, deleteAllReceiversEval),
},
}
return rcvAccess
@ -145,11 +248,6 @@ func (s ReceiverAccess[T]) HasRead(ctx context.Context, user identity.Requester,
return s.read.Has(ctx, user, receiver)
}
// HasReadAll checks if user has access to read all redacted receivers. Returns false if user does not have access.
func (s ReceiverAccess[T]) HasReadAll(ctx context.Context, user identity.Requester) (bool, error) { // TODO: Temporary for legacy compatibility.
return s.read.HasAccess(ctx, user, s.read.authorizeAll)
}
// FilterReadDecrypted filters the given list of receivers based on the read decrypted access control permissions of the user.
// This method is preferred when many receivers need to be checked.
func (s ReceiverAccess[T]) FilterReadDecrypted(ctx context.Context, user identity.Requester, receivers ...T) ([]T, error) {
@ -166,9 +264,46 @@ func (s ReceiverAccess[T]) HasReadDecrypted(ctx context.Context, user identity.R
return s.readDecrypted.Has(ctx, user, receiver)
}
// AuthorizeReadDecryptedAll checks if user has access to read all decrypted receiver. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeReadDecryptedAll(ctx context.Context, user identity.Requester) error { // TODO: Temporary for legacy compatibility.
return s.readDecrypted.HasAccessOrError(ctx, user, s.readDecrypted.authorizeAll, func() string {
return fmt.Sprintf("%s %s", s.readDecrypted.action, s.readDecrypted.resource)
})
// AuthorizeUpdate checks if user has access to update a receiver. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeUpdate(ctx context.Context, user identity.Requester, receiver T) error {
return s.update.Authorize(ctx, user, receiver)
}
// Global
// AuthorizeCreate checks if user has access to create receivers. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeCreate(ctx context.Context, user identity.Requester) error {
return s.create.AuthorizeAll(ctx, user)
}
// By UID
type identified struct {
uid string
}
func (i identified) GetUID() string {
return i.uid
}
// AuthorizeDeleteByUID checks if user has access to delete a receiver by uid. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeDeleteByUID(ctx context.Context, user identity.Requester, uid string) error {
return s.delete.Authorize(ctx, user, identified{uid: uid})
}
// AuthorizeReadByUID checks if user has access to read a redacted receiver by uid. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeReadByUID(ctx context.Context, user identity.Requester, uid string) error {
return s.read.Authorize(ctx, user, identified{uid: uid})
}
// AuthorizeUpdateByUID checks if user has access to update a receiver by uid. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeUpdateByUID(ctx context.Context, user identity.Requester, uid string) error {
return s.update.Authorize(ctx, user, identified{uid: uid})
}
// Preconditions
// AuthorizeReadSome checks if user has access to read some redacted receivers. Returns an error if user does not have access.
func (s ReceiverAccess[T]) AuthorizeReadSome(ctx context.Context, user identity.Requester) error {
return s.read.AuthorizePreConditions(ctx, user)
}

View File

@ -8,7 +8,6 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
@ -19,8 +18,8 @@ type NotificationSrv struct {
}
type ReceiverService interface {
GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error)
ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error)
GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error)
ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]*models.Receiver, error)
}
func (srv *NotificationSrv) RouteGetTimeInterval(c *contextmodel.ReqContext, name string) response.Response {
@ -51,7 +50,12 @@ func (srv *NotificationSrv) RouteGetReceiver(c *contextmodel.ReqContext, name st
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get receiver", err)
}
return response.JSON(http.StatusOK, receiver)
gettable, err := GettableApiReceiverFromReceiver(receiver)
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to convert receiver", err)
}
return response.JSON(http.StatusOK, gettable)
}
func (srv *NotificationSrv) RouteGetReceivers(c *contextmodel.ReqContext) response.Response {
@ -67,5 +71,10 @@ func (srv *NotificationSrv) RouteGetReceivers(c *contextmodel.ReqContext) respon
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get receiver groups", err)
}
return response.JSON(http.StatusOK, receivers)
gettables, err := GettableApiReceiversFromReceivers(receivers)
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to convert receivers", err)
}
return response.JSON(http.StatusOK, gettables)
}

View File

@ -25,7 +25,6 @@ import (
"github.com/grafana/grafana/pkg/services/user"
"github.com/grafana/grafana/pkg/web"
am_config "github.com/prometheus/alertmanager/config"
"github.com/stretchr/testify/require"
)
@ -33,35 +32,33 @@ func TestRouteGetReceiver(t *testing.T) {
fakeReceiverSvc := fakes.NewFakeReceiverService()
t.Run("returns expected model", func(t *testing.T) {
expected := definitions.GettableApiReceiver{
Receiver: am_config.Receiver{
expected := &models.Receiver{
Name: "receiver1",
},
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{
Integrations: []*models.Integration{
{
UID: "uid1",
Name: "receiver1",
Type: "slack",
},
Config: models.IntegrationConfig{Type: "slack"},
},
},
}
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
return expected, nil
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
rc := testReqCtx("GET")
resp := handler.handleRouteGetReceiver(&rc, "receiver1")
require.Equal(t, http.StatusOK, resp.Status())
json, err := json.Marshal(expected)
gettables, err := GettableApiReceiverFromReceiver(expected)
require.NoError(t, err)
json, err := json.Marshal(gettables)
require.NoError(t, err)
require.Equal(t, json, resp.Body())
})
t.Run("builds query from request context and url param", func(t *testing.T) {
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
return definitions.GettableApiReceiver{}, nil
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
return &models.Receiver{}, nil
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
rc := testReqCtx("GET")
@ -80,8 +77,8 @@ func TestRouteGetReceiver(t *testing.T) {
})
t.Run("should pass along not found response", func(t *testing.T) {
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
return definitions.GettableApiReceiver{}, notifier.ErrReceiverNotFound.Errorf("")
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
return nil, legacy_storage.ErrReceiverNotFound.Errorf("")
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
rc := testReqCtx("GET")
@ -90,8 +87,8 @@ func TestRouteGetReceiver(t *testing.T) {
})
t.Run("should pass along permission denied response", func(t *testing.T) {
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
return definitions.GettableApiReceiver{}, ac.ErrAuthorizationBase.Errorf("")
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
return nil, ac.ErrAuthorizationBase.Errorf("")
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
rc := testReqCtx("GET")
@ -104,23 +101,19 @@ func TestRouteGetReceivers(t *testing.T) {
fakeReceiverSvc := fakes.NewFakeReceiverService()
t.Run("returns expected model", func(t *testing.T) {
expected := []definitions.GettableApiReceiver{
expected := []*models.Receiver{
{
Receiver: am_config.Receiver{
Name: "receiver1",
},
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{
Integrations: []*models.Integration{
{
UID: "uid1",
Name: "receiver1",
Type: "slack",
},
Config: models.IntegrationConfig{Type: "slack"},
},
},
},
}
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
return expected, nil
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
@ -128,14 +121,16 @@ func TestRouteGetReceivers(t *testing.T) {
rc.Context.Req.Form.Set("names", "receiver1")
resp := handler.handleRouteGetReceivers(&rc)
require.Equal(t, http.StatusOK, resp.Status())
json, err := json.Marshal(expected)
gettables, err := GettableApiReceiversFromReceivers(expected)
require.NoError(t, err)
require.Equal(t, json, resp.Body())
jsonBody, err := json.Marshal(gettables)
require.NoError(t, err)
require.JSONEq(t, string(jsonBody), string(resp.Body()))
})
t.Run("builds query from request context", func(t *testing.T) {
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
return []definitions.GettableApiReceiver{}, nil
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
return nil, nil
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
rc := testReqCtx("GET")
@ -159,7 +154,7 @@ func TestRouteGetReceivers(t *testing.T) {
})
t.Run("should pass along permission denied response", func(t *testing.T) {
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
return nil, ac.ErrAuthorizationBase.Errorf("")
}
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
@ -221,7 +216,7 @@ func TestRouteGetReceiversResponses(t *testing.T) {
{limit: 4, offset: 0, expected: expected[:4]},
{limit: 1, offset: 1, expected: expected[1:2]},
{limit: 2, offset: 2, expected: expected[2:4]},
{limit: 2, offset: 99, expected: nil},
{limit: 2, offset: 99, expected: []definitions.GettableApiReceiver{}},
{limit: 0, offset: 0, expected: expected},
{limit: 0, offset: 1, expected: expected[1:]},
}
@ -237,7 +232,7 @@ func TestRouteGetReceiversResponses(t *testing.T) {
err := json.Unmarshal(response.Body(), &configs)
require.NoError(t, err)
require.Equal(t, configs, tc.expected)
require.Equal(t, tc.expected, configs)
})
}
})
@ -331,8 +326,8 @@ func TestRouteGetReceiversResponses(t *testing.T) {
})
t.Run("json body content is as expected", func(t *testing.T) {
expectedRedactedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"secureFields":{}}]}`
expectedDecryptedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"testpass","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"secureFields":{}}]}`
expectedRedactedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"[REDACTED]","use_discord_username":true},"secureFields":{"url":true}}]}`
expectedDecryptedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"testpass","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"secureFields":{"url":true}}]}`
t.Run("decrypt false", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createNotificationSrvSutFromEnv(t, &env)
@ -375,6 +370,7 @@ func createNotificationSrvSutFromEnv(t *testing.T, env *testEnvironment) Notific
ac.NewReceiverAccess[*models.Receiver](env.ac, false),
legacy_storage.NewAlertmanagerConfigStore(env.configs),
env.prov,
env.store,
env.secrets,
env.xact,
env.log,

View File

@ -1632,7 +1632,7 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
})
t.Run("json body content is as expected", func(t *testing.T) {
expectedRedactedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"[REDACTED]","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"[REDACTED]"},"disableResolveMessage":true}]}]}`
expectedRedactedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"[REDACTED]","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"[REDACTED]","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"[REDACTED]"},"disableResolveMessage":true}]}]}`
t.Run("decrypt false", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
@ -1685,14 +1685,14 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
response := sut.RouteGetContactPointsExport(&rc)
expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]}]}`
expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"[REDACTED]","use_discord_username":true},"disableResolveMessage":false}]}]}`
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
})
t.Run("yaml body content is as expected", func(t *testing.T) {
expectedRedactedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: <example@email.com>\n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: '[REDACTED]'\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: '[REDACTED]'\n disableResolveMessage: true\n"
expectedRedactedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: <example@email.com>\n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: '[REDACTED]'\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: '[REDACTED]'\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: '[REDACTED]'\n disableResolveMessage: true\n"
t.Run("decrypt false", func(t *testing.T) {
env := createTestEnv(t, testContactPointConfig)
sut := createProvisioningSrvSutFromEnv(t, &env)
@ -1745,7 +1745,7 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
response := sut.RouteGetContactPointsExport(&rc)
expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n"
expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: '[REDACTED]'\n use_discord_username: true\n disableResolveMessage: false\n"
require.Equal(t, 200, response.Status())
require.Equal(t, expectedResponse, string(response.Body()))
})
@ -1897,6 +1897,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi
ac.NewReceiverAccess[*models.Receiver](env.ac, true),
configStore,
env.prov,
env.store,
env.secrets,
env.xact,
env.log,
@ -2301,10 +2302,11 @@ var testContactPointConfig = `
"disableResolveMessage":false,
"settings":{
"avatar_url":"some avatar",
"url":"some url",
"use_discord_username":true
},
"secureSettings":{}
"secureSettings":{
"url":"some url"
}
}
]
},

View File

@ -78,7 +78,6 @@ func (api *API) authorize(method, path string) web.Handler {
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
)
case http.MethodGet + "/api/v1/notifications/receivers/{Name}":
// TODO: scope to :Name
eval = ac.EvalAny(
ac.EvalPermission(ac.ActionAlertingReceiversRead),
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),

View File

@ -6,6 +6,7 @@ import (
"time"
jsoniter "github.com/json-iterator/go"
amConfig "github.com/prometheus/alertmanager/config"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
@ -501,3 +502,56 @@ func ApiRecordFromModelRecord(r *models.Record) *definitions.Record {
From: r.From,
}
}
func GettableGrafanaReceiverFromReceiver(r *models.Integration, provenance models.Provenance) (definitions.GettableGrafanaReceiver, error) {
out := definitions.GettableGrafanaReceiver{
UID: r.UID,
Name: r.Name,
Type: r.Config.Type,
Provenance: definitions.Provenance(provenance),
DisableResolveMessage: r.DisableResolveMessage,
SecureFields: r.SecureFields(),
}
if len(r.Settings) > 0 {
jsonBytes, err := json.Marshal(r.Settings)
if err != nil {
return definitions.GettableGrafanaReceiver{}, err
}
out.Settings = jsonBytes
}
return out, nil
}
func GettableApiReceiverFromReceiver(r *models.Receiver) (*definitions.GettableApiReceiver, error) {
out := definitions.GettableApiReceiver{
Receiver: amConfig.Receiver{
Name: r.Name,
},
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
GrafanaManagedReceivers: make([]*definitions.GettableGrafanaReceiver, 0, len(r.Integrations)),
},
}
for _, integration := range r.Integrations {
gettable, err := GettableGrafanaReceiverFromReceiver(integration, r.Provenance)
if err != nil {
return nil, err
}
out.GrafanaManagedReceivers = append(out.GrafanaManagedReceivers, &gettable)
}
return &out, nil
}
func GettableApiReceiversFromReceivers(recvs []*models.Receiver) ([]*definitions.GettableApiReceiver, error) {
out := make([]*definitions.GettableApiReceiver, 0, len(recvs))
for _, r := range recvs {
gettables, err := GettableApiReceiverFromReceiver(r)
if err != nil {
return nil, err
}
out = append(out, gettables)
}
return out, nil
}

View File

@ -1,6 +1,21 @@
package models
import "github.com/grafana/alerting/notify"
import (
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"maps"
"math"
"sort"
"unsafe"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
)
// GetReceiverQuery represents a query for a single receiver.
type GetReceiverQuery struct {
@ -30,15 +45,430 @@ type ListReceiversQuery struct {
type Receiver struct {
UID string
Name string
Integrations []*notify.GrafanaIntegrationConfig
Integrations []*Integration
Provenance Provenance
Version string
}
func (r *Receiver) Clone() Receiver {
clone := Receiver{
UID: r.UID,
Name: r.Name,
Provenance: r.Provenance,
Version: r.Version,
}
if r.Integrations != nil {
clone.Integrations = make([]*Integration, len(r.Integrations))
for i, integration := range r.Integrations {
cloneIntegration := integration.Clone()
clone.Integrations[i] = &cloneIntegration
}
}
return clone
}
// Encrypt encrypts all integrations.
func (r *Receiver) Encrypt(encryptFn EncryptFn) error {
for _, integration := range r.Integrations {
if err := integration.Encrypt(encryptFn); err != nil {
return err
}
}
return nil
}
// Decrypt decrypts all integrations.
func (r *Receiver) Decrypt(decryptFn DecryptFn) error {
var errs []error
for _, integration := range r.Integrations {
if err := integration.Decrypt(decryptFn); err != nil {
errs = append(errs, fmt.Errorf("failed to decrypt integration %s: %w", integration.UID, err))
}
}
return errors.Join(errs...)
}
// Redact redacts all integrations.
func (r *Receiver) Redact(redactFn RedactFn) {
for _, integration := range r.Integrations {
integration.Redact(redactFn)
}
}
// WithExistingSecureFields copies secure settings from an existing receivers for each integration. Which fields to copy
// is determined by the integrationSecureFields map, which contains a list of secure fields for each integration UID.
func (r *Receiver) WithExistingSecureFields(existing *Receiver, integrationSecureFields map[string][]string) {
existingIntegrations := make(map[string]*Integration, len(existing.Integrations))
for _, integration := range existing.Integrations {
existingIntegrations[integration.UID] = integration
}
for _, integration := range r.Integrations {
if integration.UID == "" {
// This is a new integration, so we don't need to copy any secure fields.
continue
}
fields := integrationSecureFields[integration.UID]
if len(fields) > 0 {
integration.WithExistingSecureFields(existingIntegrations[integration.UID], fields)
}
}
}
// Validate validates all integration settings, ensuring that the integrations are correctly configured.
func (r *Receiver) Validate(decryptFn DecryptFn) error {
var errs []error
for _, integration := range r.Integrations {
if err := integration.Validate(decryptFn); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// Integration is the domain model representation of an integration.
type Integration struct {
UID string
Name string
Config IntegrationConfig
DisableResolveMessage bool
// Settings can contain both secure and non-secure settings either unencrypted or redacted.
Settings map[string]any
// SecureSettings can contain only secure settings either encrypted or redacted.
SecureSettings map[string]string
}
// IntegrationConfig represents the configuration of an integration. It contains the type and information about the fields.
type IntegrationConfig struct {
Type string
Fields map[string]IntegrationField
}
// IntegrationField represents a field in an integration configuration.
type IntegrationField struct {
Name string
Secure bool
}
// IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is
// not found an error is returned.
func IntegrationConfigFromType(integrationType string) (IntegrationConfig, error) {
config, err := channels_config.ConfigForIntegrationType(integrationType)
if err != nil {
return IntegrationConfig{}, err
}
integrationConfig := IntegrationConfig{Type: config.Type, Fields: make(map[string]IntegrationField, len(config.Options))}
for _, option := range config.Options {
integrationConfig.Fields[option.PropertyName] = IntegrationField{
Name: option.PropertyName,
Secure: option.Secure,
}
}
return integrationConfig, nil
}
// IsSecureField returns true if the field is both known and marked as secure in the integration configuration.
func (config *IntegrationConfig) IsSecureField(field string) bool {
if config.Fields != nil {
if f, ok := config.Fields[field]; ok {
return f.Secure
}
}
return false
}
func (config *IntegrationConfig) Clone() IntegrationConfig {
clone := IntegrationConfig{
Type: config.Type,
}
if len(config.Fields) > 0 {
clone.Fields = make(map[string]IntegrationField, len(config.Fields))
for key, field := range config.Fields {
clone.Fields[key] = field.Clone()
}
}
return clone
}
func (field *IntegrationField) Clone() IntegrationField {
return IntegrationField{
Name: field.Name,
Secure: field.Secure,
}
}
func (integration *Integration) Clone() Integration {
return Integration{
UID: integration.UID,
Name: integration.Name,
Config: integration.Config.Clone(),
DisableResolveMessage: integration.DisableResolveMessage,
Settings: maps.Clone(integration.Settings),
SecureSettings: maps.Clone(integration.SecureSettings),
}
}
// Encrypt encrypts all fields in Settings that are marked as secure in the integration configuration. The encrypted values
// are stored in SecureSettings and the original values are removed from Settings.
// If a field is already in SecureSettings it is not encrypted again.
func (integration *Integration) Encrypt(encryptFn EncryptFn) error {
var errs []error
for key, val := range integration.Settings {
if isSecureField := integration.Config.IsSecureField(key); !isSecureField {
continue
}
delete(integration.Settings, key)
unencryptedSecureValue, isString := val.(string)
if !isString {
continue
}
if _, exists := integration.SecureSettings[key]; exists {
continue
}
encrypted, err := encryptFn(unencryptedSecureValue)
if err != nil {
errs = append(errs, fmt.Errorf("failed to encrypt secure setting '%s': %w", key, err))
}
integration.SecureSettings[key] = encrypted
}
return errors.Join(errs...)
}
// Decrypt decrypts all fields in SecureSettings and moves them to Settings.
// The original values are removed from SecureSettings.
func (integration *Integration) Decrypt(decryptFn DecryptFn) error {
var errs []error
for key, secureVal := range integration.SecureSettings {
decrypted, err := decryptFn(secureVal)
if err != nil {
errs = append(errs, fmt.Errorf("failed to decrypt secure setting '%s': %w", key, err))
}
delete(integration.SecureSettings, key)
integration.Settings[key] = decrypted
}
return errors.Join(errs...)
}
// Redact redacts all fields in SecureSettings and moves them to Settings.
// The original values are removed from SecureSettings.
func (integration *Integration) Redact(redactFn RedactFn) {
for key, secureVal := range integration.SecureSettings { // TODO: Should we trust that the receiver is stored correctly or use known secure settings?
integration.Settings[key] = redactFn(secureVal)
delete(integration.SecureSettings, key)
}
// We don't trust that the receiver is stored correctly, so we redact secure fields in the settings as well.
for key, val := range integration.Settings {
if val != "" && integration.Config.IsSecureField(key) {
s, isString := val.(string)
if !isString {
continue
}
integration.Settings[key] = redactFn(s)
delete(integration.SecureSettings, key)
}
}
}
// WithExistingSecureFields copies secure settings from an existing integration. Which fields to copy is determined by the
// fields slice.
// Any fields found in Settings or SecureSettings are removed, even if they don't appear in the existing integration.
func (integration *Integration) WithExistingSecureFields(existing *Integration, fields []string) {
// Now for each field marked as secure, we copy the value from the existing receiver.
for _, secureField := range fields {
delete(integration.Settings, secureField) // Ensure secure fields are removed from new settings and secure settings.
delete(integration.SecureSettings, secureField)
if existing != nil {
if existingVal, ok := existing.SecureSettings[secureField]; ok {
integration.SecureSettings[secureField] = existingVal
}
}
}
}
// SecureFields returns a map of all secure fields in the integration. This includes fields in SecureSettings and fields
// in Settings that are marked as secure in the integration configuration.
func (integration *Integration) SecureFields() map[string]bool {
secureFields := make(map[string]bool, len(integration.SecureSettings))
if len(integration.SecureSettings) > 0 {
for key := range integration.SecureSettings {
secureFields[key] = true
}
}
// We mark secure fields in the settings as well. This is to ensure legacy behaviour for redacted secure settings.
for key, val := range integration.Settings {
if val != "" && integration.Config.IsSecureField(key) {
secureFields[key] = true
}
}
return secureFields
}
// Validate validates the integration settings, ensuring that the integration is correctly configured.
func (integration *Integration) Validate(decryptFn DecryptFn) error {
decrypted := integration.Clone()
if err := decrypted.Decrypt(decryptFn); err != nil {
return err
}
jsonBytes, err := json.Marshal(decrypted.Settings)
if err != nil {
return err
}
return ValidateIntegration(context.Background(), alertingNotify.GrafanaIntegrationConfig{
UID: decrypted.UID,
Name: decrypted.Name,
Type: decrypted.Config.Type,
DisableResolveMessage: decrypted.DisableResolveMessage,
Settings: jsonBytes,
SecureSettings: decrypted.SecureSettings,
}, alertingNotify.NoopDecrypt)
}
func ValidateIntegration(ctx context.Context, integration alertingNotify.GrafanaIntegrationConfig, decryptFunc alertingNotify.GetDecryptedValueFn) error {
if integration.Type == "" {
return fmt.Errorf("type should not be an empty string")
}
if integration.Settings == nil {
return fmt.Errorf("settings should not be empty")
}
_, err := alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{
GrafanaIntegrations: alertingNotify.GrafanaIntegrations{
Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration},
},
}, decryptFunc)
if err != nil {
return err
}
return nil
}
type EncryptFn = func(string) (string, error)
type DecryptFn = func(string) (string, error)
type RedactFn = func(string) string
// Identified describes a class of resources that have a UID. Created to abstract required fields for authorization.
type Identified interface {
GetUID() string
}
func (r Receiver) GetUID() string {
func (r *Receiver) GetUID() string {
return r.UID
}
func (r *Receiver) Fingerprint() string {
sum := fnv.New64()
writeBytes := func(b []byte) {
_, _ = sum.Write(b)
// add a byte sequence that cannot happen in UTF-8 strings.
_, _ = sum.Write([]byte{255})
}
writeString := func(s string) {
if len(s) == 0 {
writeBytes(nil)
return
}
// #nosec G103
// avoid allocation when converting string to byte slice
writeBytes(unsafe.Slice(unsafe.StringData(s), len(s)))
}
// this temp slice is used to convert ints to bytes.
tmp := make([]byte, 8)
writeInt := func(u int) {
binary.LittleEndian.PutUint64(tmp, uint64(u))
writeBytes(tmp)
}
writeIntegration := func(in *Integration) {
writeString(in.UID)
writeString(in.Name)
// Do not include fields in fingerprint as these are not part of the receiver definition.
writeString(in.Config.Type)
if in.DisableResolveMessage {
writeInt(1)
} else {
writeInt(0)
}
// allocate a slice that will be used for sorting keys, so we allocate it only once
var keys []string
maxLen := int(math.Max(float64(len(in.Settings)), float64(len(in.SecureSettings))))
if maxLen > 0 {
keys = make([]string, maxLen)
}
writeSecureSettings := func(secureSettings map[string]string) {
// maps do not guarantee predictable sequence of keys.
// Therefore, to make hash stable, we need to sort keys
if len(secureSettings) == 0 {
return
}
idx := 0
for k := range secureSettings {
keys[idx] = k
idx++
}
sub := keys[:idx]
sort.Strings(sub)
for _, name := range sub {
writeString(name)
writeString(secureSettings[name])
}
}
writeSecureSettings(in.SecureSettings)
writeSettings := func(settings map[string]any) {
// maps do not guarantee predictable sequence of keys.
// Therefore, to make hash stable, we need to sort keys
if len(settings) == 0 {
return
}
idx := 0
for k := range settings {
keys[idx] = k
idx++
}
sub := keys[:idx]
sort.Strings(sub)
for _, name := range sub {
writeString(name)
// TODO: Improve this.
v := settings[name]
bytes, err := json.Marshal(v)
if err != nil {
writeString(fmt.Sprintf("%+v", v))
} else {
writeBytes(bytes)
}
}
}
writeSettings(in.Settings)
}
// fields that determine the rule state
writeString(r.UID)
writeString(r.Name)
writeString(string(r.Provenance))
for _, integration := range r.Integrations {
writeIntegration(integration)
}
return fmt.Sprintf("%016x", sum.Sum64())
}

View File

@ -0,0 +1,399 @@
package models
import (
"reflect"
"testing"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/stretchr/testify/assert"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
)
func TestReceiver_Clone(t *testing.T) {
testCases := []struct {
name string
receiver Receiver
}{
{name: "empty receiver", receiver: Receiver{}},
{name: "empty integration", receiver: Receiver{Integrations: []*Integration{{Config: IntegrationConfig{}}}}},
{name: "random receiver", receiver: ReceiverGen()()},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
receiverClone := tc.receiver.Clone()
assert.Equal(t, tc.receiver, receiverClone)
for _, integration := range tc.receiver.Integrations {
integrationClone := integration.Clone()
assert.Equal(t, *integration, integrationClone)
}
})
}
}
func TestReceiver_EncryptDecrypt(t *testing.T) {
encryptFn := Base64Enrypt
decryptnFn := Base64Decrypt
// Test that all known integration types encrypt and decrypt their secrets.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
decrypedIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
encrypted := decrypedIntegration.Clone()
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
assert.NoError(t, err)
for _, key := range secrets {
if val, ok := encrypted.Settings[key]; ok {
if s, isString := val.(string); isString {
encryptedVal, err := encryptFn(s)
assert.NoError(t, err)
encrypted.SecureSettings[key] = encryptedVal
delete(encrypted.Settings, key)
}
}
}
testIntegration := decrypedIntegration.Clone()
err = testIntegration.Encrypt(encryptFn)
assert.NoError(t, err)
assert.Equal(t, encrypted, testIntegration)
err = testIntegration.Decrypt(decryptnFn)
assert.NoError(t, err)
assert.Equal(t, decrypedIntegration, testIntegration)
})
}
}
func TestIntegration_Redact(t *testing.T) {
redactFn := func(key string) string {
return "TESTREDACTED"
}
// Test that all known integration types redact their secrets.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
expected := validIntegration.Clone()
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
assert.NoError(t, err)
for _, key := range secrets {
if val, ok := expected.Settings[key]; ok {
if s, isString := val.(string); isString && s != "" {
expected.Settings[key] = redactFn(s)
delete(expected.SecureSettings, key)
}
}
}
validIntegration.Redact(redactFn)
assert.Equal(t, expected, validIntegration)
})
}
}
func TestIntegration_Validate(t *testing.T) {
// Test that all known integration types are valid.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
assert.NoError(t, validIntegration.Encrypt(Base64Enrypt))
assert.NoErrorf(t, validIntegration.Validate(Base64Decrypt), "integration should be valid")
invalidIntegration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
assert.NoError(t, invalidIntegration.Encrypt(Base64Enrypt))
assert.Errorf(t, invalidIntegration.Validate(Base64Decrypt), "integration should be invalid")
})
}
}
func TestIntegration_WithExistingSecureFields(t *testing.T) {
// Test that WithExistingSecureFields will copy over the secure fields from the existing integration.
testCases := []struct {
name string
integration Integration
secureFields []string
existing Integration
expected Integration
}{
{
name: "test receiver",
integration: Integration{
SecureSettings: map[string]string{
"f1": "newVal1",
"f2": "newVal2",
"f3": "newVal3",
"f5": "newVal5",
},
},
secureFields: []string{"f2", "f4", "f5"},
existing: Integration{
SecureSettings: map[string]string{
"f1": "oldVal1",
"f2": "oldVal2",
"f3": "oldVal3",
"f4": "oldVal4",
},
},
expected: Integration{
SecureSettings: map[string]string{
"f1": "newVal1",
"f2": "oldVal2",
"f3": "newVal3",
"f4": "oldVal4",
},
},
},
{
name: "Integration[exists], SecureFields[true], Existing[exists]: old value",
integration: Integration{
SecureSettings: map[string]string{"f1": "newVal1"},
},
secureFields: []string{"f1"},
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
expected: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
},
{
name: "Integration[exists], SecureFields[true], Existing[missing]: no value",
integration: Integration{
SecureSettings: map[string]string{"f1": "newVal1"},
},
secureFields: []string{"f1"},
existing: Integration{SecureSettings: map[string]string{}},
expected: Integration{SecureSettings: map[string]string{}},
},
{
name: "Integration[exists], SecureFields[false], Existing[exists]: new value",
integration: Integration{
SecureSettings: map[string]string{"f1": "newVal1"},
},
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
expected: Integration{SecureSettings: map[string]string{"f1": "newVal1"}},
},
{
name: "Integration[exists], SecureFields[false], Existing[missing]: new value",
integration: Integration{
SecureSettings: map[string]string{"f1": "newVal1"},
},
existing: Integration{SecureSettings: map[string]string{}},
expected: Integration{SecureSettings: map[string]string{"f1": "newVal1"}},
},
{
name: "Integration[missing], SecureFields[true], Existing[exists]: old value",
integration: Integration{
SecureSettings: map[string]string{},
},
secureFields: []string{"f1"},
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
expected: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
},
{
name: "Integration[missing], SecureFields[true], Existing[missing]: no value",
integration: Integration{
SecureSettings: map[string]string{},
},
secureFields: []string{"f1"},
existing: Integration{SecureSettings: map[string]string{}},
expected: Integration{SecureSettings: map[string]string{}},
},
{
name: "Integration[missing], SecureFields[false], Existing[exists]: no value",
integration: Integration{
SecureSettings: map[string]string{},
},
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
expected: Integration{SecureSettings: map[string]string{}},
},
{
name: "Integration[missing], SecureFields[false], Existing[missing]: no value",
integration: Integration{
SecureSettings: map[string]string{},
},
existing: Integration{SecureSettings: map[string]string{}},
expected: Integration{SecureSettings: map[string]string{}},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.integration.WithExistingSecureFields(&tc.existing, tc.secureFields)
assert.Equal(t, tc.expected, tc.integration)
})
}
}
func TestIntegrationConfig(t *testing.T) {
// Test that all known integration types have a config and correctly mark their secrets as secure.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
config, err := IntegrationConfigFromType(integrationType)
assert.NoError(t, err)
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
assert.NoError(t, err)
allSecrets := make(map[string]struct{}, len(secrets))
for _, key := range secrets {
allSecrets[key] = struct{}{}
}
for field := range config.Fields {
_, isSecret := allSecrets[field]
assert.Equal(t, isSecret, config.IsSecureField(field))
}
assert.False(t, config.IsSecureField("__--**unknown_field**--__"))
})
}
t.Run("Unknown type returns error", func(t *testing.T) {
_, err := IntegrationConfigFromType("__--**unknown_type**--__")
assert.Error(t, err)
})
}
func TestIntegration_SecureFields(t *testing.T) {
// Test that all known integration types have a config and correctly mark their secrets as secure.
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
t.Run(integrationType, func(t *testing.T) {
t.Run("contains SecureSettings", func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
expected := make(map[string]bool, len(validIntegration.SecureSettings))
for field := range validIntegration.Config.Fields {
if validIntegration.Config.IsSecureField(field) {
expected[field] = true
validIntegration.SecureSettings[field] = "test"
delete(validIntegration.Settings, field)
}
}
assert.Equal(t, expected, validIntegration.SecureFields())
})
t.Run("contains secret Settings not in SecureSettings", func(t *testing.T) {
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
expected := make(map[string]bool, len(validIntegration.SecureSettings))
for field := range validIntegration.Config.Fields {
if validIntegration.Config.IsSecureField(field) {
expected[field] = true
validIntegration.Settings[field] = "test"
delete(validIntegration.SecureSettings, field)
}
}
assert.Equal(t, expected, validIntegration.SecureFields())
})
})
}
}
// This is a broken type that will error if marshalled.
type broken struct {
f1 string
}
func (b broken) MarshalJSON() ([]byte, error) {
return nil, assert.AnError
}
func TestReceiver_Fingerprint(t *testing.T) {
// Test that the fingerprint is stable.
im := IntegrationMuts
baseReceiver := ReceiverGen(ReceiverMuts.WithName("test receiver"), ReceiverMuts.WithIntegrations(
IntegrationGen(im.WithName("test receiver"), im.WithValidConfig("slack"))(),
))()
baseReceiver.Integrations[0].UID = "stable UID"
baseReceiver.Integrations[0].DisableResolveMessage = true
baseReceiver.Integrations[0].SecureSettings = map[string]string{"test2": "test2"}
baseReceiver.Integrations[0].Settings["broken"] = broken{f1: "this"} // Add a broken type to ensure it is stable in the fingerprint.
baseReceiver.Integrations[0].Config = IntegrationConfig{Type: baseReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
completelyDifferentReceiver := ReceiverGen(ReceiverMuts.WithName("test receiver2"), ReceiverMuts.WithIntegrations(
IntegrationGen(im.WithName("test receiver2"), im.WithValidConfig("discord"))(),
))()
completelyDifferentReceiver.Integrations[0].UID = "stable UID2"
completelyDifferentReceiver.Integrations[0].DisableResolveMessage = false
completelyDifferentReceiver.Integrations[0].SecureSettings = map[string]string{"test": "test"}
completelyDifferentReceiver.Provenance = ProvenanceAPI
completelyDifferentReceiver.Integrations[0].Config = IntegrationConfig{Type: completelyDifferentReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
t.Run("stable across code changes", func(t *testing.T) {
expectedFingerprint := "ae141b582965f4f5" // If this is a valid fingerprint generation change, update the expected value.
assert.Equal(t, expectedFingerprint, baseReceiver.Fingerprint())
})
t.Run("stable across clones", func(t *testing.T) {
fingerprint := baseReceiver.Fingerprint()
receiverClone := baseReceiver.Clone()
assert.Equal(t, fingerprint, receiverClone.Fingerprint())
})
t.Run("stable across Version field modification", func(t *testing.T) {
fingerprint := baseReceiver.Fingerprint()
receiverClone := baseReceiver.Clone()
receiverClone.Version = "new version"
assert.Equal(t, fingerprint, receiverClone.Fingerprint())
})
t.Run("unstable across field modification", func(t *testing.T) {
fingerprint := baseReceiver.Fingerprint()
excludedFields := map[string]struct{}{
"Version": {},
}
reflectVal := reflect.ValueOf(&completelyDifferentReceiver).Elem()
receiverType := reflect.TypeOf((*Receiver)(nil)).Elem()
for i := 0; i < receiverType.NumField(); i++ {
field := receiverType.Field(i).Name
if _, ok := excludedFields[field]; ok {
continue
}
cp := baseReceiver.Clone()
// Get the current field being modified.
v := reflect.ValueOf(&cp).Elem()
vf := v.Field(i)
otherField := reflectVal.Field(i)
if reflect.DeepEqual(otherField.Interface(), vf.Interface()) {
assert.Failf(t, "filds are identical", "Receiver field %s is the same as the original, test does not ensure instability across the field", field)
continue
}
// Set the field to the value of the completelyDifferentReceiver.
vf.Set(otherField)
f2 := cp.Fingerprint()
assert.NotEqualf(t, fingerprint, f2, "Receiver field %s does not seem to be used in fingerprint", field)
}
excludedFields = map[string]struct{}{}
reflectVal = reflect.ValueOf(completelyDifferentReceiver.Integrations[0]).Elem()
integrationType := reflect.TypeOf((*Integration)(nil)).Elem()
for i := 0; i < integrationType.NumField(); i++ {
field := integrationType.Field(i).Name
if _, ok := excludedFields[field]; ok {
continue
}
cp := baseReceiver.Clone()
integrationCp := cp.Integrations[0]
// Get the current field being modified.
v := reflect.ValueOf(integrationCp).Elem()
vf := v.Field(i)
otherField := reflectVal.Field(i)
if reflect.DeepEqual(otherField.Interface(), vf.Interface()) {
assert.Failf(t, "filds are identical", "Integration field %s is the same as the original, test does not ensure instability across the field", field)
continue
}
// Set the field to the value of the completelyDifferentReceiver.
vf.Set(otherField)
f2 := cp.Fingerprint()
assert.NotEqualf(t, fingerprint, f2, "Integration field %s does not seem to be used in fingerprint", field)
}
})
}

View File

@ -1,6 +1,7 @@
package models
import (
"encoding/base64"
"encoding/json"
"fmt"
"math/rand"
@ -10,11 +11,13 @@ import (
"time"
"github.com/go-openapi/strfmt"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana-plugin-sdk-go/data"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
alertingModels "github.com/grafana/alerting/models"
@ -1092,6 +1095,220 @@ func (n SilenceMutators) WithEmptyId() Mutator[Silence] {
}
}
// Receivers
// CopyReceiverWith creates a deep copy of Receiver and then applies mutators to it.
func CopyReceiverWith(r Receiver, mutators ...Mutator[Receiver]) Receiver {
c := r.Clone()
for _, mutator := range mutators {
mutator(&c)
}
c.Version = c.Fingerprint()
return c
}
// ReceiverGen generates Receiver using a base and mutators.
func ReceiverGen(mutators ...Mutator[Receiver]) func() Receiver {
return func() Receiver {
name := util.GenerateShortUID()
integration := IntegrationGen(IntegrationMuts.WithName(name))()
c := Receiver{
UID: nameToUid(name),
Name: name,
Integrations: []*Integration{&integration},
Provenance: ProvenanceNone,
}
for _, mutator := range mutators {
mutator(&c)
}
c.Version = c.Fingerprint()
return c
}
}
var (
ReceiverMuts = ReceiverMutators{}
)
type ReceiverMutators struct{}
func (n ReceiverMutators) WithName(name string) Mutator[Receiver] {
return func(r *Receiver) {
r.Name = name
r.UID = nameToUid(name)
}
}
func (n ReceiverMutators) WithProvenance(provenance Provenance) Mutator[Receiver] {
return func(r *Receiver) {
r.Provenance = provenance
}
}
func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[Receiver] {
return func(r *Receiver) {
integration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
r.Integrations = []*Integration{&integration}
}
}
func (n ReceiverMutators) WithInvalidIntegration(integrationType string) Mutator[Receiver] {
return func(r *Receiver) {
integration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
r.Integrations = []*Integration{&integration}
}
}
func (n ReceiverMutators) WithIntegrations(integration ...Integration) Mutator[Receiver] {
return func(r *Receiver) {
integrations := make([]*Integration, len(integration))
for i, v := range integration {
clone := v.Clone()
integrations[i] = &clone
}
r.Integrations = integrations
}
}
func (n ReceiverMutators) Encrypted(fn EncryptFn) Mutator[Receiver] {
return func(r *Receiver) {
_ = r.Encrypt(fn)
}
}
func (n ReceiverMutators) Decrypted(fn DecryptFn) Mutator[Receiver] {
return func(r *Receiver) {
_ = r.Decrypt(fn)
}
}
// Integrations
// CopyIntegrationWith creates a deep copy of Integration and then applies mutators to it.
func CopyIntegrationWith(r Integration, mutators ...Mutator[Integration]) Integration {
c := r.Clone()
for _, mutator := range mutators {
mutator(&c)
}
return c
}
// IntegrationGen generates Integration using a base and mutators.
func IntegrationGen(mutators ...Mutator[Integration]) func() Integration {
return func() Integration {
name := util.GenerateShortUID()
randomIntegrationType, _ := randomMapKey(alertingNotify.AllKnownConfigsForTesting)
c := Integration{
UID: util.GenerateShortUID(),
Name: name,
DisableResolveMessage: rand.Intn(2) == 1,
Settings: make(map[string]any),
SecureSettings: make(map[string]string),
}
IntegrationMuts.WithValidConfig(randomIntegrationType)(&c)
for _, mutator := range mutators {
mutator(&c)
}
return c
}
}
var (
IntegrationMuts = IntegrationMutators{}
Base64Enrypt = func(s string) (string, error) {
return base64.StdEncoding.EncodeToString([]byte(s)), nil
}
Base64Decrypt = func(s string) (string, error) {
b, err := base64.StdEncoding.DecodeString(s)
return string(b), err
}
)
type IntegrationMutators struct{}
func (n IntegrationMutators) WithUID(uid string) Mutator[Integration] {
return func(s *Integration) {
s.UID = uid
}
}
func (n IntegrationMutators) WithName(name string) Mutator[Integration] {
return func(s *Integration) {
s.Name = name
}
}
func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Integration] {
return func(c *Integration) {
config := alertingNotify.AllKnownConfigsForTesting[integrationType].GetRawNotifierConfig(c.Name)
integrationConfig, _ := IntegrationConfigFromType(integrationType)
c.Config = integrationConfig
var settings map[string]any
_ = json.Unmarshal(config.Settings, &settings)
c.Settings = settings
// Decrypt secure settings over to normal settings.
for k, v := range c.SecureSettings {
decodeValue, _ := base64.StdEncoding.DecodeString(v)
settings[k] = string(decodeValue)
}
}
}
func (n IntegrationMutators) WithInvalidConfig(integrationType string) Mutator[Integration] {
return func(c *Integration) {
integrationConfig, _ := IntegrationConfigFromType(integrationType)
c.Config = integrationConfig
c.Settings = map[string]interface{}{}
c.SecureSettings = map[string]string{}
if integrationType == "webex" {
// Webex passes validation without any settings but should fail with an unparsable URL.
c.Settings["api_url"] = "(*^$*^%!@#$*()"
}
}
}
func (n IntegrationMutators) WithSettings(settings map[string]any) Mutator[Integration] {
return func(c *Integration) {
c.Settings = maps.Clone(settings)
}
}
func (n IntegrationMutators) AddSetting(key string, val any) Mutator[Integration] {
return func(c *Integration) {
c.Settings[key] = val
}
}
func (n IntegrationMutators) WithSecureSettings(secureSettings map[string]string) Mutator[Integration] {
return func(r *Integration) {
r.SecureSettings = maps.Clone(secureSettings)
}
}
func (n IntegrationMutators) AddSecureSetting(key, val string) Mutator[Integration] {
return func(r *Integration) {
r.SecureSettings[key] = val
}
}
func randomMapKey[K comparable, V any](m map[K]V) (K, V) {
randIdx := rand.Intn(len(m))
i := 0
for key, val := range m {
if i == randIdx {
return key, val
}
i++
}
return *new(K), *new(V)
}
func ConvertToRecordingRule(rule *AlertRule) {
if rule.Record == nil {
rule.Record = &Record{}
@ -1108,3 +1325,7 @@ func ConvertToRecordingRule(rule *AlertRule) {
rule.For = 0
rule.NotificationSettings = nil
}
func nameToUid(name string) string { // Avoid legacy_storage.NameToUid import cycle.
return base64.RawURLEncoding.EncodeToString([]byte(name))
}

View File

@ -419,6 +419,7 @@ func (ng *AlertNG) init() error {
ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, false),
configStore,
ng.store,
ng.store,
ng.SecretsService,
ng.store,
ng.Log,
@ -427,6 +428,7 @@ func (ng *AlertNG) init() error {
ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true),
configStore,
ng.store,
ng.store,
ng.SecretsService,
ng.store,
ng.Log,

View File

@ -1601,7 +1601,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
func GetSecretKeysForContactPointType(contactPointType string) ([]string, error) {
notifiers := GetAvailableNotifiers()
for _, n := range notifiers {
if n.Type == contactPointType {
if strings.EqualFold(n.Type, contactPointType) {
var secureFields []string
for _, field := range n.Options {
if field.Secure {
@ -1613,3 +1613,14 @@ func GetSecretKeysForContactPointType(contactPointType string) ([]string, error)
}
return nil, fmt.Errorf("no secrets configured for type '%s'", contactPointType)
}
// ConfigForIntegrationType returns the config for the given integration type. Returns error is integration type is not known.
func ConfigForIntegrationType(contactPointType string) (*NotifierPlugin, error) {
notifiers := GetAvailableNotifiers()
for _, n := range notifiers {
if strings.EqualFold(n.Type, contactPointType) {
return n, nil
}
}
return nil, fmt.Errorf("unknown integration type '%s'", contactPointType)
}

View File

@ -2,17 +2,103 @@ package notifier
import (
"encoding/json"
"github.com/prometheus/alertmanager/config"
"fmt"
alertingNotify "github.com/grafana/alerting/notify"
alertingTemplates "github.com/grafana/alerting/templates"
"github.com/grafana/grafana/pkg/components/simplejson"
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/legacy_storage"
)
func PostableApiReceiversToReceivers(postables []*apimodels.PostableApiReceiver, storedProvenances map[string]models.Provenance) ([]*models.Receiver, error) {
receivers := make([]*models.Receiver, 0, len(postables))
for _, postable := range postables {
r, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
if err != nil {
return nil, err
}
receivers = append(receivers, r)
}
return receivers, nil
}
func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, provenance models.Provenance) (*models.Receiver, error) {
integrations, err := PostableGrafanaReceiversToIntegrations(postable.GrafanaManagedReceivers)
if err != nil {
return nil, err
}
r := &models.Receiver{
UID: legacy_storage.NameToUid(postable.GetName()), // TODO replace with stable UID.
Name: postable.GetName(),
Integrations: integrations,
Provenance: provenance,
}
r.Version = r.Fingerprint()
return r, nil
}
func PostableGrafanaReceiversToIntegrations(postables []*apimodels.PostableGrafanaReceiver) ([]*models.Integration, error) {
integrations := make([]*models.Integration, 0, len(postables))
for _, cfg := range postables {
integration, err := PostableGrafanaReceiverToIntegration(cfg)
if err != nil {
return nil, err
}
integrations = append(integrations, integration)
}
return integrations, nil
}
func PostableGrafanaReceiverToIntegration(p *apimodels.PostableGrafanaReceiver) (*models.Integration, error) {
config, err := models.IntegrationConfigFromType(p.Type)
if err != nil {
return nil, err
}
integration := &models.Integration{
UID: p.UID,
Name: p.Name,
Config: config,
DisableResolveMessage: p.DisableResolveMessage,
Settings: make(map[string]any, len(p.Settings)),
SecureSettings: make(map[string]string, len(p.SecureSettings)),
}
if p.Settings != nil {
if err := json.Unmarshal(p.Settings, &integration.Settings); err != nil {
return nil, fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", integration.Config.Type, p.Name, err)
}
}
for k, v := range p.SecureSettings {
if v != "" {
integration.SecureSettings[k] = v
}
}
return integration, nil
}
// getReceiverProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
func getReceiverProvenance(storedProvenances map[string]models.Provenance, r *apimodels.PostableApiReceiver) models.Provenance {
if len(r.GrafanaManagedReceivers) == 0 {
return models.ProvenanceNone
}
// Current provisioning works on the integration level, so we need some way to determine the provenance of the
// entire receiver. All integrations in a receiver should have the same provenance, but we don't want to rely on
// this assumption in case the first provenance is None and a later one is not. To this end, we return the first
// non-zero provenance we find.
for _, contactPoint := range r.GrafanaManagedReceivers {
if p, exists := storedProvenances[contactPoint.UID]; exists && p != models.ProvenanceNone {
return p
}
}
return models.ProvenanceNone
}
func PostableGrafanaReceiverToGrafanaIntegrationConfig(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaIntegrationConfig {
return &alertingNotify.GrafanaIntegrationConfig{
UID: p.UID,
@ -46,76 +132,6 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf
return apiReceivers
}
type DecryptFn = func(value string) string
func PostableToGettableGrafanaReceiver(r *apimodels.PostableGrafanaReceiver, provenance *models.Provenance, decryptFn DecryptFn) (apimodels.GettableGrafanaReceiver, error) {
out := apimodels.GettableGrafanaReceiver{
UID: r.UID,
Name: r.Name,
Type: r.Type,
DisableResolveMessage: r.DisableResolveMessage,
SecureFields: make(map[string]bool, len(r.SecureSettings)),
}
if provenance != nil {
out.Provenance = apimodels.Provenance(*provenance)
}
if r.Settings == nil && r.SecureSettings == nil {
return out, nil
}
settings := simplejson.New()
if r.Settings != nil {
var err error
settings, err = simplejson.NewJson(r.Settings)
if err != nil {
return apimodels.GettableGrafanaReceiver{}, err
}
}
for k, v := range r.SecureSettings {
decryptedValue := decryptFn(v)
if decryptedValue == "" {
continue
} else {
settings.Set(k, decryptedValue)
}
out.SecureFields[k] = true
}
jsonBytes, err := settings.MarshalJSON()
if err != nil {
return apimodels.GettableGrafanaReceiver{}, err
}
out.Settings = jsonBytes
return out, nil
}
func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances map[string]models.Provenance, decryptFn DecryptFn) (apimodels.GettableApiReceiver, error) {
out := apimodels.GettableApiReceiver{
Receiver: config.Receiver{
Name: r.Receiver.Name,
},
}
for _, gr := range r.GrafanaManagedReceivers {
var prov *models.Provenance
if p, ok := provenances[gr.UID]; ok {
prov = &p
}
gettable, err := PostableToGettableGrafanaReceiver(gr, prov, decryptFn)
if err != nil {
return apimodels.GettableApiReceiver{}, err
}
out.GrafanaManagedReceivers = append(out.GrafanaManagedReceivers, &gettable)
}
return out, nil
}
// ToTemplateDefinitions converts the given PostableUserConfig's TemplateFiles to a slice of TemplateDefinitions.
func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplates.TemplateDefinition {
out := make([]alertingTemplates.TemplateDefinition, 0, len(cfg.TemplateFiles))

View File

@ -2,6 +2,13 @@ package legacy_storage
import (
"encoding/base64"
"encoding/json"
"maps"
alertingNotify "github.com/grafana/alerting/notify"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
func NameToUid(name string) string {
@ -15,3 +22,42 @@ func UidToName(uid string) (string, error) {
}
return string(data), nil
}
func IntegrationToPostableGrafanaReceiver(integration *models.Integration) (*apimodels.PostableGrafanaReceiver, error) {
postable := &apimodels.PostableGrafanaReceiver{
UID: integration.UID,
Name: integration.Name,
Type: integration.Config.Type,
DisableResolveMessage: integration.DisableResolveMessage,
SecureSettings: maps.Clone(integration.SecureSettings),
}
if len(integration.Settings) > 0 {
jsonBytes, err := json.Marshal(integration.Settings)
if err != nil {
return nil, err
}
postable.Settings = jsonBytes
}
return postable, nil
}
func ReceiverToPostableApiReceiver(r *models.Receiver) (*apimodels.PostableApiReceiver, error) {
integrations := apimodels.PostableGrafanaReceivers{
GrafanaManagedReceivers: make([]*apimodels.PostableGrafanaReceiver, 0, len(r.Integrations)),
}
for _, cfg := range r.Integrations {
postable, err := IntegrationToPostableGrafanaReceiver(cfg)
if err != nil {
return nil, err
}
integrations.GrafanaManagedReceivers = append(integrations.GrafanaManagedReceivers, postable)
}
return &apimodels.PostableApiReceiver{
Receiver: alertingNotify.ConfigReceiver{
Name: r.Name,
},
PostableGrafanaReceivers: integrations,
}, nil
}

View File

@ -5,6 +5,13 @@ import "github.com/grafana/grafana/pkg/apimachinery/errutil"
var (
ErrNoAlertmanagerConfiguration = errutil.Internal("alerting.notification.configMissing", errutil.WithPublicMessage("No alertmanager configuration present in this organization"))
ErrBadAlertmanagerConfiguration = errutil.Internal("alerting.notification.configCorrupted").MustTemplate("Failed to unmarshal the Alertmanager configuration", errutil.WithPublic("Current Alertmanager configuration in the storage is corrupted. Reset the configuration or rollback to a recent valid one."))
ErrReceiverNotFound = errutil.NotFound("alerting.notifications.receivers.notFound", errutil.WithPublicMessage("Receiver not found"))
ErrReceiverExists = errutil.BadRequest("alerting.notifications.receivers.exists", errutil.WithPublicMessage("Receiver with this name already exists. Use a different name or update an existing one."))
ErrReceiverInvalid = errutil.Conflict("alerting.notifications.receivers.invalid").MustTemplate(
"Invalid receiver: '{{ .Public.Reason }}'",
errutil.WithPublic("Invalid receiver: '{{ .Public.Reason }}'"),
)
)
func makeErrBadAlertmanagerConfiguration(err error) error {
@ -16,3 +23,13 @@ func makeErrBadAlertmanagerConfiguration(err error) error {
}
return ErrBadAlertmanagerConfiguration.Build(data)
}
func MakeErrReceiverInvalid(err error) error {
data := errutil.TemplateData{
Public: map[string]interface{}{
"Reason": err.Error(),
},
Error: err,
}
return ErrReceiverInvalid.Build(data)
}

View File

@ -1,9 +1,13 @@
package legacy_storage
import (
"errors"
"fmt"
"slices"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
)
func (rev *ConfigRevision) DeleteReceiver(uid string) {
@ -13,17 +17,70 @@ func (rev *ConfigRevision) DeleteReceiver(uid string) {
})
}
func (rev *ConfigRevision) CreateReceiver(receiver *models.Receiver) error {
// Check if the receiver already exists.
_, err := rev.GetReceiver(receiver.GetUID())
if err == nil {
return ErrReceiverExists.Errorf("")
}
if !errors.Is(err, ErrReceiverNotFound) {
return err
}
if err := validateAndSetIntegrationUIDs(receiver); err != nil {
return err
}
postable, err := ReceiverToPostableApiReceiver(receiver)
if err != nil {
return err
}
rev.Config.AlertmanagerConfig.Receivers = append(rev.Config.AlertmanagerConfig.Receivers, postable)
if err := rev.ValidateReceiver(postable); err != nil {
return err
}
return nil
}
func (rev *ConfigRevision) UpdateReceiver(receiver *models.Receiver) error {
existing, err := rev.GetReceiver(receiver.GetUID())
if err != nil {
return err
}
if err := validateAndSetIntegrationUIDs(receiver); err != nil {
return err
}
postable, err := ReceiverToPostableApiReceiver(receiver)
if err != nil {
return err
}
// Update receiver in the configuration.
*existing = *postable
if err := rev.ValidateReceiver(existing); err != nil {
return err
}
return nil
}
func (rev *ConfigRevision) ReceiverNameUsedByRoutes(name string) bool {
return isReceiverInUse(name, []*definitions.Route{rev.Config.AlertmanagerConfig.Route})
}
func (rev *ConfigRevision) GetReceiver(uid string) *definitions.PostableApiReceiver {
func (rev *ConfigRevision) GetReceiver(uid string) (*definitions.PostableApiReceiver, error) {
for _, r := range rev.Config.AlertmanagerConfig.Receivers {
if NameToUid(r.GetName()) == uid {
return r
return r, nil
}
}
return nil
return nil, ErrReceiverNotFound.Errorf("")
}
func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableApiReceiver {
@ -36,6 +93,52 @@ func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableAp
return receivers
}
// RenameReceiverInRoutes renames all references to a receiver in routes.
func (rev *ConfigRevision) RenameReceiverInRoutes(oldName, newName string) {
RenameReceiverInRoute(oldName, newName, rev.Config.AlertmanagerConfig.Route)
}
// ValidateReceiver checks if the given receiver conflicts in name or integration UID with existing receivers.
// We only check the receiver being modified to prevent existing issues from other receivers being reported.
func (rev *ConfigRevision) ValidateReceiver(p *definitions.PostableApiReceiver) error {
uids := make(map[string]struct{}, len(rev.Config.AlertmanagerConfig.Receivers))
for _, integrations := range p.GrafanaManagedReceivers {
if _, exists := uids[integrations.UID]; exists {
return MakeErrReceiverInvalid(fmt.Errorf("integration with UID %q already exists", integrations.UID))
}
uids[integrations.UID] = struct{}{}
}
for _, r := range rev.Config.AlertmanagerConfig.Receivers {
if p == r {
// Skip the receiver itself.
continue
}
if r.GetName() == p.GetName() {
return MakeErrReceiverInvalid(fmt.Errorf("name %q already exists", r.GetName()))
}
for _, gr := range r.GrafanaManagedReceivers {
if _, exists := uids[gr.UID]; exists {
return MakeErrReceiverInvalid(fmt.Errorf("integration with UID %q already exists", gr.UID))
}
}
}
return nil
}
func RenameReceiverInRoute(oldName, newName string, routes ...*definitions.Route) {
if len(routes) == 0 {
return
}
for _, route := range routes {
if route.Receiver == oldName {
route.Receiver = newName
}
RenameReceiverInRoute(oldName, newName, route.Routes...)
}
}
// isReceiverInUse checks if a receiver is used in a route or any of its sub-routes.
func isReceiverInUse(name string, routes []*definitions.Route) bool {
if len(routes) == 0 {
@ -51,3 +154,15 @@ func isReceiverInUse(name string, routes []*definitions.Route) bool {
}
return false
}
// validateAndSetIntegrationUIDs validates existing integration UIDs and generates them if they are empty.
func validateAndSetIntegrationUIDs(receiver *models.Receiver) error {
for _, integration := range receiver.Integrations {
if integration.UID == "" {
integration.UID = util.GenerateShortUID()
} else if err := util.ValidateUID(integration.UID); err != nil {
return MakeErrReceiverInvalid(fmt.Errorf("integration UID %q is invalid: %w", integration.UID, err))
}
}
return nil
}

View File

@ -3,8 +3,11 @@ package notifier
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
"github.com/grafana/alerting/definition"
"golang.org/x/exp/maps"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
@ -17,8 +20,14 @@ import (
)
var (
ErrReceiverNotFound = errutil.NotFound("alerting.notifications.receiver.notFound")
ErrReceiverInUse = errutil.Conflict("alerting.notifications.receiver.used").MustTemplate("Receiver is used by notification policies or alert rules")
ErrReceiverInUse = errutil.Conflict("alerting.notifications.receivers.used").MustTemplate(
"Receiver is used by '{{ .Public.UsedBy }}'",
errutil.WithPublic("Receiver is used by {{ .Public.UsedBy }}"),
)
ErrReceiverVersionConflict = errutil.Conflict("alerting.notifications.receivers.conflict").MustTemplate(
"Provided version '{{ .Public.Version }}' of receiver '{{ .Public.Name }}' does not match current version '{{ .Public.CurrentVersion }}'",
errutil.WithPublic("Provided version '{{ .Public.Version }}' of receiver '{{ .Public.Name }}' does not match current version '{{ .Public.CurrentVersion }}'"),
)
)
// ReceiverService is the service for managing alertmanager receivers.
@ -26,17 +35,34 @@ type ReceiverService struct {
authz receiverAccessControlService
provisioningStore provisoningStore
cfgStore alertmanagerConfigStore
encryptionService secrets.Service
ruleNotificationsStore alertRuleNotificationSettingsStore
encryptionService secretService
xact transactionManager
log log.Logger
validator validation.ProvenanceStatusTransitionValidator
provenanceValidator validation.ProvenanceStatusTransitionValidator
}
type alertRuleNotificationSettingsStore interface {
RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error)
ListNotificationSettings(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error)
}
type secretService interface {
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
}
// receiverAccessControlService provides access control for receivers.
type receiverAccessControlService interface {
FilterRead(context.Context, identity.Requester, ...*models.Receiver) ([]*models.Receiver, error)
AuthorizeRead(context.Context, identity.Requester, *models.Receiver) error
FilterReadDecrypted(context.Context, identity.Requester, ...*models.Receiver) ([]*models.Receiver, error)
AuthorizeReadDecrypted(context.Context, identity.Requester, *models.Receiver) error
HasList(ctx context.Context, user identity.Requester) (bool, error)
HasReadAll(ctx context.Context, user identity.Requester) (bool, error)
AuthorizeReadDecryptedAll(ctx context.Context, user identity.Requester) error
AuthorizeCreate(context.Context, identity.Requester) error
AuthorizeUpdate(context.Context, identity.Requester, *models.Receiver) error
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
}
type alertmanagerConfigStore interface {
@ -58,7 +84,8 @@ func NewReceiverService(
authz receiverAccessControlService,
cfgStore alertmanagerConfigStore,
provisioningStore provisoningStore,
encryptionService secrets.Service,
ruleNotificationsStore alertRuleNotificationSettingsStore,
encryptionService secretService,
xact transactionManager,
log log.Logger,
) *ReceiverService {
@ -66,53 +93,51 @@ func NewReceiverService(
authz: authz,
provisioningStore: provisioningStore,
cfgStore: cfgStore,
ruleNotificationsStore: ruleNotificationsStore,
encryptionService: encryptionService,
xact: xact,
log: log,
validator: validation.ValidateProvenanceRelaxed,
provenanceValidator: validation.ValidateProvenanceRelaxed,
}
}
func (rs *ReceiverService) shouldDecrypt(ctx context.Context, user identity.Requester, reqDecrypt bool) (bool, error) {
if !reqDecrypt {
return false, nil
}
if err := rs.authz.AuthorizeReadDecryptedAll(ctx, user); err != nil {
return false, err
}
return true, nil
}
// GetReceiver returns a receiver by name.
// The receiver's secure settings are decrypted if requested and the user has access to do so.
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error) {
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) {
revision, err := rs.cfgStore.Get(ctx, q.OrgID)
if err != nil {
return definitions.GettableApiReceiver{}, err
return nil, err
}
postable := revision.GetReceiver(legacy_storage.NameToUid(q.Name))
if postable == nil {
return definitions.GettableApiReceiver{}, ErrReceiverNotFound.Errorf("")
}
decrypt, err := rs.shouldDecrypt(ctx, user, q.Decrypt)
postable, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name))
if err != nil {
return definitions.GettableApiReceiver{}, err
return nil, err
}
decryptFn := rs.decryptOrRedact(ctx, decrypt, q.Name, "")
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return definitions.GettableApiReceiver{}, err
return nil, err
}
rcv, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
if err != nil {
return nil, err
}
return PostableToGettableApiReceiver(postable, storedProvenances, decryptFn)
auth := rs.authz.AuthorizeReadDecrypted
if !q.Decrypt {
auth = rs.authz.AuthorizeRead
}
if err := auth(ctx, user, rcv); err != nil {
return nil, err
}
rs.decryptOrRedactSecureSettings(ctx, rcv, q.Decrypt)
return rcv, nil
}
// GetReceivers returns a list of receivers a user has access to.
// Receivers can be filtered by name, and secure settings are decrypted if requested and the user has access to do so.
func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) {
func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]*models.Receiver, error) {
uids := make([]string, 0, len(q.Names))
for _, name := range q.Names {
uids = append(uids, legacy_storage.NameToUid(name))
@ -128,41 +153,25 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
if err != nil {
return nil, err
}
decrypt, err := rs.shouldDecrypt(ctx, user, q.Decrypt)
receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances)
if err != nil {
return nil, err
}
readRedactedAccess, err := rs.authz.HasReadAll(ctx, user)
filterFn := rs.authz.FilterReadDecrypted
if !q.Decrypt {
filterFn = rs.authz.FilterRead
}
filtered, err := filterFn(ctx, user, receivers...)
if err != nil {
return nil, err
}
// User doesn't have any permissions on the receivers.
// This is mostly a safeguard as it should not be possible with current API endpoints + middleware authentication.
if !readRedactedAccess {
return nil, nil
for _, r := range filtered {
rs.decryptOrRedactSecureSettings(ctx, r, q.Decrypt)
}
var output []definitions.GettableApiReceiver
for i := q.Offset; i < len(postables); i++ {
r := postables[i]
decryptFn := rs.decryptOrRedact(ctx, decrypt, r.Name, "")
res, err := PostableToGettableApiReceiver(r, storedProvenances, decryptFn)
if err != nil {
return nil, err
}
output = append(output, res)
// stop if we have reached the limit or we have found all the requested receivers
if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) {
break
}
}
return output, nil
return limitOffset(filtered, q.Offset, q.Limit), nil
}
// ListReceivers returns a list of receivers a user has access to.
@ -170,17 +179,12 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
// This offers an looser permissions compared to GetReceivers. When a user doesn't have read access it will check for list access instead of returning an empty list.
// If the users has list access, all receiver settings will be removed from the response. This option is for backwards compatibility with the v1/receivers endpoint
// and should be removed when FGAC is fully implemented.
func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) { // TODO: Remove this method with FGAC.
func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]*models.Receiver, error) { // TODO: Remove this method with FGAC.
listAccess, err := rs.authz.HasList(ctx, user)
if err != nil {
return nil, err
}
readRedactedAccess, err := rs.authz.HasReadAll(ctx, user)
if err != nil {
return nil, err
}
uids := make([]string, 0, len(q.Names))
for _, name := range q.Names {
uids = append(uids, legacy_storage.NameToUid(name))
@ -196,66 +200,75 @@ func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListRecei
if err != nil {
return nil, err
}
// User doesn't have any permissions on the receivers.
// This is mostly a safeguard as it should not be possible with current API endpoints + middleware authentication.
if !listAccess && !readRedactedAccess {
return nil, nil
}
var output []definitions.GettableApiReceiver
for i := q.Offset; i < len(postables); i++ {
r := postables[i]
// Remove settings.
for _, integration := range r.GrafanaManagedReceivers {
integration.Settings = nil
integration.SecureSettings = nil
integration.DisableResolveMessage = false
}
decryptFn := rs.decryptOrRedact(ctx, false, r.Name, "")
res, err := PostableToGettableApiReceiver(r, storedProvenances, decryptFn)
receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances)
if err != nil {
return nil, err
}
output = append(output, res)
// stop if we have reached the limit or we have found all the requested receivers
if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) {
break
if !listAccess {
var err error
receivers, err = rs.authz.FilterRead(ctx, user, receivers...)
if err != nil {
return nil, err
}
}
return output, nil
// Remove settings.
for _, r := range receivers {
for _, integration := range r.Integrations {
integration.Settings = nil
integration.SecureSettings = nil
integration.DisableResolveMessage = false
}
}
return limitOffset(receivers, q.Offset, q.Limit), nil
}
// DeleteReceiver deletes a receiver by uid.
// UID field currently does not exist, we assume the uid is a particular hashed value of the receiver name.
func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, orgID int64, callerProvenance definitions.Provenance, version string) error {
//TODO: Check delete permissions.
func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, callerProvenance definitions.Provenance, version string, orgID int64, user identity.Requester) error {
if err := rs.authz.AuthorizeDeleteByUID(ctx, user, uid); err != nil {
return err
}
revision, err := rs.cfgStore.Get(ctx, orgID)
if err != nil {
return err
}
postable := revision.GetReceiver(uid)
if postable == nil {
return ErrReceiverNotFound.Errorf("")
postable, err := revision.GetReceiver(uid)
if err != nil {
if errors.Is(err, legacy_storage.ErrReceiverNotFound) {
return nil
}
return err
}
// TODO: Implement + check optimistic concurrency.
storedProvenance, err := rs.getContactPointProvenance(ctx, postable, orgID)
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return err
}
existing, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
if err != nil {
return err
}
if err := rs.validator(storedProvenance, models.Provenance(callerProvenance)); err != nil {
// Check optimistic concurrency.
// Optimistic concurrency is optional for delete operations, but we still check it if a version is provided.
if version != "" {
err = rs.checkOptimisticConcurrency(existing, version)
if err != nil {
return err
}
} else {
rs.log.Debug("ignoring optimistic concurrency check because version was not provided", "receiver", existing.Name, "operation", "delete")
}
if err := rs.provenanceValidator(existing.Provenance, models.Provenance(callerProvenance)); err != nil {
return err
}
usedByRoutes := revision.ReceiverNameUsedByRoutes(postable.GetName())
usedByRules, err := rs.UsedByRules(ctx, orgID, uid)
usedByRoutes := revision.ReceiverNameUsedByRoutes(existing.Name)
usedByRules, err := rs.UsedByRules(ctx, orgID, existing.Name)
if err != nil {
return err
}
@ -271,26 +284,172 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, orgID
if err != nil {
return err
}
return rs.deleteProvenances(ctx, orgID, postable.GrafanaManagedReceivers)
return rs.deleteProvenances(ctx, orgID, existing.Integrations)
})
}
func (rs *ReceiverService) CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) {
// TODO: Stub
panic("not implemented")
func (rs *ReceiverService) CreateReceiver(ctx context.Context, r *models.Receiver, orgID int64, user identity.Requester) (*models.Receiver, error) {
if err := rs.authz.AuthorizeCreate(ctx, user); err != nil {
return nil, err
}
func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) {
// TODO: Stub
panic("not implemented")
revision, err := rs.cfgStore.Get(ctx, orgID)
if err != nil {
return nil, err
}
func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, uid string) ([]models.AlertRuleKey, error) {
//TODO: Implement
return []models.AlertRuleKey{}, nil
createdReceiver := r.Clone()
err = createdReceiver.Encrypt(rs.encryptor(ctx))
if err != nil {
return nil, err
}
func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, integrations []*definition.PostableGrafanaReceiver) error {
if err := createdReceiver.Validate(rs.decryptor(ctx)); err != nil {
return nil, legacy_storage.MakeErrReceiverInvalid(err)
}
err = revision.CreateReceiver(&createdReceiver)
if err != nil {
return nil, err
}
createdReceiver.Version = createdReceiver.Fingerprint()
err = rs.xact.InTransaction(ctx, func(ctx context.Context) error {
err = rs.cfgStore.Save(ctx, revision, orgID)
if err != nil {
return err
}
return rs.setReceiverProvenance(ctx, orgID, &createdReceiver)
})
if err != nil {
return nil, err
}
return &createdReceiver, nil
}
func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*models.Receiver, error) {
// TODO: To support receiver renaming, we need to consider permissions on old and new UID since UIDs are tied to names.
if err := rs.authz.AuthorizeUpdate(ctx, user, r); err != nil {
return nil, err
}
revision, err := rs.cfgStore.Get(ctx, orgID)
if err != nil {
return nil, err
}
postable, err := revision.GetReceiver(r.GetUID())
if err != nil {
return nil, err
}
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return nil, err
}
existing, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
if err != nil {
return nil, err
}
// Check optimistic concurrency.
err = rs.checkOptimisticConcurrency(existing, r.Version)
if err != nil {
return nil, err
}
if err := rs.provenanceValidator(existing.Provenance, r.Provenance); err != nil {
return nil, err
}
// We need to perform two important steps to process settings on an updated integration:
// 1. Encrypt new or updated secret fields as they will arrive in plain text.
// 2. For updates, callers do not re-send unchanged secure settings and instead mark them in SecureFields. We need
// to load these secure settings from the existing integration.
updatedReceiver := r.Clone()
err = updatedReceiver.Encrypt(rs.encryptor(ctx))
if err != nil {
return nil, err
}
if len(storedSecureFields) > 0 {
updatedReceiver.WithExistingSecureFields(existing, storedSecureFields)
}
if err := updatedReceiver.Validate(rs.decryptor(ctx)); err != nil {
return nil, legacy_storage.MakeErrReceiverInvalid(err)
}
err = revision.UpdateReceiver(&updatedReceiver)
if err != nil {
return nil, err
}
updatedReceiver.Version = updatedReceiver.Fingerprint()
err = rs.xact.InTransaction(ctx, func(ctx context.Context) error {
// If the name of the receiver changed, we must update references to it in both routes and notification settings.
// TODO: Needs to check provenance status compatibility: For example, if we rename a receiver via UI but rules are provisioned, this call should be rejected.
if existing.Name != r.Name {
affected, err := rs.ruleNotificationsStore.RenameReceiverInNotificationSettings(ctx, orgID, existing.Name, r.Name)
if err != nil {
return err
}
if affected > 0 {
rs.log.Info("Renamed receiver in notification settings", "oldName", existing.Name, "newName", r.Name, "affectedSettings", affected)
}
revision.RenameReceiverInRoutes(existing.Name, r.Name)
}
err = rs.cfgStore.Save(ctx, revision, orgID)
if err != nil {
return err
}
err = rs.deleteProvenances(ctx, orgID, removedIntegrations(existing, &updatedReceiver))
if err != nil {
return err
}
return rs.setReceiverProvenance(ctx, orgID, &updatedReceiver)
})
if err != nil {
return nil, err
}
return &updatedReceiver, nil
}
func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, name string) ([]models.AlertRuleKey, error) {
keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, models.ListNotificationSettingsQuery{OrgID: orgID, ReceiverName: name})
if err != nil {
return nil, err
}
return maps.Keys(keys), nil
}
func removedIntegrations(old, new *models.Receiver) []*models.Integration {
updatedUIDs := make(map[string]struct{}, len(new.Integrations))
for _, integration := range new.Integrations {
updatedUIDs[integration.UID] = struct{}{}
}
removed := make([]*models.Integration, 0)
for _, existingIntegration := range old.Integrations {
if _, ok := updatedUIDs[existingIntegration.UID]; !ok {
removed = append(removed, existingIntegration)
}
}
return removed
}
func (rs *ReceiverService) setReceiverProvenance(ctx context.Context, orgID int64, receiver *models.Receiver) error {
// Add provenance for all integrations in the receiver.
for _, integration := range receiver.Integrations {
target := definitions.EmbeddedContactPoint{UID: integration.UID}
if err := rs.provisioningStore.SetProvenance(ctx, &target, orgID, receiver.Provenance); err != nil { // TODO: Should we set ProvenanceNone?
return err
}
}
return nil
}
func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, integrations []*models.Integration) error {
// Delete provenance for all integrations.
for _, integration := range integrations {
target := definitions.EmbeddedContactPoint{UID: integration.UID}
@ -301,47 +460,73 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i
return nil
}
func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, name, fallback string) func(value string) string {
return func(value string) string {
if !decrypt {
return definitions.RedactedValue
func (rs *ReceiverService) decryptOrRedactSecureSettings(ctx context.Context, recv *models.Receiver, decrypt bool) {
if decrypt {
err := recv.Decrypt(rs.decryptor(ctx))
if err != nil {
rs.log.Warn("failed to decrypt secure settings", "name", recv.Name, "error", err)
}
} else {
recv.Redact(rs.redactor())
}
}
// decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used.
func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn {
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
rs.log.Warn("failed to decode secure setting", "name", name, "error", err)
return fallback
}
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
if err != nil {
rs.log.Warn("failed to decrypt secure setting", "name", name, "error", err)
return fallback
}
return string(decrypted)
}
}
// getContactPointProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
func (rs *ReceiverService) getContactPointProvenance(ctx context.Context, r *definitions.PostableApiReceiver, orgID int64) (models.Provenance, error) {
if len(r.GrafanaManagedReceivers) == 0 {
return models.ProvenanceNone, nil
}
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
if err != nil {
return "", err
}
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
}
// Current provisioning works on the integration level, so we need some way to determine the provenance of the
// entire receiver. All integrations in a receiver should have the same provenance, but we don't want to rely on
// this assumption in case the first provenance is None and a later one is not. To this end, we return the first
// non-zero provenance we find.
for _, contactPoint := range r.GrafanaManagedReceivers {
if p, exists := storedProvenances[contactPoint.UID]; exists && p != models.ProvenanceNone {
return p, nil
// redactor returns a models.RedactFn that redacts a secure setting.
func (rs *ReceiverService) redactor() models.RedactFn {
return func(value string) string {
return definitions.RedactedValue
}
}
return models.ProvenanceNone, nil
// encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result.
func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn {
return func(payload string) (string, error) {
s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(s), nil
}
}
// checkOptimisticConcurrency checks if the existing receiver's version matches the desired version.
func (rs *ReceiverService) checkOptimisticConcurrency(receiver *models.Receiver, desiredVersion string) error {
if receiver.Version != desiredVersion {
return makeErrReceiverVersionConflict(receiver, desiredVersion)
}
return nil
}
// limitOffset returns a subslice of items with the given offset and limit. Returns the same underlying array, not a copy.
func limitOffset[T any](items []T, offset, limit int) []T {
if limit == 0 && offset == 0 {
return items
}
if offset >= len(items) {
return nil
}
if offset+limit >= len(items) {
return items[offset:]
}
if limit == 0 {
limit = len(items) - offset
}
return items[offset : offset+limit]
}
func makeReceiverInUseErr(usedByRoutes bool, rules []models.AlertRuleKey) error {
@ -349,16 +534,34 @@ func makeReceiverInUseErr(usedByRoutes bool, rules []models.AlertRuleKey) error
for _, key := range rules {
uids = append(uids, key.UID)
}
data := make(map[string]any, 2)
var usedBy []string
data := make(map[string]any)
if len(uids) > 0 {
usedBy = append(usedBy, fmt.Sprintf("%d rule(s)", len(uids)))
data["UsedByRules"] = uids
}
if usedByRoutes {
usedBy = append(usedBy, "one or more routes")
data["UsedByRoutes"] = true
}
if len(usedBy) > 0 {
data["UsedBy"] = strings.Join(usedBy, ", ")
}
return ErrReceiverInUse.Build(errutil.TemplateData{
Public: data,
Error: nil,
})
}
func makeErrReceiverVersionConflict(current *models.Receiver, desiredVersion string) error {
data := errutil.TemplateData{
Public: map[string]interface{}{
"Version": desiredVersion,
"CurrentVersion": current.Version,
"Name": current.Name,
},
}
return ErrReceiverVersionConflict.Build(data)
}

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,28 @@ func (f *fakeConfigStore) ListNotificationSettings(ctx context.Context, q models
return settings, nil
}
func (f *fakeConfigStore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error) {
if oldReceiver == newReceiver {
return 0, nil
}
settings, ok := f.notificationSettings[orgID]
if !ok {
return 0, nil
}
var updated int
for _, notificationSettings := range settings {
for i, setting := range notificationSettings {
if setting.Receiver == oldReceiver {
updated++
notificationSettings[i].Receiver = newReceiver
}
}
}
return updated, nil
}
// Saves the image or returns an error.
func (f *fakeConfigStore) SaveImage(ctx context.Context, img *models.Image) error {
return alertingImages.ErrImageNotFound

View File

@ -46,28 +46,22 @@ func PostableGrafanaReceiverToEmbeddedContactPoint(contactPoint *definitions.Pos
return embeddedContactPoint, nil
}
func GettableGrafanaReceiverToEmbeddedContactPoint(r *definitions.GettableGrafanaReceiver) (definitions.EmbeddedContactPoint, error) {
func GrafanaIntegrationConfigToEmbeddedContactPoint(r *models.Integration, provenance models.Provenance) definitions.EmbeddedContactPoint {
settingJson := simplejson.New()
if r.Settings != nil {
var err error
settingJson, err = simplejson.NewJson(r.Settings)
if err != nil {
return definitions.EmbeddedContactPoint{}, err
}
settingJson = simplejson.NewFromAny(r.Settings)
}
for k := range r.SecureFields {
if settingJson.Get(k).MustString() == "" {
settingJson.Set(k, definitions.RedactedValue)
}
}
// We explicitly do not copy the secure settings to the settings field. This is because the provisioning API
// never returns decrypted or encrypted values, only redacted values. Redacted values should already exist in the
// settings field.
return definitions.EmbeddedContactPoint{
UID: r.UID,
Name: r.Name,
Type: r.Type,
Type: r.Config.Type,
DisableResolveMessage: r.DisableResolveMessage,
Settings: settingJson,
Provenance: string(r.Provenance),
}, nil
Provenance: string(provenance),
}
}

View File

@ -40,7 +40,7 @@ type ContactPointService struct {
}
type receiverService interface {
GetReceivers(ctx context.Context, query models.GetReceiversQuery, user identity.Requester) ([]apimodels.GettableApiReceiver, error)
GetReceivers(ctx context.Context, query models.GetReceiversQuery, user identity.Requester) ([]*models.Receiver, error)
}
func NewContactPointService(store alertmanagerConfigStore, encryptionService secrets.Service,
@ -79,23 +79,15 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP
if err != nil {
return nil, convertRecSvcErr(err)
}
grafanaReceivers := []*apimodels.GettableGrafanaReceiver{}
if q.Name != "" && len(res) > 0 {
grafanaReceivers = res[0].GettableGrafanaReceivers.GrafanaManagedReceivers // we only expect one receiver group
} else {
for _, r := range res {
grafanaReceivers = append(grafanaReceivers, r.GettableGrafanaReceivers.GrafanaManagedReceivers...)
}
res = []*models.Receiver{res[0]} // we only expect one receiver group
}
contactPoints := make([]apimodels.EmbeddedContactPoint, len(grafanaReceivers))
for i, gr := range grafanaReceivers {
contactPoint, err := GettableGrafanaReceiverToEmbeddedContactPoint(gr)
if err != nil {
return nil, err
contactPoints := make([]apimodels.EmbeddedContactPoint, 0, len(res))
for _, recv := range res {
for _, gr := range recv.Integrations {
contactPoints = append(contactPoints, GrafanaIntegrationConfigToEmbeddedContactPoint(gr, recv.Provenance))
}
contactPoints[i] = contactPoint
}
sort.SliceStable(contactPoints, func(i, j int) bool {
@ -428,7 +420,7 @@ groupLoop:
// If we're renaming, we'll need to fix up the macro receiver group for consistency.
// Firstly, if we're the only receiver in the group, simply rename the group to match. Done!
if len(receiverGroup.GrafanaManagedReceivers) == 1 {
replaceReferences(receiverGroup.Name, target.Name, cfg.AlertmanagerConfig.Route)
legacy_storage.RenameReceiverInRoute(receiverGroup.Name, target.Name, cfg.AlertmanagerConfig.Route)
receiverGroup.Name = target.Name
receiverGroup.GrafanaManagedReceivers[i] = target
renamedReceiver = receiverGroup.Name
@ -476,38 +468,12 @@ groupLoop:
return configModified, renamedReceiver
}
func replaceReferences(oldName, newName string, routes ...*apimodels.Route) {
if len(routes) == 0 {
return
}
for _, route := range routes {
if route.Receiver == oldName {
route.Receiver = newName
}
replaceReferences(oldName, newName, route.Routes...)
}
}
func ValidateContactPoint(ctx context.Context, e apimodels.EmbeddedContactPoint, decryptFunc alertingNotify.GetDecryptedValueFn) error {
if e.Type == "" {
return fmt.Errorf("type should not be an empty string")
}
if e.Settings == nil {
return fmt.Errorf("settings should not be empty")
}
integration, err := EmbeddedContactPointToGrafanaIntegrationConfig(e)
if err != nil {
return err
}
_, err = alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{
GrafanaIntegrations: alertingNotify.GrafanaIntegrations{
Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration},
},
}, decryptFunc)
if err != nil {
return err
}
return nil
return models.ValidateIntegration(ctx, integration, decryptFunc)
}
// RemoveSecretsForContactPoint removes all secrets from the contact point's settings and returns them as a map. Returns error if contact point type is not known.

View File

@ -392,6 +392,7 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec
ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), true),
legacy_storage.NewAlertmanagerConfigStore(configStore),
provisioningStore,
notifier.NewFakeConfigStore(t, nil),
secretService,
xact,
log.NewNopLogger(),

View File

@ -4,7 +4,6 @@ import (
"context"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
@ -15,8 +14,8 @@ type ReceiverServiceMethodCall struct {
type FakeReceiverService struct {
MethodCalls []ReceiverServiceMethodCall
GetReceiverFn func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error)
ListReceiversFn func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error)
GetReceiverFn func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error)
ListReceiversFn func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error)
}
func NewFakeReceiverService() *FakeReceiverService {
@ -26,12 +25,12 @@ func NewFakeReceiverService() *FakeReceiverService {
}
}
func (f *FakeReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
func (f *FakeReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceiver", Args: []interface{}{ctx, q}})
return f.GetReceiverFn(ctx, q, u)
}
func (f *FakeReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
func (f *FakeReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "ListReceivers", Args: []interface{}{ctx, q}})
return f.ListReceiversFn(ctx, q, u)
}
@ -51,10 +50,10 @@ func (f *FakeReceiverService) Reset() {
f.ListReceiversFn = defaultReceiversFn
}
func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
return definitions.GettableApiReceiver{}, nil
}
func defaultReceiversFn(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
return nil, nil
}
func defaultReceiversFn(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
return nil, nil
}

View File

@ -278,6 +278,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error
alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true),
configStore,
st,
st,
ps.secretService,
ps.SQLStore,
ps.log,

View File

@ -127,6 +127,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
ualert.AddStateResolvedAtColumns(mg)
enableTraceQLStreaming(mg, oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagTraceQLStreaming))
ualert.AddReceiverActionScopesMigration(mg)
}
func addStarMigrations(mg *Migrator) {

View File

@ -0,0 +1,49 @@
package ualert
import (
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
)
const (
AlertingAddReceiverActionScopes = "Add scope to alert.notifications.receivers:read and alert.notifications.receivers.secrets:read"
)
// AddReceiverActionScopesMigration is a migration that will add scopes to alert.notifications.receivers:read and
// alert.notifications.receivers.secrets:read actions.
// Originally, they were created without any scope, but treated as if all actions were globally scoped.
// With the introduction of receiver FGAC, we need to scope these actions to UID so any existing roles should be updated
// to explicitly have the global scope.
func AddReceiverActionScopesMigration(mg *migrator.Migrator) {
mg.AddMigration(AlertingAddReceiverActionScopes, &addReceiverActionScopesMigrator{})
}
var _ migrator.CodeMigration = (*addReceiverActionScopesMigrator)(nil)
type addReceiverActionScopesMigrator struct {
migrator.MigrationBase
}
func (p addReceiverActionScopesMigrator) SQL(migrator.Dialect) string {
return codeMigration
}
func (p addReceiverActionScopesMigrator) Exec(sess *xorm.Session, migrator *migrator.Migrator) error {
// Vendored.
actionAlertingReceiversRead := "alert.notifications.receivers:read"
actionAlertingReceiversReadSecrets := "alert.notifications.receivers.secrets:read"
_, err := sess.Exec("UPDATE permission SET `scope` = 'receivers:*', `kind` = 'receivers', `attribute` = '*', `identifier` = '*' WHERE action = ?", actionAlertingReceiversRead)
if err != nil {
migrator.Logger.Error("Failed to update permissions for action", "action", actionAlertingReceiversRead, "error", err)
return err
}
_, err = sess.Exec("UPDATE permission SET `scope` = 'receivers:*', `kind` = 'receivers', `attribute` = '*', `identifier` = '*' WHERE action = ?", actionAlertingReceiversReadSecrets)
if err != nil {
migrator.Logger.Error("Failed to update permissions for action", "action", actionAlertingReceiversReadSecrets, "error", err)
return err
}
return nil
}

View File

@ -0,0 +1,183 @@
package test
import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/setting"
)
func TestScopeMigration(t *testing.T) {
x := setupTestDB(t)
now := time.Now()
// Vendored.
actionAlertingReceiversRead := "alert.notifications.receivers:read"
actionAlertingReceiversReadSecrets := "alert.notifications.receivers.secrets:read"
type migrationTestCase struct {
desc string
permissionSeed []*accesscontrol.Permission
wantPermissions []*accesscontrol.Permission
}
testCases := []migrationTestCase{
{
desc: "convert existing alert.notifications.receivers:read regardless of scope",
permissionSeed: []*accesscontrol.Permission{
{
RoleID: 1,
Action: actionAlertingReceiversRead,
Scope: "",
Kind: "",
Attribute: "",
Created: now,
Updated: now,
},
{
RoleID: 2,
Action: actionAlertingReceiversRead,
Scope: "Scope",
Kind: "Kind",
Attribute: "Attribute",
Created: now,
Updated: now,
},
},
wantPermissions: []*accesscontrol.Permission{
{
RoleID: 1,
Action: actionAlertingReceiversRead,
Scope: "receivers:*",
Kind: "receivers",
Attribute: "*",
Identifier: "*",
},
{
RoleID: 2,
Action: actionAlertingReceiversRead,
Scope: "receivers:*",
Kind: "receivers",
Attribute: "*",
Identifier: "*",
},
},
},
{
desc: "convert existing alert.notifications.receivers:read regardless of scope",
permissionSeed: []*accesscontrol.Permission{
{
RoleID: 1,
Action: actionAlertingReceiversReadSecrets,
Scope: "",
Kind: "",
Attribute: "",
Created: now,
Updated: now,
},
{
RoleID: 2,
Action: actionAlertingReceiversReadSecrets,
Scope: "Scope",
Kind: "Kind",
Attribute: "Attribute",
Created: now,
Updated: now,
},
},
wantPermissions: []*accesscontrol.Permission{
{
RoleID: 1,
Action: actionAlertingReceiversReadSecrets,
Scope: "receivers:*",
Kind: "receivers",
Attribute: "*",
Identifier: "*",
},
{
RoleID: 2,
Action: actionAlertingReceiversReadSecrets,
Scope: "receivers:*",
Kind: "receivers",
Attribute: "*",
Identifier: "*",
},
},
},
{
desc: "empty perms",
permissionSeed: []*accesscontrol.Permission{},
wantPermissions: []*accesscontrol.Permission{},
},
{
desc: "unrelated perms",
permissionSeed: []*accesscontrol.Permission{
{
RoleID: 1,
Action: "some.other.resource:read",
Scope: "Scope",
Kind: "Kind",
Attribute: "Attribute",
Created: now,
Updated: now,
},
},
wantPermissions: []*accesscontrol.Permission{
{
RoleID: 1,
Action: "some.other.resource:read",
Scope: "Scope",
Kind: "Kind",
Attribute: "Attribute",
Created: now,
Updated: now,
},
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
// Remove migration and permissions
_, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id = ?`, ualert.AlertingAddReceiverActionScopes)
require.NoError(t, errDeleteMig)
_, errDeletePerms := x.Exec(`DELETE FROM permission`)
require.NoError(t, errDeletePerms)
// seed DB with permissions
if len(tc.permissionSeed) != 0 {
permissionsCount, err := x.Insert(tc.permissionSeed)
require.NoError(t, err)
require.Equal(t, int64(len(tc.permissionSeed)), permissionsCount)
}
// Run RBAC action name migration
acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")})
ualert.AddReceiverActionScopesMigration(acmigrator)
errRunningMig := acmigrator.Start(false, 0)
require.NoError(t, errRunningMig)
// Check permissions
resultingPermissions := []*accesscontrol.Permission{}
err := x.Table("permission").Find(&resultingPermissions)
require.NoError(t, err)
// verify got == want
cOpt := []cmp.Option{
cmpopts.SortSlices(func(a, b accesscontrol.Permission) bool { return a.RoleID < b.RoleID }),
cmpopts.IgnoreFields(accesscontrol.Permission{}, "ID", "Created", "Updated"),
}
if !cmp.Equal(tc.wantPermissions, resultingPermissions, cOpt...) {
t.Errorf("Unexpected permissions: %v", cmp.Diff(tc.wantPermissions, resultingPermissions, cOpt...))
}
})
}
}

View File

@ -0,0 +1,49 @@
package test
import (
"fmt"
"testing"
"github.com/stretchr/testify/require"
"gopkg.in/ini.v1"
"xorm.io/xorm"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
"github.com/grafana/grafana/pkg/setting"
)
func setupTestDB(t *testing.T) *xorm.Engine {
t.Helper()
dbType := sqlutil.GetTestDBType()
testDB, err := sqlutil.GetTestDB(dbType)
require.NoError(t, err)
t.Cleanup(testDB.Cleanup)
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
require.NoError(t, err)
t.Cleanup(func() {
if err := x.Close(); err != nil {
fmt.Printf("failed to close xorm engine: %v", err)
}
})
err = migrator.NewDialect(x.DriverName()).CleanDB(x)
require.NoError(t, err)
mg := migrator.NewMigrator(x, &setting.Cfg{
Logger: log.New("acmigration.test"),
Raw: ini.Empty(),
})
migrations := &migrations.OSSMigrations{}
migrations.AddMigration(mg)
err = mg.Start(false, 0)
require.NoError(t, err)
return x
}