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 ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
| 
 | 
 | ||||||
| 	"k8s.io/apiserver/pkg/authorization/authorizer" | 	"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/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 { | 	if attr.GetResource() != resourceInfo.GroupResource().Resource { | ||||||
| 		return authorizer.DecisionNoOpinion, "", nil | 		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 | 		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() { | 	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": | 	case "patch": | ||||||
| 		fallthrough | 		fallthrough | ||||||
| 	case "create": |  | ||||||
| 		fallthrough // TODO: Add alert.notifications.receivers:create permission
 |  | ||||||
| 	case "update": | 	case "update": | ||||||
| 		action = accesscontrol.EvalAny( | 		if uid == "" { | ||||||
| 			accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:write permission
 | 			return deny(err) | ||||||
| 		) | 		} | ||||||
| 	case "deletecollection": | 		if err := ac.AuthorizeUpdateByUID(ctx, user, uid); err != nil { | ||||||
| 		fallthrough | 			return deny(err) | ||||||
|  | 		} | ||||||
| 	case "delete": | 	case "delete": | ||||||
| 		action = accesscontrol.EvalAny( | 		if uid == "" { | ||||||
| 			accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:delete permission
 | 			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.DecisionAllow, "", nil | ||||||
| 	} |  | ||||||
| 	return authorizer.DecisionDeny, "", err |  | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1,26 +1,19 @@ | ||||||
| package receiver | package receiver | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/json" | 	"maps" | ||||||
| 	"fmt" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/prometheus/alertmanager/config" |  | ||||||
| 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||||||
| 	"k8s.io/apimachinery/pkg/types" | 	"k8s.io/apimachinery/pkg/types" | ||||||
| 
 | 
 | ||||||
| 	common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" | 	common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" | ||||||
| 	model "github.com/grafana/grafana/pkg/apis/alerting_notifications/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/apiserver/endpoints/request" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" | 	ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/models" |  | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" | 	"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| func getUID(t definitions.GettableApiReceiver) string { | func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) { | ||||||
| 	return legacy_storage.NameToUid(t.Name) |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func convertToK8sResources(orgID int64, receivers []definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) { |  | ||||||
| 	result := &model.ReceiverList{ | 	result := &model.ReceiverList{ | ||||||
| 		Items: make([]model.Receiver, 0, len(receivers)), | 		Items: make([]model.Receiver, 0, len(receivers)), | ||||||
| 	} | 	} | ||||||
|  | @ -34,67 +27,54 @@ func convertToK8sResources(orgID int64, receivers []definitions.GettableApiRecei | ||||||
| 	return result, nil | 	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{ | 	spec := model.ReceiverSpec{ | ||||||
| 		Title: receiver.Receiver.Name, | 		Title: 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) |  | ||||||
| 	} | 	} | ||||||
|  | 	for _, integration := range receiver.Integrations { | ||||||
| 		spec.Integrations = append(spec.Integrations, model.Integration{ | 		spec.Integrations = append(spec.Integrations, model.Integration{ | ||||||
| 			Uid:                   &integration.UID, | 			Uid:                   &integration.UID, | ||||||
| 			Type:                  integration.Type, | 			Type:                  integration.Config.Type, | ||||||
| 			DisableResolveMessage: &integration.DisableResolveMessage, | 			DisableResolveMessage: &integration.DisableResolveMessage, | ||||||
| 			Settings:              unstruct, | 			Settings:              common.Unstructured{Object: maps.Clone(integration.Settings)}, | ||||||
| 			SecureFields:          integration.SecureFields, | 			SecureFields:          integration.SecureFields(), | ||||||
| 		}) | 		}) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	uid := getUID(receiver) // TODO replace to stable UID when we switch to normal storage
 |  | ||||||
| 	r := &model.Receiver{ | 	r := &model.Receiver{ | ||||||
| 		TypeMeta: resourceInfo.TypeMeta(), | 		TypeMeta: resourceInfo.TypeMeta(), | ||||||
| 		ObjectMeta: metav1.ObjectMeta{ | 		ObjectMeta: metav1.ObjectMeta{ | ||||||
| 			UID:             types.UID(uid), // This is needed to make PATCH work
 | 			UID:             types.UID(receiver.GetUID()), // This is needed to make PATCH work
 | ||||||
| 			Name:            uid,            // TODO replace to stable UID when we switch to normal storage
 | 			Name:            receiver.GetUID(), | ||||||
| 			Namespace:       namespacer(orgID), | 			Namespace:       namespacer(orgID), | ||||||
| 			ResourceVersion: "", // TODO: Implement optimistic concurrency.
 | 			ResourceVersion: receiver.Version, | ||||||
| 		}, | 		}, | ||||||
| 		Spec: spec, | 		Spec: spec, | ||||||
| 	} | 	} | ||||||
| 	r.SetProvenanceStatus(string(provenance)) | 	r.SetProvenanceStatus(string(receiver.Provenance)) | ||||||
| 	return r, nil | 	return r, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) { | func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) { | ||||||
| 	// TODO: Using GettableApiReceiver instead of PostableApiReceiver so that SecureFields type matches.
 | 	domain := &ngmodels.Receiver{ | ||||||
| 	gettable := definitions.GettableApiReceiver{ | 		UID:          legacy_storage.NameToUid(receiver.Spec.Title), | ||||||
| 		Receiver: config.Receiver{ |  | ||||||
| 		Name:         receiver.Spec.Title, | 		Name:         receiver.Spec.Title, | ||||||
| 		}, | 		Integrations: make([]*ngmodels.Integration, 0, len(receiver.Spec.Integrations)), | ||||||
| 		GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ | 		Version:      receiver.ResourceVersion, | ||||||
| 			GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{}, | 		Provenance:   ngmodels.ProvenanceNone, | ||||||
| 		}, |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations)) | ||||||
| 	for _, integration := range receiver.Spec.Integrations { | 	for _, integration := range receiver.Spec.Integrations { | ||||||
| 		data, err := integration.Settings.MarshalJSON() | 		config, err := ngmodels.IntegrationConfigFromType(integration.Type) | ||||||
| 		if err != nil { | 		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, | 			Name:           receiver.Spec.Title, | ||||||
| 			Type:         integration.Type, | 			Config:         config, | ||||||
| 			Settings:     definitions.RawMessage(data), | 			Settings:       maps.Clone(integration.Settings.UnstructuredContent()), | ||||||
| 			SecureFields: integration.SecureFields, | 			SecureSettings: make(map[string]string), | ||||||
| 			Provenance:   definitions.Provenance(models.ProvenanceNone), |  | ||||||
| 		} | 		} | ||||||
| 		if integration.Uid != nil { | 		if integration.Uid != nil { | ||||||
| 			grafanaIntegration.UID = *integration.Uid | 			grafanaIntegration.UID = *integration.Uid | ||||||
|  | @ -102,8 +82,20 @@ func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiRece | ||||||
| 		if integration.DisableResolveMessage != nil { | 		if integration.DisableResolveMessage != nil { | ||||||
| 			grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage | 			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" | 	grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest" | ||||||
| 	"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" | 	"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/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 ( | var ( | ||||||
|  | @ -25,11 +26,11 @@ var ( | ||||||
| var resourceInfo = notifications.ReceiverResourceInfo | var resourceInfo = notifications.ReceiverResourceInfo | ||||||
| 
 | 
 | ||||||
| type ReceiverService interface { | type ReceiverService interface { | ||||||
| 	GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error) | 	GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error) | ||||||
| 	GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) | 	GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error) | ||||||
| 	CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
 | 	CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) | ||||||
| 	UpdateReceiver(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 *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error) | ||||||
| 	DeleteReceiver(ctx context.Context, name string, orgID int64, provenance definitions.Provenance, version string) error | 	DeleteReceiver(ctx context.Context, name string, provenance definitions.Provenance, version string, orgID int64, user identity.Requester) error | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type legacyStorage struct { | type legacyStorage struct { | ||||||
|  | @ -66,12 +67,12 @@ func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	q := models.GetReceiversQuery{ | 	q := ngmodels.GetReceiversQuery{ | ||||||
| 		OrgID:   orgId, | 		OrgID:   orgId, | ||||||
|  | 		Decrypt: false, | ||||||
| 		//Names:   ctx.QueryStrings("names"), // TODO: Query params.
 | 		//Names:   ctx.QueryStrings("names"), // TODO: Query params.
 | ||||||
| 		//Limit:   ctx.QueryInt("limit"),
 | 		//Limit:   ctx.QueryInt("limit"),
 | ||||||
| 		//Offset:  ctx.QueryInt("offset"),
 | 		//Offset:  ctx.QueryInt("offset"),
 | ||||||
| 		//Decrypt: ctx.QueryBool("decrypt"),
 |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	user, err := identity.GetRequester(ctx) | 	user, err := identity.GetRequester(ctx) | ||||||
|  | @ -93,9 +94,14 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption | ||||||
| 		return nil, err | 		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, | 		OrgID:   info.OrgID, | ||||||
| 		//Decrypt: ctx.QueryBool("decrypt"), // TODO: Query params.
 | 		Name:    name, | ||||||
|  | 		Decrypt: false, | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	user, err := identity.GetRequester(ctx) | 	user, err := identity.GetRequester(ctx) | ||||||
|  | @ -103,18 +109,11 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	res, err := s.service.GetReceivers(ctx, q, user) | 	r, err := s.service.GetReceiver(ctx, q, user) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	for _, r := range res { |  | ||||||
| 		if getUID(r) == uid { |  | ||||||
| 	return convertToK8sResource(info.OrgID, r, s.namespacer) | 	return convertToK8sResource(info.OrgID, r, s.namespacer) | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *legacyStorage) Create(ctx context.Context, | 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
 | 	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") | 		return nil, errors.NewBadRequest("object's metadata.name should be empty") | ||||||
| 	} | 	} | ||||||
| 	model, err := convertToDomainModel(p) | 	model, _, err := convertToDomainModel(p) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		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 { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
|  | @ -162,6 +167,11 @@ func (s *legacyStorage) Update(ctx context.Context, | ||||||
| 		return nil, false, err | 		return nil, false, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	user, err := identity.GetRequester(ctx) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, false, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	old, err := s.Get(ctx, uid, nil) | 	old, err := s.Get(ctx, uid, nil) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return old, false, err | 		return old, false, err | ||||||
|  | @ -179,16 +189,16 @@ func (s *legacyStorage) Update(ctx context.Context, | ||||||
| 	if !ok { | 	if !ok { | ||||||
| 		return nil, false, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind()) | 		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 { | 	if err != nil { | ||||||
| 		return old, false, err | 		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.") | 		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 { | 	if err != nil { | ||||||
| 		return nil, false, err | 		return nil, false, err | ||||||
| 	} | 	} | ||||||
|  | @ -203,6 +213,12 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, false, err | 		return nil, false, err | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	user, err := identity.GetRequester(ctx) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, false, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	old, err := s.Get(ctx, uid, nil) | 	old, err := s.Get(ctx, uid, nil) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return old, false, err | 		return old, false, err | ||||||
|  | @ -217,7 +233,7 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation | ||||||
| 		version = *options.Preconditions.ResourceVersion | 		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
 | 	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/apiserver/endpoints/request" | ||||||
| 	"github.com/grafana/grafana/pkg/services/featuremgmt" | 	"github.com/grafana/grafana/pkg/services/featuremgmt" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert" | 	"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" | 	"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
 | // This is used just so wire has something unique to return
 | ||||||
| type NotificationsAPIBuilder struct { | type NotificationsAPIBuilder struct { | ||||||
| 	authz        accesscontrol.AccessControl | 	authz        accesscontrol.AccessControl | ||||||
|  | 	receiverAuth receiver.AccessControlService | ||||||
| 	ng           *ngalert.AlertNG | 	ng           *ngalert.AlertNG | ||||||
| 	namespacer   request.NamespaceMapper | 	namespacer   request.NamespaceMapper | ||||||
| 	gv           schema.GroupVersion | 	gv           schema.GroupVersion | ||||||
|  | @ -51,6 +54,7 @@ func RegisterAPIService( | ||||||
| 		namespacer:   request.GetNamespaceMapper(cfg), | 		namespacer:   request.GetNamespaceMapper(cfg), | ||||||
| 		gv:           notificationsModels.SchemeGroupVersion, | 		gv:           notificationsModels.SchemeGroupVersion, | ||||||
| 		authz:        ng.Api.AccessControl, | 		authz:        ng.Api.AccessControl, | ||||||
|  | 		receiverAuth: ac.NewReceiverAccess[*ngmodels.Receiver](ng.Api.AccessControl, false), | ||||||
| 	} | 	} | ||||||
| 	apiregistration.RegisterAPI(builder) | 	apiregistration.RegisterAPI(builder) | ||||||
| 	return builder | 	return builder | ||||||
|  | @ -128,7 +132,7 @@ func (t *NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer { | ||||||
| 			case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource: | 			case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource: | ||||||
| 				return timeInterval.Authorize(ctx, t.authz, a) | 				return timeInterval.Authorize(ctx, t.authz, a) | ||||||
| 			case notificationsModels.ReceiverResourceInfo.GroupResource().Resource: | 			case notificationsModels.ReceiverResourceInfo.GroupResource().Resource: | ||||||
| 				return receiver.Authorize(ctx, t.authz, a) | 				return receiver.Authorize(ctx, t.receiverAuth, a) | ||||||
| 			} | 			} | ||||||
| 			return authorizer.DecisionNoOpinion, "", nil | 			return authorizer.DecisionNoOpinion, "", nil | ||||||
| 		}) | 		}) | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ import ( | ||||||
| 	model "github.com/grafana/grafana/pkg/apis/alerting_notifications/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/apiserver/endpoints/request" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" | 	"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) { | 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.Version = interval.ResourceVersion | ||||||
| 	result.UID = interval.ObjectMeta.Name | 	result.UID = interval.ObjectMeta.Name | ||||||
| 	result.Provenance = definitions.Provenance(models.ProvenanceNone) | 	result.Provenance = definitions.Provenance(ngmodels.ProvenanceNone) | ||||||
| 	err = result.Validate() | 	err = result.Validate() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return definitions.MuteTimeInterval{}, err | 		return definitions.MuteTimeInterval{}, err | ||||||
|  |  | ||||||
|  | @ -14,7 +14,7 @@ import ( | ||||||
| 	grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest" | 	grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest" | ||||||
| 	"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" | 	"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/api/tooling/definitions" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/models" | 	ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | 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()) | 		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
 | 	return old, false, err                                                                                                         // false - will be deleted async
 | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -447,6 +447,9 @@ const ( | ||||||
| 	ActionAlertingReceiversList        = "alert.notifications.receivers:list" | 	ActionAlertingReceiversList        = "alert.notifications.receivers:list" | ||||||
| 	ActionAlertingReceiversRead        = "alert.notifications.receivers:read" | 	ActionAlertingReceiversRead        = "alert.notifications.receivers:read" | ||||||
| 	ActionAlertingReceiversReadSecrets = "alert.notifications.receivers.secrets: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.
 | 	// 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" | 	ActionAlertingRuleExternalWrite = "alert.rules.external:write" | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ import ( | ||||||
| 	"github.com/grafana/grafana/pkg/services/accesscontrol" | 	"github.com/grafana/grafana/pkg/services/accesscontrol" | ||||||
| 	"github.com/grafana/grafana/pkg/services/dashboards" | 	"github.com/grafana/grafana/pkg/services/dashboards" | ||||||
| 	"github.com/grafana/grafana/pkg/services/datasources" | 	"github.com/grafana/grafana/pkg/services/datasources" | ||||||
|  | 	ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol" | ||||||
| 	"github.com/grafana/grafana/pkg/services/org" | 	"github.com/grafana/grafana/pkg/services/org" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -133,6 +134,7 @@ var ( | ||||||
| 				}, | 				}, | ||||||
| 				{ | 				{ | ||||||
| 					Action: accesscontrol.ActionAlertingReceiversRead, | 					Action: accesscontrol.ActionAlertingReceiversRead, | ||||||
|  | 					Scope:  ac.ScopeReceiversAll, | ||||||
| 				}, | 				}, | ||||||
| 			}, | 			}, | ||||||
| 		}, | 		}, | ||||||
|  | @ -152,6 +154,18 @@ var ( | ||||||
| 					Action: accesscontrol.ActionAlertingNotificationsExternalWrite, | 					Action: accesscontrol.ActionAlertingNotificationsExternalWrite, | ||||||
| 					Scope:  datasources.ScopeAll, | 					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/errutil" | ||||||
| 	"github.com/grafana/grafana/pkg/apimachinery/identity" | 	"github.com/grafana/grafana/pkg/apimachinery/identity" | ||||||
| 	ac "github.com/grafana/grafana/pkg/services/accesscontrol" | 	ac "github.com/grafana/grafana/pkg/services/accesscontrol" | ||||||
|  | 	"github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | 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.
 | // 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 | 	genericService | ||||||
| 
 | 
 | ||||||
| 	// authorizeSome evaluates to true if user has access to some (any) resources.
 | 	// authorizeSome evaluates to true if user has access to some (any) resources.
 | ||||||
|  | @ -41,7 +42,7 @@ type actionAccess[T any] struct { | ||||||
| 	authorizeAll ac.Evaluator | 	authorizeAll ac.Evaluator | ||||||
| 
 | 
 | ||||||
| 	// authorizeOne returns an evaluator that checks if user has access to a specific resource.
 | 	// 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 is the action that user is trying to perform on the resource. Used in error messages.
 | ||||||
| 	action string | 	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.
 | // 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.
 | // 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) { | 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 { | 	if err != nil { | ||||||
| 		return nil, err | 		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.
 | // 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 { | func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester, resource models.Identified) error { | ||||||
| 	canAll, err := s.authorizePreConditions(ctx, user) | 	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.
 | 	if canAll || err != nil { // Return early if user can either access all or there is an error.
 | ||||||
| 		return err | 		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.
 | // 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) { | func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resource models.Identified) (bool, error) { | ||||||
| 	canAll, err := s.authorizePreConditions(ctx, user) | 	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.
 | 	if canAll || err != nil { // Return early if user can either access all or there is an error.
 | ||||||
| 		return canAll, err | 		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) | 	return s.has(ctx, user, resource) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // authorizePreConditions checks necessary preconditions for resources. Returns true if user has access for all
 | // AuthorizeAll checks if user has access to all resources. Returns error if user does not have access to all resources.
 | ||||||
| // resources. Returns error if user does not have access to on any resources.
 | func (s actionAccess[T]) AuthorizeAll(ctx context.Context, user identity.Requester) error { | ||||||
| func (s actionAccess[T]) authorizePreConditions(ctx context.Context, user identity.Requester) (bool, error) { | 	return s.HasAccessOrError(ctx, user, s.authorizeAll, func() string { | ||||||
| 	canAll, err := s.HasAccess(ctx, user, s.authorizeAll) | 		return fmt.Sprintf("%s all %ss", s.action, s.resource) | ||||||
| 	if canAll || err != nil { // Return early if user can either access all or there is an error.
 | 	}) | ||||||
| 		return canAll, err | } | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	can, err := s.HasAccess(ctx, user, s.authorizeSome) | // AuthorizePreConditions checks necessary preconditions for resources. Returns error if user does not have access to any resources.
 | ||||||
| 	if err != nil { | func (s actionAccess[T]) AuthorizePreConditions(ctx context.Context, user identity.Requester) error { | ||||||
| 		return false, err | 	return s.HasAccessOrError(ctx, user, s.authorizeSome, func() string { | ||||||
| 	} | 		return fmt.Sprintf("%s any %s", s.action, s.resource) | ||||||
| 	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 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // 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.
 | // 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 s.HasAccessOrError(ctx, user, s.authorizeOne(resource), func() string { | ||||||
| 		return fmt.Sprintf("%s %s", s.action, s.resource) | 		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.
 | // 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)) | 	return s.HasAccess(ctx, user, s.authorizeOne(resource)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -2,13 +2,21 @@ package accesscontrol | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/apimachinery/identity" | 	"github.com/grafana/grafana/pkg/apimachinery/identity" | ||||||
| 	ac "github.com/grafana/grafana/pkg/services/accesscontrol" | 	ac "github.com/grafana/grafana/pkg/services/accesscontrol" | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/models" | 	"github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | const ( | ||||||
|  | 	ScopeReceiversRoot = "receivers" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ( | ||||||
|  | 	ScopeReceiversProvider = ac.NewScopeProvider(ScopeReceiversRoot) | ||||||
|  | 	ScopeReceiversAll      = ScopeReceiversProvider.GetResourceAllScope() | ||||||
|  | ) | ||||||
|  | 
 | ||||||
| var ( | var ( | ||||||
| 	// Asserts pre-conditions for read access to redacted receivers. If this evaluates to false, the user cannot read any redacted receivers.
 | 	// Asserts pre-conditions for read access to redacted receivers. If this evaluates to false, the user cannot read any redacted receivers.
 | ||||||
| 	readRedactedReceiversPreConditionsEval = ac.EvalAny( | 	readRedactedReceiversPreConditionsEval = ac.EvalAny( | ||||||
|  | @ -24,23 +32,19 @@ var ( | ||||||
| 	// Asserts read-only access to all redacted receivers.
 | 	// Asserts read-only access to all redacted receivers.
 | ||||||
| 	readRedactedAllReceiversEval = ac.EvalAny( | 	readRedactedAllReceiversEval = ac.EvalAny( | ||||||
| 		ac.EvalPermission(ac.ActionAlertingNotificationsRead), | 		ac.EvalPermission(ac.ActionAlertingNotificationsRead), | ||||||
| 
 | 		ac.EvalPermission(ac.ActionAlertingReceiversRead, ScopeReceiversAll), | ||||||
| 		// 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.
 |  | ||||||
| 		readDecryptedAllReceiversEval, | 		readDecryptedAllReceiversEval, | ||||||
| 	) | 	) | ||||||
| 	// Asserts read-only access to all decrypted receivers.
 | 	// Asserts read-only access to all decrypted receivers.
 | ||||||
| 	readDecryptedAllReceiversEval = ac.EvalAny( | 	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.
 | 	// Asserts read-only access to a specific redacted receiver.
 | ||||||
| 	readRedactedReceiverEval = func(uid string) ac.Evaluator { | 	readRedactedReceiverEval = func(uid string) ac.Evaluator { | ||||||
| 		return ac.EvalAny( | 		return ac.EvalAny( | ||||||
| 			ac.EvalPermission(ac.ActionAlertingNotificationsRead), | 			ac.EvalPermission(ac.ActionAlertingNotificationsRead), | ||||||
| 
 | 			ac.EvalPermission(ac.ActionAlertingReceiversRead, ScopeReceiversProvider.GetResourceScopeUID(uid)), | ||||||
| 			// 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.
 |  | ||||||
| 			readDecryptedReceiverEval(uid), | 			readDecryptedReceiverEval(uid), | ||||||
| 		) | 		) | ||||||
| 	} | 	} | ||||||
|  | @ -48,7 +52,7 @@ var ( | ||||||
| 	// Asserts read-only access to a specific decrypted receiver.
 | 	// Asserts read-only access to a specific decrypted receiver.
 | ||||||
| 	readDecryptedReceiverEval = func(uid string) ac.Evaluator { | 	readDecryptedReceiverEval = func(uid string) ac.Evaluator { | ||||||
| 		return ac.EvalAny( | 		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( | 	provisioningExtraReadDecryptedPermissions = ac.EvalAny( | ||||||
| 		ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets), // Global provisioning action for all AM config + secrets. Org scope.
 | 		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 { | type ReceiverAccess[T models.Identified] struct { | ||||||
| 	read          actionAccess[T] | 	read          actionAccess[T] | ||||||
| 	readDecrypted 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
 | // NewReceiverAccess creates a new ReceiverAccess service. If includeProvisioningActions is true, the service will include
 | ||||||
| // permissions specific to the provisioning API.
 | // permissions specific to the provisioning API.
 | ||||||
| func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvisioningActions bool) *ReceiverAccess[T] { | 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]{ | 	rcvAccess := &ReceiverAccess[T]{ | ||||||
| 		read: actionAccess[T]{ | 		read: actionAccess[T]{ | ||||||
| 			genericService: genericService{ | 			genericService: genericService{ | ||||||
|  | @ -86,7 +169,7 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision | ||||||
| 			resource:      "receiver", | 			resource:      "receiver", | ||||||
| 			action:        "read", | 			action:        "read", | ||||||
| 			authorizeSome: readRedactedReceiversPreConditionsEval, | 			authorizeSome: readRedactedReceiversPreConditionsEval, | ||||||
| 			authorizeOne: func(receiver T) ac.Evaluator { | 			authorizeOne: func(receiver models.Identified) ac.Evaluator { | ||||||
| 				return readRedactedReceiverEval(receiver.GetUID()) | 				return readRedactedReceiverEval(receiver.GetUID()) | ||||||
| 			}, | 			}, | ||||||
| 			authorizeAll: readRedactedAllReceiversEval, | 			authorizeAll: readRedactedAllReceiversEval, | ||||||
|  | @ -98,27 +181,47 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision | ||||||
| 			resource:      "decrypted receiver", | 			resource:      "decrypted receiver", | ||||||
| 			action:        "read", | 			action:        "read", | ||||||
| 			authorizeSome: readDecryptedReceiversPreConditionsEval, | 			authorizeSome: readDecryptedReceiversPreConditionsEval, | ||||||
| 			authorizeOne: func(receiver T) ac.Evaluator { | 			authorizeOne: func(receiver models.Identified) ac.Evaluator { | ||||||
| 				return readDecryptedReceiverEval(receiver.GetUID()) | 				return readDecryptedReceiverEval(receiver.GetUID()) | ||||||
| 			}, | 			}, | ||||||
| 			authorizeAll: readDecryptedAllReceiversEval, | 			authorizeAll: readDecryptedAllReceiversEval, | ||||||
| 		}, | 		}, | ||||||
| 	} | 		create: actionAccess[T]{ | ||||||
| 
 | 			genericService: genericService{ | ||||||
| 	// If this service is meant for the provisioning API, we include the provisioning actions as possible permissions.
 | 				ac: a, | ||||||
| 	if includeProvisioningActions { | 			}, | ||||||
| 		rcvAccess.read.authorizeSome = ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeSome) | 			resource:      "receiver", | ||||||
| 		rcvAccess.readDecrypted.authorizeSome = ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeSome) | 			action:        "create", | ||||||
| 
 | 			authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval), | ||||||
| 		rcvAccess.read.authorizeOne = func(receiver T) ac.Evaluator { | 			authorizeOne: func(receiver models.Identified) ac.Evaluator { | ||||||
| 			return ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeOne(receiver)) | 				return ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval) | ||||||
| 		} | 			}, | ||||||
| 		rcvAccess.readDecrypted.authorizeOne = func(receiver T) ac.Evaluator { | 			authorizeAll: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval), | ||||||
| 			return ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeOne(receiver)) | 		}, | ||||||
| 		} | 		update: actionAccess[T]{ | ||||||
| 
 | 			genericService: genericService{ | ||||||
| 		rcvAccess.read.authorizeAll = ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeAll) | 				ac: a, | ||||||
| 		rcvAccess.readDecrypted.authorizeAll = ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeAll) | 			}, | ||||||
|  | 			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 | 	return rcvAccess | ||||||
|  | @ -145,11 +248,6 @@ func (s ReceiverAccess[T]) HasRead(ctx context.Context, user identity.Requester, | ||||||
| 	return s.read.Has(ctx, user, receiver) | 	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.
 | // 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.
 | // 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) { | 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) | 	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.
 | // AuthorizeUpdate checks if user has access to update a 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.
 | func (s ReceiverAccess[T]) AuthorizeUpdate(ctx context.Context, user identity.Requester, receiver T) error { | ||||||
| 	return s.readDecrypted.HasAccessOrError(ctx, user, s.readDecrypted.authorizeAll, func() string { | 	return s.update.Authorize(ctx, user, receiver) | ||||||
| 		return fmt.Sprintf("%s %s", s.readDecrypted.action, s.readDecrypted.resource) | } | ||||||
| 	}) | 
 | ||||||
|  | // 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/apimachinery/identity" | ||||||
| 	"github.com/grafana/grafana/pkg/infra/log" | 	"github.com/grafana/grafana/pkg/infra/log" | ||||||
| 	contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" | 	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" | 	"github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -19,8 +18,8 @@ type NotificationSrv struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type ReceiverService interface { | type ReceiverService interface { | ||||||
| 	GetReceiver(ctx context.Context, q models.GetReceiverQuery, u 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) ([]definitions.GettableApiReceiver, 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 { | 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.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 { | 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.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/services/user" | ||||||
| 	"github.com/grafana/grafana/pkg/web" | 	"github.com/grafana/grafana/pkg/web" | ||||||
| 
 | 
 | ||||||
| 	am_config "github.com/prometheus/alertmanager/config" |  | ||||||
| 	"github.com/stretchr/testify/require" | 	"github.com/stretchr/testify/require" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -33,35 +32,33 @@ func TestRouteGetReceiver(t *testing.T) { | ||||||
| 	fakeReceiverSvc := fakes.NewFakeReceiverService() | 	fakeReceiverSvc := fakes.NewFakeReceiverService() | ||||||
| 
 | 
 | ||||||
| 	t.Run("returns expected model", func(t *testing.T) { | 	t.Run("returns expected model", func(t *testing.T) { | ||||||
| 		expected := definitions.GettableApiReceiver{ | 		expected := &models.Receiver{ | ||||||
| 			Receiver: am_config.Receiver{ |  | ||||||
| 			Name: "receiver1", | 			Name: "receiver1", | ||||||
| 			}, | 			Integrations: []*models.Integration{ | ||||||
| 			GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ |  | ||||||
| 				GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{ |  | ||||||
| 				{ | 				{ | ||||||
| 					UID:    "uid1", | 					UID:    "uid1", | ||||||
| 					Name:   "receiver1", | 					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 | 			return expected, nil | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
| 		rc := testReqCtx("GET") | 		rc := testReqCtx("GET") | ||||||
| 		resp := handler.handleRouteGetReceiver(&rc, "receiver1") | 		resp := handler.handleRouteGetReceiver(&rc, "receiver1") | ||||||
| 		require.Equal(t, http.StatusOK, resp.Status()) | 		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.NoError(t, err) | ||||||
| 		require.Equal(t, json, resp.Body()) | 		require.Equal(t, json, resp.Body()) | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	t.Run("builds query from request context and url param", func(t *testing.T) { | 	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) { | 		fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) { | ||||||
| 			return definitions.GettableApiReceiver{}, nil | 			return &models.Receiver{}, nil | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
| 		rc := testReqCtx("GET") | 		rc := testReqCtx("GET") | ||||||
|  | @ -80,8 +77,8 @@ func TestRouteGetReceiver(t *testing.T) { | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	t.Run("should pass along not found response", func(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) { | 		fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) { | ||||||
| 			return definitions.GettableApiReceiver{}, notifier.ErrReceiverNotFound.Errorf("") | 			return nil, legacy_storage.ErrReceiverNotFound.Errorf("") | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
| 		rc := testReqCtx("GET") | 		rc := testReqCtx("GET") | ||||||
|  | @ -90,8 +87,8 @@ func TestRouteGetReceiver(t *testing.T) { | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	t.Run("should pass along permission denied response", func(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) { | 		fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) { | ||||||
| 			return definitions.GettableApiReceiver{}, ac.ErrAuthorizationBase.Errorf("") | 			return nil, ac.ErrAuthorizationBase.Errorf("") | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
| 		rc := testReqCtx("GET") | 		rc := testReqCtx("GET") | ||||||
|  | @ -104,23 +101,19 @@ func TestRouteGetReceivers(t *testing.T) { | ||||||
| 	fakeReceiverSvc := fakes.NewFakeReceiverService() | 	fakeReceiverSvc := fakes.NewFakeReceiverService() | ||||||
| 
 | 
 | ||||||
| 	t.Run("returns expected model", func(t *testing.T) { | 	t.Run("returns expected model", func(t *testing.T) { | ||||||
| 		expected := []definitions.GettableApiReceiver{ | 		expected := []*models.Receiver{ | ||||||
| 			{ | 			{ | ||||||
| 				Receiver: am_config.Receiver{ |  | ||||||
| 				Name: "receiver1", | 				Name: "receiver1", | ||||||
| 				}, | 				Integrations: []*models.Integration{ | ||||||
| 				GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ |  | ||||||
| 					GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{ |  | ||||||
| 					{ | 					{ | ||||||
| 						UID:    "uid1", | 						UID:    "uid1", | ||||||
| 						Name:   "receiver1", | 						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 | 			return expected, nil | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
|  | @ -128,14 +121,16 @@ func TestRouteGetReceivers(t *testing.T) { | ||||||
| 		rc.Context.Req.Form.Set("names", "receiver1") | 		rc.Context.Req.Form.Set("names", "receiver1") | ||||||
| 		resp := handler.handleRouteGetReceivers(&rc) | 		resp := handler.handleRouteGetReceivers(&rc) | ||||||
| 		require.Equal(t, http.StatusOK, resp.Status()) | 		require.Equal(t, http.StatusOK, resp.Status()) | ||||||
| 		json, err := json.Marshal(expected) | 		gettables, err := GettableApiReceiversFromReceivers(expected) | ||||||
| 		require.NoError(t, err) | 		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) { | 	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) { | 		fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) { | ||||||
| 			return []definitions.GettableApiReceiver{}, nil | 			return nil, nil | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
| 		rc := testReqCtx("GET") | 		rc := testReqCtx("GET") | ||||||
|  | @ -159,7 +154,7 @@ func TestRouteGetReceivers(t *testing.T) { | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	t.Run("should pass along permission denied response", func(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("") | 			return nil, ac.ErrAuthorizationBase.Errorf("") | ||||||
| 		} | 		} | ||||||
| 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | 		handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) | ||||||
|  | @ -221,7 +216,7 @@ func TestRouteGetReceiversResponses(t *testing.T) { | ||||||
| 					{limit: 4, offset: 0, expected: expected[:4]}, | 					{limit: 4, offset: 0, expected: expected[:4]}, | ||||||
| 					{limit: 1, offset: 1, expected: expected[1:2]}, | 					{limit: 1, offset: 1, expected: expected[1:2]}, | ||||||
| 					{limit: 2, offset: 2, expected: expected[2:4]}, | 					{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: 0, expected: expected}, | ||||||
| 					{limit: 0, offset: 1, expected: expected[1:]}, | 					{limit: 0, offset: 1, expected: expected[1:]}, | ||||||
| 				} | 				} | ||||||
|  | @ -237,7 +232,7 @@ func TestRouteGetReceiversResponses(t *testing.T) { | ||||||
| 						err := json.Unmarshal(response.Body(), &configs) | 						err := json.Unmarshal(response.Body(), &configs) | ||||||
| 						require.NoError(t, err) | 						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) { | 		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":{}}]}` | 			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":{}}]}` | 			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) { | 			t.Run("decrypt false", func(t *testing.T) { | ||||||
| 				env := createTestEnv(t, testContactPointConfig) | 				env := createTestEnv(t, testContactPointConfig) | ||||||
| 				sut := createNotificationSrvSutFromEnv(t, &env) | 				sut := createNotificationSrvSutFromEnv(t, &env) | ||||||
|  | @ -375,6 +370,7 @@ func createNotificationSrvSutFromEnv(t *testing.T, env *testEnvironment) Notific | ||||||
| 		ac.NewReceiverAccess[*models.Receiver](env.ac, false), | 		ac.NewReceiverAccess[*models.Receiver](env.ac, false), | ||||||
| 		legacy_storage.NewAlertmanagerConfigStore(env.configs), | 		legacy_storage.NewAlertmanagerConfigStore(env.configs), | ||||||
| 		env.prov, | 		env.prov, | ||||||
|  | 		env.store, | ||||||
| 		env.secrets, | 		env.secrets, | ||||||
| 		env.xact, | 		env.xact, | ||||||
| 		env.log, | 		env.log, | ||||||
|  |  | ||||||
|  | @ -1632,7 +1632,7 @@ func TestProvisioningApiContactPointExport(t *testing.T) { | ||||||
| 		}) | 		}) | ||||||
| 
 | 
 | ||||||
| 		t.Run("json body content is as expected", func(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) { | 			t.Run("decrypt false", func(t *testing.T) { | ||||||
| 				env := createTestEnv(t, testContactPointConfig) | 				env := createTestEnv(t, testContactPointConfig) | ||||||
| 				sut := createProvisioningSrvSutFromEnv(t, &env) | 				sut := createProvisioningSrvSutFromEnv(t, &env) | ||||||
|  | @ -1685,14 +1685,14 @@ func TestProvisioningApiContactPointExport(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| 				response := sut.RouteGetContactPointsExport(&rc) | 				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, 200, response.Status()) | ||||||
| 				require.Equal(t, expectedResponse, string(response.Body())) | 				require.Equal(t, expectedResponse, string(response.Body())) | ||||||
| 			}) | 			}) | ||||||
| 		}) | 		}) | ||||||
| 
 | 
 | ||||||
| 		t.Run("yaml body content is as expected", func(t *testing.T) { | 		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) { | 			t.Run("decrypt false", func(t *testing.T) { | ||||||
| 				env := createTestEnv(t, testContactPointConfig) | 				env := createTestEnv(t, testContactPointConfig) | ||||||
| 				sut := createProvisioningSrvSutFromEnv(t, &env) | 				sut := createProvisioningSrvSutFromEnv(t, &env) | ||||||
|  | @ -1745,7 +1745,7 @@ func TestProvisioningApiContactPointExport(t *testing.T) { | ||||||
| 
 | 
 | ||||||
| 				response := sut.RouteGetContactPointsExport(&rc) | 				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, 200, response.Status()) | ||||||
| 				require.Equal(t, expectedResponse, string(response.Body())) | 				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), | 		ac.NewReceiverAccess[*models.Receiver](env.ac, true), | ||||||
| 		configStore, | 		configStore, | ||||||
| 		env.prov, | 		env.prov, | ||||||
|  | 		env.store, | ||||||
| 		env.secrets, | 		env.secrets, | ||||||
| 		env.xact, | 		env.xact, | ||||||
| 		env.log, | 		env.log, | ||||||
|  | @ -2301,10 +2302,11 @@ var testContactPointConfig = ` | ||||||
|             "disableResolveMessage":false, |             "disableResolveMessage":false, | ||||||
|             "settings":{ |             "settings":{ | ||||||
|                "avatar_url":"some avatar", |                "avatar_url":"some avatar", | ||||||
|                "url":"some url", |  | ||||||
|                "use_discord_username":true |                "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), | 			ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), | ||||||
| 		) | 		) | ||||||
| 	case http.MethodGet + "/api/v1/notifications/receivers/{Name}": | 	case http.MethodGet + "/api/v1/notifications/receivers/{Name}": | ||||||
| 		// TODO: scope to :Name
 |  | ||||||
| 		eval = ac.EvalAny( | 		eval = ac.EvalAny( | ||||||
| 			ac.EvalPermission(ac.ActionAlertingReceiversRead), | 			ac.EvalPermission(ac.ActionAlertingReceiversRead), | ||||||
| 			ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), | 			ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ import ( | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	jsoniter "github.com/json-iterator/go" | 	jsoniter "github.com/json-iterator/go" | ||||||
|  | 	amConfig "github.com/prometheus/alertmanager/config" | ||||||
| 	"github.com/prometheus/common/model" | 	"github.com/prometheus/common/model" | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" | 	"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" | ||||||
|  | @ -501,3 +502,56 @@ func ApiRecordFromModelRecord(r *models.Record) *definitions.Record { | ||||||
| 		From:   r.From, | 		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 | 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.
 | // GetReceiverQuery represents a query for a single receiver.
 | ||||||
| type GetReceiverQuery struct { | type GetReceiverQuery struct { | ||||||
|  | @ -30,15 +45,430 @@ type ListReceiversQuery struct { | ||||||
| type Receiver struct { | type Receiver struct { | ||||||
| 	UID          string | 	UID          string | ||||||
| 	Name         string | 	Name         string | ||||||
| 	Integrations []*notify.GrafanaIntegrationConfig | 	Integrations []*Integration | ||||||
| 	Provenance   Provenance | 	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.
 | // Identified describes a class of resources that have a UID. Created to abstract required fields for authorization.
 | ||||||
| type Identified interface { | type Identified interface { | ||||||
| 	GetUID() string | 	GetUID() string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (r Receiver) GetUID() string { | func (r *Receiver) GetUID() string { | ||||||
| 	return r.UID | 	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 | package models | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"encoding/base64" | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"math/rand" | 	"math/rand" | ||||||
|  | @ -10,11 +11,13 @@ import ( | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/go-openapi/strfmt" | 	"github.com/go-openapi/strfmt" | ||||||
|  | 	alertingNotify "github.com/grafana/alerting/notify" | ||||||
| 	"github.com/grafana/grafana-plugin-sdk-go/data" | 	"github.com/grafana/grafana-plugin-sdk-go/data" | ||||||
| 	amv2 "github.com/prometheus/alertmanager/api/v2/models" | 	amv2 "github.com/prometheus/alertmanager/api/v2/models" | ||||||
| 	"github.com/prometheus/alertmanager/pkg/labels" | 	"github.com/prometheus/alertmanager/pkg/labels" | ||||||
| 	"github.com/prometheus/common/model" | 	"github.com/prometheus/common/model" | ||||||
| 	"github.com/stretchr/testify/require" | 	"github.com/stretchr/testify/require" | ||||||
|  | 	"golang.org/x/exp/maps" | ||||||
| 
 | 
 | ||||||
| 	alertingModels "github.com/grafana/alerting/models" | 	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) { | func ConvertToRecordingRule(rule *AlertRule) { | ||||||
| 	if rule.Record == nil { | 	if rule.Record == nil { | ||||||
| 		rule.Record = &Record{} | 		rule.Record = &Record{} | ||||||
|  | @ -1108,3 +1325,7 @@ func ConvertToRecordingRule(rule *AlertRule) { | ||||||
| 	rule.For = 0 | 	rule.For = 0 | ||||||
| 	rule.NotificationSettings = nil | 	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), | 		ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, false), | ||||||
| 		configStore, | 		configStore, | ||||||
| 		ng.store, | 		ng.store, | ||||||
|  | 		ng.store, | ||||||
| 		ng.SecretsService, | 		ng.SecretsService, | ||||||
| 		ng.store, | 		ng.store, | ||||||
| 		ng.Log, | 		ng.Log, | ||||||
|  | @ -427,6 +428,7 @@ func (ng *AlertNG) init() error { | ||||||
| 		ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true), | 		ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true), | ||||||
| 		configStore, | 		configStore, | ||||||
| 		ng.store, | 		ng.store, | ||||||
|  | 		ng.store, | ||||||
| 		ng.SecretsService, | 		ng.SecretsService, | ||||||
| 		ng.store, | 		ng.store, | ||||||
| 		ng.Log, | 		ng.Log, | ||||||
|  |  | ||||||
|  | @ -1601,7 +1601,7 @@ func GetAvailableNotifiers() []*NotifierPlugin { | ||||||
| func GetSecretKeysForContactPointType(contactPointType string) ([]string, error) { | func GetSecretKeysForContactPointType(contactPointType string) ([]string, error) { | ||||||
| 	notifiers := GetAvailableNotifiers() | 	notifiers := GetAvailableNotifiers() | ||||||
| 	for _, n := range notifiers { | 	for _, n := range notifiers { | ||||||
| 		if n.Type == contactPointType { | 		if strings.EqualFold(n.Type, contactPointType) { | ||||||
| 			var secureFields []string | 			var secureFields []string | ||||||
| 			for _, field := range n.Options { | 			for _, field := range n.Options { | ||||||
| 				if field.Secure { | 				if field.Secure { | ||||||
|  | @ -1613,3 +1613,14 @@ func GetSecretKeysForContactPointType(contactPointType string) ([]string, error) | ||||||
| 	} | 	} | ||||||
| 	return nil, fmt.Errorf("no secrets configured for type '%s'", contactPointType) | 	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 ( | import ( | ||||||
| 	"encoding/json" | 	"encoding/json" | ||||||
| 
 | 	"fmt" | ||||||
| 	"github.com/prometheus/alertmanager/config" |  | ||||||
| 
 | 
 | ||||||
| 	alertingNotify "github.com/grafana/alerting/notify" | 	alertingNotify "github.com/grafana/alerting/notify" | ||||||
| 	alertingTemplates "github.com/grafana/alerting/templates" | 	alertingTemplates "github.com/grafana/alerting/templates" | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/components/simplejson" |  | ||||||
| 	apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" | 	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/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 { | func PostableGrafanaReceiverToGrafanaIntegrationConfig(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaIntegrationConfig { | ||||||
| 	return &alertingNotify.GrafanaIntegrationConfig{ | 	return &alertingNotify.GrafanaIntegrationConfig{ | ||||||
| 		UID:                   p.UID, | 		UID:                   p.UID, | ||||||
|  | @ -46,76 +132,6 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf | ||||||
| 	return apiReceivers | 	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.
 | // ToTemplateDefinitions converts the given PostableUserConfig's TemplateFiles to a slice of TemplateDefinitions.
 | ||||||
| func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplates.TemplateDefinition { | func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplates.TemplateDefinition { | ||||||
| 	out := make([]alertingTemplates.TemplateDefinition, 0, len(cfg.TemplateFiles)) | 	out := make([]alertingTemplates.TemplateDefinition, 0, len(cfg.TemplateFiles)) | ||||||
|  |  | ||||||
|  | @ -2,6 +2,13 @@ package legacy_storage | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"encoding/base64" | 	"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 { | func NameToUid(name string) string { | ||||||
|  | @ -15,3 +22,42 @@ func UidToName(uid string) (string, error) { | ||||||
| 	} | 	} | ||||||
| 	return string(data), nil | 	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 ( | var ( | ||||||
| 	ErrNoAlertmanagerConfiguration  = errutil.Internal("alerting.notification.configMissing", errutil.WithPublicMessage("No alertmanager configuration present in this organization")) | 	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.")) | 	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 { | func makeErrBadAlertmanagerConfiguration(err error) error { | ||||||
|  | @ -16,3 +23,13 @@ func makeErrBadAlertmanagerConfiguration(err error) error { | ||||||
| 	} | 	} | ||||||
| 	return ErrBadAlertmanagerConfiguration.Build(data) | 	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 | package legacy_storage | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
| 	"slices" | 	"slices" | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" | 	"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) { | 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 { | func (rev *ConfigRevision) ReceiverNameUsedByRoutes(name string) bool { | ||||||
| 	return isReceiverInUse(name, []*definitions.Route{rev.Config.AlertmanagerConfig.Route}) | 	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 { | 	for _, r := range rev.Config.AlertmanagerConfig.Receivers { | ||||||
| 		if NameToUid(r.GetName()) == uid { | 		if NameToUid(r.GetName()) == uid { | ||||||
| 			return r | 			return r, nil | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	return nil | 	return nil, ErrReceiverNotFound.Errorf("") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableApiReceiver { | func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableApiReceiver { | ||||||
|  | @ -36,6 +93,52 @@ func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableAp | ||||||
| 	return receivers | 	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.
 | // isReceiverInUse checks if a receiver is used in a route or any of its sub-routes.
 | ||||||
| func isReceiverInUse(name string, routes []*definitions.Route) bool { | func isReceiverInUse(name string, routes []*definitions.Route) bool { | ||||||
| 	if len(routes) == 0 { | 	if len(routes) == 0 { | ||||||
|  | @ -51,3 +154,15 @@ func isReceiverInUse(name string, routes []*definitions.Route) bool { | ||||||
| 	} | 	} | ||||||
| 	return false | 	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 ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"encoding/base64" | 	"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/errutil" | ||||||
| 	"github.com/grafana/grafana/pkg/apimachinery/identity" | 	"github.com/grafana/grafana/pkg/apimachinery/identity" | ||||||
|  | @ -17,8 +20,14 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| var ( | var ( | ||||||
| 	ErrReceiverNotFound = errutil.NotFound("alerting.notifications.receiver.notFound") | 	ErrReceiverInUse = errutil.Conflict("alerting.notifications.receivers.used").MustTemplate( | ||||||
| 	ErrReceiverInUse    = errutil.Conflict("alerting.notifications.receiver.used").MustTemplate("Receiver is used by notification policies or alert rules") | 		"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.
 | // ReceiverService is the service for managing alertmanager receivers.
 | ||||||
|  | @ -26,17 +35,34 @@ type ReceiverService struct { | ||||||
| 	authz                  receiverAccessControlService | 	authz                  receiverAccessControlService | ||||||
| 	provisioningStore      provisoningStore | 	provisioningStore      provisoningStore | ||||||
| 	cfgStore               alertmanagerConfigStore | 	cfgStore               alertmanagerConfigStore | ||||||
| 	encryptionService secrets.Service | 	ruleNotificationsStore alertRuleNotificationSettingsStore | ||||||
|  | 	encryptionService      secretService | ||||||
| 	xact                   transactionManager | 	xact                   transactionManager | ||||||
| 	log                    log.Logger | 	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.
 | // receiverAccessControlService provides access control for receivers.
 | ||||||
| type receiverAccessControlService interface { | 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) | 	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 { | type alertmanagerConfigStore interface { | ||||||
|  | @ -58,7 +84,8 @@ func NewReceiverService( | ||||||
| 	authz receiverAccessControlService, | 	authz receiverAccessControlService, | ||||||
| 	cfgStore alertmanagerConfigStore, | 	cfgStore alertmanagerConfigStore, | ||||||
| 	provisioningStore provisoningStore, | 	provisioningStore provisoningStore, | ||||||
| 	encryptionService secrets.Service, | 	ruleNotificationsStore alertRuleNotificationSettingsStore, | ||||||
|  | 	encryptionService secretService, | ||||||
| 	xact transactionManager, | 	xact transactionManager, | ||||||
| 	log log.Logger, | 	log log.Logger, | ||||||
| ) *ReceiverService { | ) *ReceiverService { | ||||||
|  | @ -66,53 +93,51 @@ func NewReceiverService( | ||||||
| 		authz:                  authz, | 		authz:                  authz, | ||||||
| 		provisioningStore:      provisioningStore, | 		provisioningStore:      provisioningStore, | ||||||
| 		cfgStore:               cfgStore, | 		cfgStore:               cfgStore, | ||||||
|  | 		ruleNotificationsStore: ruleNotificationsStore, | ||||||
| 		encryptionService:      encryptionService, | 		encryptionService:      encryptionService, | ||||||
| 		xact:                   xact, | 		xact:                   xact, | ||||||
| 		log:                    log, | 		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.
 | // GetReceiver returns a receiver by name.
 | ||||||
| // The receiver's secure settings are decrypted if requested and the user has access to do so.
 | // 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) | 	revision, err := rs.cfgStore.Get(ctx, q.OrgID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return definitions.GettableApiReceiver{}, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 	postable := revision.GetReceiver(legacy_storage.NameToUid(q.Name)) | 	postable, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name)) | ||||||
| 	if postable == nil { |  | ||||||
| 		return definitions.GettableApiReceiver{}, ErrReceiverNotFound.Errorf("") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	decrypt, err := rs.shouldDecrypt(ctx, user, q.Decrypt) |  | ||||||
| 	if err != nil { | 	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()) | 	storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, (&definitions.EmbeddedContactPoint{}).ResourceType()) | ||||||
| 	if err != nil { | 	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.
 | // 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.
 | // 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)) | 	uids := make([]string, 0, len(q.Names)) | ||||||
| 	for _, name := range q.Names { | 	for _, name := range q.Names { | ||||||
| 		uids = append(uids, legacy_storage.NameToUid(name)) | 		uids = append(uids, legacy_storage.NameToUid(name)) | ||||||
|  | @ -128,41 +153,25 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 	receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances) | ||||||
| 	decrypt, err := rs.shouldDecrypt(ctx, user, q.Decrypt) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		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 { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// User doesn't have any permissions on the receivers.
 | 	for _, r := range filtered { | ||||||
| 	// This is mostly a safeguard as it should not be possible with current API endpoints + middleware authentication.
 | 		rs.decryptOrRedactSecureSettings(ctx, r, q.Decrypt) | ||||||
| 	if !readRedactedAccess { |  | ||||||
| 		return nil, nil |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var output []definitions.GettableApiReceiver | 	return limitOffset(filtered, q.Offset, q.Limit), nil | ||||||
| 	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 |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ListReceivers returns a list of receivers a user has access to.
 | // 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.
 | // 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
 | // 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.
 | // 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) | 	listAccess, err := rs.authz.HasList(ctx, user) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	readRedactedAccess, err := rs.authz.HasReadAll(ctx, user) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, err |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	uids := make([]string, 0, len(q.Names)) | 	uids := make([]string, 0, len(q.Names)) | ||||||
| 	for _, name := range q.Names { | 	for _, name := range q.Names { | ||||||
| 		uids = append(uids, legacy_storage.NameToUid(name)) | 		uids = append(uids, legacy_storage.NameToUid(name)) | ||||||
|  | @ -196,66 +200,75 @@ func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListRecei | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 	receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances) | ||||||
| 	// 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) |  | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, err | 		return nil, err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 		output = append(output, res) | 	if !listAccess { | ||||||
| 		// stop if we have reached the limit or we have found all the requested receivers
 | 		var err error | ||||||
| 		if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) { | 		receivers, err = rs.authz.FilterRead(ctx, user, receivers...) | ||||||
| 			break | 		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.
 | // 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.
 | // 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 { | func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, callerProvenance definitions.Provenance, version string, orgID int64, user identity.Requester) error { | ||||||
| 	//TODO: Check delete permissions.
 | 	if err := rs.authz.AuthorizeDeleteByUID(ctx, user, uid); err != nil { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
| 	revision, err := rs.cfgStore.Get(ctx, orgID) | 	revision, err := rs.cfgStore.Get(ctx, orgID) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	postable := revision.GetReceiver(uid) | 	postable, err := revision.GetReceiver(uid) | ||||||
| 	if postable == nil { | 	if err != nil { | ||||||
| 		return ErrReceiverNotFound.Errorf("") | 		if errors.Is(err, legacy_storage.ErrReceiverNotFound) { | ||||||
|  | 			return nil | ||||||
|  | 		} | ||||||
|  | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// TODO: Implement + check optimistic concurrency.
 | 	storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType()) | ||||||
| 
 | 	if err != nil { | ||||||
| 	storedProvenance, err := rs.getContactPointProvenance(ctx, postable, orgID) | 		return err | ||||||
|  | 	} | ||||||
|  | 	existing, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		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 | 		return err | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	usedByRoutes := revision.ReceiverNameUsedByRoutes(postable.GetName()) | 	usedByRoutes := revision.ReceiverNameUsedByRoutes(existing.Name) | ||||||
| 	usedByRules, err := rs.UsedByRules(ctx, orgID, uid) | 	usedByRules, err := rs.UsedByRules(ctx, orgID, existing.Name) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
|  | @ -271,26 +284,172 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, orgID | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return err | 			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) { | func (rs *ReceiverService) CreateReceiver(ctx context.Context, r *models.Receiver, orgID int64, user identity.Requester) (*models.Receiver, error) { | ||||||
| 	// TODO: Stub
 | 	if err := rs.authz.AuthorizeCreate(ctx, user); err != nil { | ||||||
| 	panic("not implemented") | 		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) { | func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*models.Receiver, error) { | ||||||
| 	// TODO: Stub
 | 	// TODO: To support receiver renaming, we need to consider permissions on old and new UID since UIDs are tied to names.
 | ||||||
| 	panic("not implemented") | 	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) { | func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, name string) ([]models.AlertRuleKey, error) { | ||||||
| 	//TODO: Implement
 | 	keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, models.ListNotificationSettingsQuery{OrgID: orgID, ReceiverName: name}) | ||||||
| 	return []models.AlertRuleKey{}, nil | 	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.
 | 	// Delete provenance for all integrations.
 | ||||||
| 	for _, integration := range integrations { | 	for _, integration := range integrations { | ||||||
| 		target := definitions.EmbeddedContactPoint{UID: integration.UID} | 		target := definitions.EmbeddedContactPoint{UID: integration.UID} | ||||||
|  | @ -301,47 +460,73 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, name, fallback string) func(value string) string { | func (rs *ReceiverService) decryptOrRedactSecureSettings(ctx context.Context, recv *models.Receiver, decrypt bool) { | ||||||
| 	return func(value string) string { | 	if decrypt { | ||||||
| 		if !decrypt { | 		err := recv.Decrypt(rs.decryptor(ctx)) | ||||||
| 			return definitions.RedactedValue |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		decoded, err := base64.StdEncoding.DecodeString(value) |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			rs.log.Warn("failed to decode secure setting", "name", name, "error", err) | 			rs.log.Warn("failed to decrypt secure settings", "name", recv.Name, "error", err) | ||||||
| 			return fallback |  | ||||||
| 		} | 		} | ||||||
| 		decrypted, err := rs.encryptionService.Decrypt(ctx, decoded) | 	} else { | ||||||
| 		if err != nil { | 		recv.Redact(rs.redactor()) | ||||||
| 			rs.log.Warn("failed to decrypt secure setting", "name", name, "error", err) |  | ||||||
| 			return fallback |  | ||||||
| 		} |  | ||||||
| 		return string(decrypted) |  | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // getContactPointProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
 | // decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used.
 | ||||||
| func (rs *ReceiverService) getContactPointProvenance(ctx context.Context, r *definitions.PostableApiReceiver, orgID int64) (models.Provenance, error) { | func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn { | ||||||
| 	if len(r.GrafanaManagedReceivers) == 0 { | 	return func(value string) (string, error) { | ||||||
| 		return models.ProvenanceNone, nil | 		decoded, err := base64.StdEncoding.DecodeString(value) | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType()) |  | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			return "", err | 			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
 | // redactor returns a models.RedactFn that redacts a secure setting.
 | ||||||
| 	// entire receiver. All integrations in a receiver should have the same provenance, but we don't want to rely on
 | func (rs *ReceiverService) redactor() models.RedactFn { | ||||||
| 	// this assumption in case the first provenance is None and a later one is not. To this end, we return the first
 | 	return func(value string) string { | ||||||
| 	// non-zero provenance we find.
 | 		return definitions.RedactedValue | ||||||
| 	for _, contactPoint := range r.GrafanaManagedReceivers { |  | ||||||
| 		if p, exists := storedProvenances[contactPoint.UID]; exists && p != models.ProvenanceNone { |  | ||||||
| 			return p, nil |  | ||||||
| 	} | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result.
 | ||||||
|  | func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn { | ||||||
|  | 	return func(payload string) (string, error) { | ||||||
|  | 		s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope()) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return "", err | ||||||
| 		} | 		} | ||||||
| 	return 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 { | func makeReceiverInUseErr(usedByRoutes bool, rules []models.AlertRuleKey) error { | ||||||
|  | @ -349,16 +534,34 @@ func makeReceiverInUseErr(usedByRoutes bool, rules []models.AlertRuleKey) error | ||||||
| 	for _, key := range rules { | 	for _, key := range rules { | ||||||
| 		uids = append(uids, key.UID) | 		uids = append(uids, key.UID) | ||||||
| 	} | 	} | ||||||
| 	data := make(map[string]any, 2) | 
 | ||||||
|  | 	var usedBy []string | ||||||
|  | 	data := make(map[string]any) | ||||||
| 	if len(uids) > 0 { | 	if len(uids) > 0 { | ||||||
|  | 		usedBy = append(usedBy, fmt.Sprintf("%d rule(s)", len(uids))) | ||||||
| 		data["UsedByRules"] = uids | 		data["UsedByRules"] = uids | ||||||
| 	} | 	} | ||||||
| 	if usedByRoutes { | 	if usedByRoutes { | ||||||
|  | 		usedBy = append(usedBy, "one or more routes") | ||||||
| 		data["UsedByRoutes"] = true | 		data["UsedByRoutes"] = true | ||||||
| 	} | 	} | ||||||
|  | 	if len(usedBy) > 0 { | ||||||
|  | 		data["UsedBy"] = strings.Join(usedBy, ", ") | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	return ErrReceiverInUse.Build(errutil.TemplateData{ | 	return ErrReceiverInUse.Build(errutil.TemplateData{ | ||||||
| 		Public: data, | 		Public: data, | ||||||
| 		Error:  nil, | 		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 | 	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.
 | // Saves the image or returns an error.
 | ||||||
| func (f *fakeConfigStore) SaveImage(ctx context.Context, img *models.Image) error { | func (f *fakeConfigStore) SaveImage(ctx context.Context, img *models.Image) error { | ||||||
| 	return alertingImages.ErrImageNotFound | 	return alertingImages.ErrImageNotFound | ||||||
|  |  | ||||||
|  | @ -46,28 +46,22 @@ func PostableGrafanaReceiverToEmbeddedContactPoint(contactPoint *definitions.Pos | ||||||
| 	return embeddedContactPoint, nil | 	return embeddedContactPoint, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func GettableGrafanaReceiverToEmbeddedContactPoint(r *definitions.GettableGrafanaReceiver) (definitions.EmbeddedContactPoint, error) { | func GrafanaIntegrationConfigToEmbeddedContactPoint(r *models.Integration, provenance models.Provenance) definitions.EmbeddedContactPoint { | ||||||
| 	settingJson := simplejson.New() | 	settingJson := simplejson.New() | ||||||
| 	if r.Settings != nil { | 	if r.Settings != nil { | ||||||
| 		var err error | 		settingJson = simplejson.NewFromAny(r.Settings) | ||||||
| 		settingJson, err = simplejson.NewJson(r.Settings) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return definitions.EmbeddedContactPoint{}, err |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for k := range r.SecureFields { | 	// We explicitly do not copy the secure settings to the settings field. This is because the provisioning API
 | ||||||
| 		if settingJson.Get(k).MustString() == "" { | 	// never returns decrypted or encrypted values, only redacted values. Redacted values should already exist in the
 | ||||||
| 			settingJson.Set(k, definitions.RedactedValue) | 	// settings field.
 | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	return definitions.EmbeddedContactPoint{ | 	return definitions.EmbeddedContactPoint{ | ||||||
| 		UID:                   r.UID, | 		UID:                   r.UID, | ||||||
| 		Name:                  r.Name, | 		Name:                  r.Name, | ||||||
| 		Type:                  r.Type, | 		Type:                  r.Config.Type, | ||||||
| 		DisableResolveMessage: r.DisableResolveMessage, | 		DisableResolveMessage: r.DisableResolveMessage, | ||||||
| 		Settings:              settingJson, | 		Settings:              settingJson, | ||||||
| 		Provenance:            string(r.Provenance), | 		Provenance:            string(provenance), | ||||||
| 	}, nil | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -40,7 +40,7 @@ type ContactPointService struct { | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type receiverService interface { | 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, | func NewContactPointService(store alertmanagerConfigStore, encryptionService secrets.Service, | ||||||
|  | @ -79,23 +79,15 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, convertRecSvcErr(err) | 		return nil, convertRecSvcErr(err) | ||||||
| 	} | 	} | ||||||
| 	grafanaReceivers := []*apimodels.GettableGrafanaReceiver{} |  | ||||||
| 	if q.Name != "" && len(res) > 0 { | 	if q.Name != "" && len(res) > 0 { | ||||||
| 		grafanaReceivers = res[0].GettableGrafanaReceivers.GrafanaManagedReceivers // we only expect one receiver group
 | 		res = []*models.Receiver{res[0]} // we only expect one receiver group
 | ||||||
| 	} else { |  | ||||||
| 		for _, r := range res { |  | ||||||
| 			grafanaReceivers = append(grafanaReceivers, r.GettableGrafanaReceivers.GrafanaManagedReceivers...) |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	contactPoints := make([]apimodels.EmbeddedContactPoint, len(grafanaReceivers)) | 	contactPoints := make([]apimodels.EmbeddedContactPoint, 0, len(res)) | ||||||
| 	for i, gr := range grafanaReceivers { | 	for _, recv := range res { | ||||||
| 		contactPoint, err := GettableGrafanaReceiverToEmbeddedContactPoint(gr) | 		for _, gr := range recv.Integrations { | ||||||
| 		if err != nil { | 			contactPoints = append(contactPoints, GrafanaIntegrationConfigToEmbeddedContactPoint(gr, recv.Provenance)) | ||||||
| 			return nil, err |  | ||||||
| 		} | 		} | ||||||
| 
 |  | ||||||
| 		contactPoints[i] = contactPoint |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	sort.SliceStable(contactPoints, func(i, j int) bool { | 	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.
 | 				// 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!
 | 				// Firstly, if we're the only receiver in the group, simply rename the group to match. Done!
 | ||||||
| 				if len(receiverGroup.GrafanaManagedReceivers) == 1 { | 				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.Name = target.Name | ||||||
| 					receiverGroup.GrafanaManagedReceivers[i] = target | 					receiverGroup.GrafanaManagedReceivers[i] = target | ||||||
| 					renamedReceiver = receiverGroup.Name | 					renamedReceiver = receiverGroup.Name | ||||||
|  | @ -476,38 +468,12 @@ groupLoop: | ||||||
| 	return configModified, renamedReceiver | 	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 { | 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) | 	integration, err := EmbeddedContactPointToGrafanaIntegrationConfig(e) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return err | ||||||
| 	} | 	} | ||||||
| 	_, err = alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{ | 	return models.ValidateIntegration(ctx, integration, decryptFunc) | ||||||
| 		GrafanaIntegrations: alertingNotify.GrafanaIntegrations{ |  | ||||||
| 			Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration}, |  | ||||||
| 		}, |  | ||||||
| 	}, decryptFunc) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return err |  | ||||||
| 	} |  | ||||||
| 	return nil |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // 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.
 | // 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), | 		ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), true), | ||||||
| 		legacy_storage.NewAlertmanagerConfigStore(configStore), | 		legacy_storage.NewAlertmanagerConfigStore(configStore), | ||||||
| 		provisioningStore, | 		provisioningStore, | ||||||
|  | 		notifier.NewFakeConfigStore(t, nil), | ||||||
| 		secretService, | 		secretService, | ||||||
| 		xact, | 		xact, | ||||||
| 		log.NewNopLogger(), | 		log.NewNopLogger(), | ||||||
|  |  | ||||||
|  | @ -4,7 +4,6 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 
 | 
 | ||||||
| 	"github.com/grafana/grafana/pkg/apimachinery/identity" | 	"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" | 	"github.com/grafana/grafana/pkg/services/ngalert/models" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | @ -15,8 +14,8 @@ type ReceiverServiceMethodCall struct { | ||||||
| 
 | 
 | ||||||
| type FakeReceiverService struct { | type FakeReceiverService struct { | ||||||
| 	MethodCalls     []ReceiverServiceMethodCall | 	MethodCalls     []ReceiverServiceMethodCall | ||||||
| 	GetReceiverFn   func(ctx context.Context, q models.GetReceiverQuery, 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) ([]definitions.GettableApiReceiver, error) | 	ListReceiversFn func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func NewFakeReceiverService() *FakeReceiverService { | 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}}) | 	f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceiver", Args: []interface{}{ctx, q}}) | ||||||
| 	return f.GetReceiverFn(ctx, q, u) | 	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}}) | 	f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "ListReceivers", Args: []interface{}{ctx, q}}) | ||||||
| 	return f.ListReceiversFn(ctx, q, u) | 	return f.ListReceiversFn(ctx, q, u) | ||||||
| } | } | ||||||
|  | @ -51,10 +50,10 @@ func (f *FakeReceiverService) Reset() { | ||||||
| 	f.ListReceiversFn = defaultReceiversFn | 	f.ListReceiversFn = defaultReceiversFn | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { | func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) { | ||||||
| 	return definitions.GettableApiReceiver{}, nil | 	return nil, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func defaultReceiversFn(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { | func defaultReceiversFn(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) { | ||||||
| 	return nil, nil | 	return nil, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -278,6 +278,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error | ||||||
| 		alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true), | 		alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true), | ||||||
| 		configStore, | 		configStore, | ||||||
| 		st, | 		st, | ||||||
|  | 		st, | ||||||
| 		ps.secretService, | 		ps.secretService, | ||||||
| 		ps.SQLStore, | 		ps.SQLStore, | ||||||
| 		ps.log, | 		ps.log, | ||||||
|  |  | ||||||
|  | @ -127,6 +127,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) { | ||||||
| 	ualert.AddStateResolvedAtColumns(mg) | 	ualert.AddStateResolvedAtColumns(mg) | ||||||
| 
 | 
 | ||||||
| 	enableTraceQLStreaming(mg, oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagTraceQLStreaming)) | 	enableTraceQLStreaming(mg, oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagTraceQLStreaming)) | ||||||
|  | 
 | ||||||
|  | 	ualert.AddReceiverActionScopesMigration(mg) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func addStarMigrations(mg *Migrator) { | 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