mirror of https://github.com/grafana/grafana.git
				
				
				
			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:
		
							parent
							
								
									22ad1cc16f
								
							
						
					
					
						commit
						32f06c6d9c
					
				|  | @ -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 | ||||
| 	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 | ||||
| 		} | ||||
| 
 | ||||
| 		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 "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 | ||||
| 		if uid == "" { | ||||
| 			return deny(err) | ||||
| 		} | ||||
| 		if err := ac.AuthorizeUpdateByUID(ctx, user, uid); err != nil { | ||||
| 			return deny(err) | ||||
| 		} | ||||
| 	case "delete": | ||||
| 		action = accesscontrol.EvalAny( | ||||
| 			accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:delete permission
 | ||||
| 		) | ||||
| 		if uid == "" { | ||||
| 			return deny(err) | ||||
| 		} | ||||
| 		if err := ac.AuthorizeDeleteByUID(ctx, user, uid); err != nil { | ||||
| 			return deny(err) | ||||
| 		} | ||||
| 	default: | ||||
| 		return authorizer.DecisionNoOpinion, "", 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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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,18 +109,11 @@ 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, | ||||
|  | @ -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
 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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 | ||||
| 		}) | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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
 | ||||
| } | ||||
| 
 | ||||
|  |  | |||
|  | @ -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" | ||||
|  |  | |||
|  | @ -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, | ||||
| 				}, | ||||
| 			}), | ||||
| 		}, | ||||
| 	} | ||||
|  |  | |||
|  | @ -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)) | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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" | ||||
|             } | ||||
|          } | ||||
|       ] | ||||
|    }, | ||||
|  |  | |||
|  | @ -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), | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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()) | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| 		} | ||||
| 	}) | ||||
| } | ||||
|  | @ -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)) | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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)) | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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) | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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 | ||||
| 	} | ||||
| 
 | ||||
| 	revision, err := rs.cfgStore.Get(ctx, orgID) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	createdReceiver := r.Clone() | ||||
| 	err = createdReceiver.Encrypt(rs.encryptor(ctx)) | ||||
| 	if err != nil { | ||||
| 		return nil, err | ||||
| 	} | ||||
| 
 | ||||
| 	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 definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) { | ||||
| 	// TODO: Stub
 | ||||
| 	panic("not implemented") | ||||
| 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, uid string) ([]models.AlertRuleKey, error) { | ||||
| 	//TODO: Implement
 | ||||
| 	return []models.AlertRuleKey{}, 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 (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, integrations []*definition.PostableGrafanaReceiver) error { | ||||
| 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 | ||||
| 		} | ||||
| 
 | ||||
| 		decoded, err := base64.StdEncoding.DecodeString(value) | ||||
| 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 decode secure setting", "name", name, "error", err) | ||||
| 			return fallback | ||||
| 			rs.log.Warn("failed to decrypt secure settings", "name", recv.Name, "error", err) | ||||
| 		} | ||||
| 		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) | ||||
| 	} else { | ||||
| 		recv.Redact(rs.redactor()) | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // 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()) | ||||
| // 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 { | ||||
| 			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 | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| // 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 models.ProvenanceNone, nil | ||||
| 		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
											
										
									
								
							|  | @ -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 | ||||
|  |  | |||
|  | @ -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), | ||||
| 	} | ||||
| } | ||||
|  |  | |||
|  | @ -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.
 | ||||
|  |  | |||
|  | @ -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(), | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -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) { | ||||
|  |  | |||
|  | @ -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 | ||||
| } | ||||
|  | @ -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...)) | ||||
| 			} | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | @ -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 | ||||
| } | ||||
		Loading…
	
		Reference in New Issue