mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Receiver API complete core implementation (#91738)
* Replace global authz abstraction with one compatible with uid scope * Replace GettableApiReceiver with models.Receiver in receiver_svc * GrafanaIntegrationConfig -> models.Integration * Implement Create/Update methods * Add optimistic concurrency to receiver API * Add scope to ReceiversRead & ReceiversReadSecrets migrates existing permissions to include implicit global scope * Add receiver create, update, delete actions * Check if receiver is used by rules before delete * On receiver name change update in routes and notification settings * Improve errors * Linting * Include read permissions are requirements for create/update/delete * Alias ngalert/models to ngmodels to differentiate from v0alpha1 model * Ensure integration UIDs are valid, unique, and generated if empty * Validate integration settings on create/update * Leverage UidToName to GetReceiver instead of GetReceivers * Remove some unnecessary uses of simplejson * alerting.notifications.receiver -> alerting.notifications.receivers * validator -> provenanceValidator * Only validate the modified receiver stops existing invalid receivers from preventing modification of a valid receiver. * Improve error in Integration.Encrypt * Remove scope from alert.notifications.receivers:create * Add todos for receiver renaming * Use receiverAC precondition checks in k8s api * Linting * Optional optimistic concurrency for delete * make update-workspace * More specific auth checks in k8s authorize.go * Add debug log when delete optimistic concurrency is skipped * Improve error message on authorizer.DecisionDeny * Keep error for non-forbidden errutil errors
This commit is contained in:
parent
22ad1cc16f
commit
32f06c6d9c
@ -2,14 +2,26 @@ package receiver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apiserver/pkg/authorization/authorizer"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
)
|
||||
|
||||
func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
// AccessControlService provides access control for receivers.
|
||||
type AccessControlService interface {
|
||||
AuthorizeReadSome(ctx context.Context, user identity.Requester) error
|
||||
AuthorizeReadByUID(context.Context, identity.Requester, string) error
|
||||
AuthorizeCreate(context.Context, identity.Requester) error
|
||||
AuthorizeUpdateByUID(context.Context, identity.Requester, string) error
|
||||
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
|
||||
}
|
||||
|
||||
func Authorize(ctx context.Context, ac AccessControlService, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
|
||||
if attr.GetResource() != resourceInfo.GroupResource().Resource {
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
@ -18,36 +30,55 @@ func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authori
|
||||
return authorizer.DecisionDeny, "valid user is required", err
|
||||
}
|
||||
|
||||
var action accesscontrol.Evaluator
|
||||
uid := attr.GetName()
|
||||
|
||||
deny := func(err error) (authorizer.Decision, string, error) {
|
||||
var utilErr errutil.Error
|
||||
if errors.As(err, &utilErr) && utilErr.Reason.Status() == errutil.StatusForbidden {
|
||||
if errors.Is(err, accesscontrol.ErrAuthorizationBase) {
|
||||
return authorizer.DecisionDeny, fmt.Sprintf("required permissions: %s", utilErr.PublicPayload["permissions"]), nil
|
||||
}
|
||||
return authorizer.DecisionDeny, utilErr.PublicMessage, nil
|
||||
}
|
||||
|
||||
return authorizer.DecisionDeny, "", err
|
||||
}
|
||||
|
||||
switch attr.GetVerb() {
|
||||
case "get":
|
||||
if uid == "" {
|
||||
return authorizer.DecisionDeny, "", nil
|
||||
}
|
||||
if err := ac.AuthorizeReadByUID(ctx, user, uid); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
case "list":
|
||||
if err := ac.AuthorizeReadSome(ctx, user); err != nil { // Preconditions, further checks are done downstream.
|
||||
return deny(err)
|
||||
}
|
||||
case "create":
|
||||
if err := ac.AuthorizeCreate(ctx, user); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
case "patch":
|
||||
fallthrough
|
||||
case "create":
|
||||
fallthrough // TODO: Add alert.notifications.receivers:create permission
|
||||
case "update":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:write permission
|
||||
)
|
||||
case "deletecollection":
|
||||
fallthrough
|
||||
if uid == "" {
|
||||
return deny(err)
|
||||
}
|
||||
if err := ac.AuthorizeUpdateByUID(ctx, user, uid); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
case "delete":
|
||||
action = accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite), // TODO: Add alert.notifications.receivers:delete permission
|
||||
)
|
||||
if uid == "" {
|
||||
return deny(err)
|
||||
}
|
||||
if err := ac.AuthorizeDeleteByUID(ctx, user, uid); err != nil {
|
||||
return deny(err)
|
||||
}
|
||||
default:
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
}
|
||||
|
||||
eval := accesscontrol.EvalAny(
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversRead),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversReadSecrets),
|
||||
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsRead),
|
||||
)
|
||||
if action != nil {
|
||||
eval = accesscontrol.EvalAll(eval, action)
|
||||
}
|
||||
|
||||
ok, err := ac.Evaluate(ctx, user, eval)
|
||||
if ok {
|
||||
return authorizer.DecisionAllow, "", nil
|
||||
}
|
||||
return authorizer.DecisionDeny, "", err
|
||||
}
|
||||
|
@ -1,26 +1,19 @@
|
||||
package receiver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
|
||||
common "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
|
||||
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
)
|
||||
|
||||
func getUID(t definitions.GettableApiReceiver) string {
|
||||
return legacy_storage.NameToUid(t.Name)
|
||||
}
|
||||
|
||||
func convertToK8sResources(orgID int64, receivers []definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) {
|
||||
func convertToK8sResources(orgID int64, receivers []*ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.ReceiverList, error) {
|
||||
result := &model.ReceiverList{
|
||||
Items: make([]model.Receiver, 0, len(receivers)),
|
||||
}
|
||||
@ -34,67 +27,54 @@ func convertToK8sResources(orgID int64, receivers []definitions.GettableApiRecei
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func convertToK8sResource(orgID int64, receiver definitions.GettableApiReceiver, namespacer request.NamespaceMapper) (*model.Receiver, error) {
|
||||
func convertToK8sResource(orgID int64, receiver *ngmodels.Receiver, namespacer request.NamespaceMapper) (*model.Receiver, error) {
|
||||
spec := model.ReceiverSpec{
|
||||
Title: receiver.Receiver.Name,
|
||||
}
|
||||
provenance := definitions.Provenance(models.ProvenanceNone)
|
||||
for _, integration := range receiver.GrafanaManagedReceivers {
|
||||
if integration.Provenance != receiver.GrafanaManagedReceivers[0].Provenance {
|
||||
return nil, fmt.Errorf("all integrations must have the same provenance")
|
||||
}
|
||||
provenance = integration.Provenance
|
||||
unstruct := common.Unstructured{}
|
||||
err := json.Unmarshal(integration.Settings, &unstruct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", integration.Type, receiver.Name, err)
|
||||
Title: receiver.Name,
|
||||
}
|
||||
for _, integration := range receiver.Integrations {
|
||||
spec.Integrations = append(spec.Integrations, model.Integration{
|
||||
Uid: &integration.UID,
|
||||
Type: integration.Type,
|
||||
Type: integration.Config.Type,
|
||||
DisableResolveMessage: &integration.DisableResolveMessage,
|
||||
Settings: unstruct,
|
||||
SecureFields: integration.SecureFields,
|
||||
Settings: common.Unstructured{Object: maps.Clone(integration.Settings)},
|
||||
SecureFields: integration.SecureFields(),
|
||||
})
|
||||
}
|
||||
|
||||
uid := getUID(receiver) // TODO replace to stable UID when we switch to normal storage
|
||||
r := &model.Receiver{
|
||||
TypeMeta: resourceInfo.TypeMeta(),
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
UID: types.UID(uid), // This is needed to make PATCH work
|
||||
Name: uid, // TODO replace to stable UID when we switch to normal storage
|
||||
UID: types.UID(receiver.GetUID()), // This is needed to make PATCH work
|
||||
Name: receiver.GetUID(),
|
||||
Namespace: namespacer(orgID),
|
||||
ResourceVersion: "", // TODO: Implement optimistic concurrency.
|
||||
ResourceVersion: receiver.Version,
|
||||
},
|
||||
Spec: spec,
|
||||
}
|
||||
r.SetProvenanceStatus(string(provenance))
|
||||
r.SetProvenanceStatus(string(receiver.Provenance))
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiReceiver, error) {
|
||||
// TODO: Using GettableApiReceiver instead of PostableApiReceiver so that SecureFields type matches.
|
||||
gettable := definitions.GettableApiReceiver{
|
||||
Receiver: config.Receiver{
|
||||
func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) {
|
||||
domain := &ngmodels.Receiver{
|
||||
UID: legacy_storage.NameToUid(receiver.Spec.Title),
|
||||
Name: receiver.Spec.Title,
|
||||
},
|
||||
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
|
||||
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{},
|
||||
},
|
||||
Integrations: make([]*ngmodels.Integration, 0, len(receiver.Spec.Integrations)),
|
||||
Version: receiver.ResourceVersion,
|
||||
Provenance: ngmodels.ProvenanceNone,
|
||||
}
|
||||
|
||||
storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations))
|
||||
for _, integration := range receiver.Spec.Integrations {
|
||||
data, err := integration.Settings.MarshalJSON()
|
||||
config, err := ngmodels.IntegrationConfigFromType(integration.Type)
|
||||
if err != nil {
|
||||
return definitions.GettableApiReceiver{}, fmt.Errorf("integration '%s' of receiver '%s' is invalid: failed to convert unstructured data to bytes: %w", integration.Type, receiver.Name, err)
|
||||
return nil, nil, err
|
||||
}
|
||||
grafanaIntegration := definitions.GettableGrafanaReceiver{
|
||||
grafanaIntegration := ngmodels.Integration{
|
||||
Name: receiver.Spec.Title,
|
||||
Type: integration.Type,
|
||||
Settings: definitions.RawMessage(data),
|
||||
SecureFields: integration.SecureFields,
|
||||
Provenance: definitions.Provenance(models.ProvenanceNone),
|
||||
Config: config,
|
||||
Settings: maps.Clone(integration.Settings.UnstructuredContent()),
|
||||
SecureSettings: make(map[string]string),
|
||||
}
|
||||
if integration.Uid != nil {
|
||||
grafanaIntegration.UID = *integration.Uid
|
||||
@ -102,8 +82,20 @@ func convertToDomainModel(receiver *model.Receiver) (definitions.GettableApiRece
|
||||
if integration.DisableResolveMessage != nil {
|
||||
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
|
||||
}
|
||||
gettable.GettableGrafanaReceivers.GrafanaManagedReceivers = append(gettable.GettableGrafanaReceivers.GrafanaManagedReceivers, &grafanaIntegration)
|
||||
|
||||
domain.Integrations = append(domain.Integrations, &grafanaIntegration)
|
||||
|
||||
if grafanaIntegration.UID != "" {
|
||||
// This is an existing integration, so we track the secure fields being requested to copy over from existing values.
|
||||
secureFields := make([]string, 0, len(integration.SecureFields))
|
||||
for k, isSecure := range integration.SecureFields {
|
||||
if isSecure {
|
||||
secureFields = append(secureFields, k)
|
||||
}
|
||||
}
|
||||
storedSecureFields[grafanaIntegration.UID] = secureFields
|
||||
}
|
||||
}
|
||||
|
||||
return gettable, nil
|
||||
return domain, storedSecureFields, nil
|
||||
}
|
||||
|
@ -15,7 +15,8 @@ import (
|
||||
grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -25,11 +26,11 @@ var (
|
||||
var resourceInfo = notifications.ReceiverResourceInfo
|
||||
|
||||
type ReceiverService interface {
|
||||
GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error)
|
||||
GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error)
|
||||
CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
|
||||
UpdateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) // TODO: Uses Gettable for Write, consider creating new struct.
|
||||
DeleteReceiver(ctx context.Context, name string, orgID int64, provenance definitions.Provenance, version string) error
|
||||
GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error)
|
||||
CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
|
||||
DeleteReceiver(ctx context.Context, name string, provenance definitions.Provenance, version string, orgID int64, user identity.Requester) error
|
||||
}
|
||||
|
||||
type legacyStorage struct {
|
||||
@ -66,12 +67,12 @@ func (s *legacyStorage) List(ctx context.Context, _ *internalversion.ListOptions
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := models.GetReceiversQuery{
|
||||
q := ngmodels.GetReceiversQuery{
|
||||
OrgID: orgId,
|
||||
Decrypt: false,
|
||||
//Names: ctx.QueryStrings("names"), // TODO: Query params.
|
||||
//Limit: ctx.QueryInt("limit"),
|
||||
//Offset: ctx.QueryInt("offset"),
|
||||
//Decrypt: ctx.QueryBool("decrypt"),
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
@ -93,9 +94,14 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q := models.GetReceiversQuery{
|
||||
name, err := legacy_storage.UidToName(uid)
|
||||
if err != nil {
|
||||
return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid)
|
||||
}
|
||||
q := ngmodels.GetReceiverQuery{
|
||||
OrgID: info.OrgID,
|
||||
//Decrypt: ctx.QueryBool("decrypt"), // TODO: Query params.
|
||||
Name: name,
|
||||
Decrypt: false,
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
@ -103,18 +109,11 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := s.service.GetReceivers(ctx, q, user)
|
||||
r, err := s.service.GetReceiver(ctx, q, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, r := range res {
|
||||
if getUID(r) == uid {
|
||||
return convertToK8sResource(info.OrgID, r, s.namespacer)
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid)
|
||||
}
|
||||
|
||||
func (s *legacyStorage) Create(ctx context.Context,
|
||||
@ -138,11 +137,17 @@ func (s *legacyStorage) Create(ctx context.Context,
|
||||
if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user
|
||||
return nil, errors.NewBadRequest("object's metadata.name should be empty")
|
||||
}
|
||||
model, err := convertToDomainModel(p)
|
||||
model, _, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := s.service.CreateReceiver(ctx, model, info.OrgID)
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out, err := s.service.CreateReceiver(ctx, model, info.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -162,6 +167,11 @@ func (s *legacyStorage) Update(ctx context.Context,
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, uid, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
@ -179,16 +189,16 @@ func (s *legacyStorage) Update(ctx context.Context,
|
||||
if !ok {
|
||||
return nil, false, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
model, err := convertToDomainModel(p)
|
||||
model, storedSecureFields, err := convertToDomainModel(p)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
}
|
||||
|
||||
if p.ObjectMeta.Name != getUID(model) {
|
||||
if p.ObjectMeta.Name != model.GetUID() {
|
||||
return nil, false, errors.NewBadRequest("title cannot be changed. Consider creating a new resource.")
|
||||
}
|
||||
|
||||
updated, err := s.service.UpdateReceiver(ctx, model, info.OrgID)
|
||||
updated, err := s.service.UpdateReceiver(ctx, model, storedSecureFields, info.OrgID, user)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@ -203,6 +213,12 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
user, err := identity.GetRequester(ctx)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
old, err := s.Get(ctx, uid, nil)
|
||||
if err != nil {
|
||||
return old, false, err
|
||||
@ -217,7 +233,7 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
|
||||
version = *options.Preconditions.ResourceVersion
|
||||
}
|
||||
|
||||
err = s.service.DeleteReceiver(ctx, uid, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
|
||||
err = s.service.DeleteReceiver(ctx, uid, definitions.Provenance(ngmodels.ProvenanceNone), version, info.OrgID, user) // TODO add support for dry-run option
|
||||
return old, false, err // false - will be deleted async
|
||||
}
|
||||
|
||||
|
@ -24,6 +24,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert"
|
||||
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
@ -32,6 +34,7 @@ var _ builder.APIGroupBuilder = (*NotificationsAPIBuilder)(nil)
|
||||
// This is used just so wire has something unique to return
|
||||
type NotificationsAPIBuilder struct {
|
||||
authz accesscontrol.AccessControl
|
||||
receiverAuth receiver.AccessControlService
|
||||
ng *ngalert.AlertNG
|
||||
namespacer request.NamespaceMapper
|
||||
gv schema.GroupVersion
|
||||
@ -51,6 +54,7 @@ func RegisterAPIService(
|
||||
namespacer: request.GetNamespaceMapper(cfg),
|
||||
gv: notificationsModels.SchemeGroupVersion,
|
||||
authz: ng.Api.AccessControl,
|
||||
receiverAuth: ac.NewReceiverAccess[*ngmodels.Receiver](ng.Api.AccessControl, false),
|
||||
}
|
||||
apiregistration.RegisterAPI(builder)
|
||||
return builder
|
||||
@ -128,7 +132,7 @@ func (t *NotificationsAPIBuilder) GetAuthorizer() authorizer.Authorizer {
|
||||
case notificationsModels.TimeIntervalResourceInfo.GroupResource().Resource:
|
||||
return timeInterval.Authorize(ctx, t.authz, a)
|
||||
case notificationsModels.ReceiverResourceInfo.GroupResource().Resource:
|
||||
return receiver.Authorize(ctx, t.authz, a)
|
||||
return receiver.Authorize(ctx, t.receiverAuth, a)
|
||||
}
|
||||
return authorizer.DecisionNoOpinion, "", nil
|
||||
})
|
||||
|
@ -10,7 +10,7 @@ import (
|
||||
model "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func convertToK8sResources(orgID int64, intervals []definitions.MuteTimeInterval, namespacer request.NamespaceMapper, selector fields.Selector) (*model.TimeIntervalList, error) {
|
||||
@ -78,7 +78,7 @@ func convertToDomainModel(interval *model.TimeInterval) (definitions.MuteTimeInt
|
||||
}
|
||||
result.Version = interval.ResourceVersion
|
||||
result.UID = interval.ObjectMeta.Name
|
||||
result.Provenance = definitions.Provenance(models.ProvenanceNone)
|
||||
result.Provenance = definitions.Provenance(ngmodels.ProvenanceNone)
|
||||
err = result.Validate()
|
||||
if err != nil {
|
||||
return definitions.MuteTimeInterval{}, err
|
||||
|
@ -14,7 +14,7 @@ import (
|
||||
grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest"
|
||||
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -195,7 +195,7 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
|
||||
return nil, false, fmt.Errorf("expected time-interval but got %s", old.GetObjectKind().GroupVersionKind())
|
||||
}
|
||||
|
||||
err = s.service.DeleteMuteTiming(ctx, p.ObjectMeta.Name, info.OrgID, definitions.Provenance(models.ProvenanceNone), version) // TODO add support for dry-run option
|
||||
err = s.service.DeleteMuteTiming(ctx, p.ObjectMeta.Name, info.OrgID, definitions.Provenance(ngmodels.ProvenanceNone), version) // TODO add support for dry-run option
|
||||
return old, false, err // false - will be deleted async
|
||||
}
|
||||
|
||||
|
@ -447,6 +447,9 @@ const (
|
||||
ActionAlertingReceiversList = "alert.notifications.receivers:list"
|
||||
ActionAlertingReceiversRead = "alert.notifications.receivers:read"
|
||||
ActionAlertingReceiversReadSecrets = "alert.notifications.receivers.secrets:read"
|
||||
ActionAlertingReceiversCreate = "alert.notifications.receivers:create"
|
||||
ActionAlertingReceiversUpdate = "alert.notifications.receivers:write"
|
||||
ActionAlertingReceiversDelete = "alert.notifications.receivers:delete"
|
||||
|
||||
// External alerting rule actions. We can only narrow it down to writes or reads, as we don't control the atomicity in the external system.
|
||||
ActionAlertingRuleExternalWrite = "alert.rules.external:write"
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/datasources"
|
||||
ac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
)
|
||||
|
||||
@ -133,6 +134,7 @@ var (
|
||||
},
|
||||
{
|
||||
Action: accesscontrol.ActionAlertingReceiversRead,
|
||||
Scope: ac.ScopeReceiversAll,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -152,6 +154,18 @@ var (
|
||||
Action: accesscontrol.ActionAlertingNotificationsExternalWrite,
|
||||
Scope: datasources.ScopeAll,
|
||||
},
|
||||
{
|
||||
Action: accesscontrol.ActionAlertingReceiversCreate,
|
||||
Scope: ac.ScopeReceiversAll,
|
||||
},
|
||||
{
|
||||
Action: accesscontrol.ActionAlertingReceiversUpdate,
|
||||
Scope: ac.ScopeReceiversAll,
|
||||
},
|
||||
{
|
||||
Action: accesscontrol.ActionAlertingReceiversDelete,
|
||||
Scope: ac.ScopeReceiversAll,
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -30,7 +31,7 @@ func NewAuthorizationErrorGeneric(action string) error {
|
||||
}
|
||||
|
||||
// actionAccess is a helper struct that provides common access control methods for a specific resource type and action.
|
||||
type actionAccess[T any] struct {
|
||||
type actionAccess[T models.Identified] struct {
|
||||
genericService
|
||||
|
||||
// authorizeSome evaluates to true if user has access to some (any) resources.
|
||||
@ -41,7 +42,7 @@ type actionAccess[T any] struct {
|
||||
authorizeAll ac.Evaluator
|
||||
|
||||
// authorizeOne returns an evaluator that checks if user has access to a specific resource.
|
||||
authorizeOne func(T) ac.Evaluator
|
||||
authorizeOne func(models.Identified) ac.Evaluator
|
||||
|
||||
// action is the action that user is trying to perform on the resource. Used in error messages.
|
||||
action string
|
||||
@ -53,7 +54,10 @@ type actionAccess[T any] struct {
|
||||
// Filter filters the given list of resources based on access control permissions of the user.
|
||||
// This method is preferred when many resources need to be checked.
|
||||
func (s actionAccess[T]) Filter(ctx context.Context, user identity.Requester, resources ...T) ([]T, error) {
|
||||
canAll, err := s.authorizePreConditions(ctx, user)
|
||||
if err := s.AuthorizePreConditions(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -70,8 +74,11 @@ func (s actionAccess[T]) Filter(ctx context.Context, user identity.Requester, re
|
||||
}
|
||||
|
||||
// Authorize checks if user has access to a resource. Returns an error if user does not have access.
|
||||
func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester, resource T) error {
|
||||
canAll, err := s.authorizePreConditions(ctx, user)
|
||||
func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester, resource models.Identified) error {
|
||||
if err := s.AuthorizePreConditions(ctx, user); err != nil {
|
||||
return err
|
||||
}
|
||||
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
|
||||
if canAll || err != nil { // Return early if user can either access all or there is an error.
|
||||
return err
|
||||
}
|
||||
@ -80,8 +87,11 @@ func (s actionAccess[T]) Authorize(ctx context.Context, user identity.Requester,
|
||||
}
|
||||
|
||||
// Has checks if user has access to a resource. Returns false if user does not have access.
|
||||
func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resource T) (bool, error) {
|
||||
canAll, err := s.authorizePreConditions(ctx, user)
|
||||
func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resource models.Identified) (bool, error) {
|
||||
if err := s.AuthorizePreConditions(ctx, user); err != nil {
|
||||
return false, err
|
||||
}
|
||||
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
|
||||
if canAll || err != nil { // Return early if user can either access all or there is an error.
|
||||
return canAll, err
|
||||
}
|
||||
@ -89,32 +99,28 @@ func (s actionAccess[T]) Has(ctx context.Context, user identity.Requester, resou
|
||||
return s.has(ctx, user, resource)
|
||||
}
|
||||
|
||||
// authorizePreConditions checks necessary preconditions for resources. Returns true if user has access for all
|
||||
// resources. Returns error if user does not have access to on any resources.
|
||||
func (s actionAccess[T]) authorizePreConditions(ctx context.Context, user identity.Requester) (bool, error) {
|
||||
canAll, err := s.HasAccess(ctx, user, s.authorizeAll)
|
||||
if canAll || err != nil { // Return early if user can either access all or there is an error.
|
||||
return canAll, err
|
||||
}
|
||||
// AuthorizeAll checks if user has access to all resources. Returns error if user does not have access to all resources.
|
||||
func (s actionAccess[T]) AuthorizeAll(ctx context.Context, user identity.Requester) error {
|
||||
return s.HasAccessOrError(ctx, user, s.authorizeAll, func() string {
|
||||
return fmt.Sprintf("%s all %ss", s.action, s.resource)
|
||||
})
|
||||
}
|
||||
|
||||
can, err := s.HasAccess(ctx, user, s.authorizeSome)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !can { // User does not have any resource permissions at all.
|
||||
return false, NewAuthorizationErrorWithPermissions(fmt.Sprintf("%s any %s", s.action, s.resource), s.authorizeSome)
|
||||
}
|
||||
return false, nil
|
||||
// AuthorizePreConditions checks necessary preconditions for resources. Returns error if user does not have access to any resources.
|
||||
func (s actionAccess[T]) AuthorizePreConditions(ctx context.Context, user identity.Requester) error {
|
||||
return s.HasAccessOrError(ctx, user, s.authorizeSome, func() string {
|
||||
return fmt.Sprintf("%s any %s", s.action, s.resource)
|
||||
})
|
||||
}
|
||||
|
||||
// authorize checks if user has access to a specific resource given precondition checks have already passed. Returns an error if user does not have access.
|
||||
func (s actionAccess[T]) authorize(ctx context.Context, user identity.Requester, resource T) error {
|
||||
func (s actionAccess[T]) authorize(ctx context.Context, user identity.Requester, resource models.Identified) error {
|
||||
return s.HasAccessOrError(ctx, user, s.authorizeOne(resource), func() string {
|
||||
return fmt.Sprintf("%s %s", s.action, s.resource)
|
||||
})
|
||||
}
|
||||
|
||||
// has checks if user has access to a specific resource given precondition checks have already passed. Returns false if user does not have access.
|
||||
func (s actionAccess[T]) has(ctx context.Context, user identity.Requester, resource T) (bool, error) {
|
||||
func (s actionAccess[T]) has(ctx context.Context, user identity.Requester, resource models.Identified) (bool, error) {
|
||||
return s.HasAccess(ctx, user, s.authorizeOne(resource))
|
||||
}
|
||||
|
@ -2,13 +2,21 @@ package accesscontrol
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
const (
|
||||
ScopeReceiversRoot = "receivers"
|
||||
)
|
||||
|
||||
var (
|
||||
ScopeReceiversProvider = ac.NewScopeProvider(ScopeReceiversRoot)
|
||||
ScopeReceiversAll = ScopeReceiversProvider.GetResourceAllScope()
|
||||
)
|
||||
|
||||
var (
|
||||
// Asserts pre-conditions for read access to redacted receivers. If this evaluates to false, the user cannot read any redacted receivers.
|
||||
readRedactedReceiversPreConditionsEval = ac.EvalAny(
|
||||
@ -24,23 +32,19 @@ var (
|
||||
// Asserts read-only access to all redacted receivers.
|
||||
readRedactedAllReceiversEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||
|
||||
// TODO: The following should be scoped, but are currently interpreted as global. Needs a db migration when scope is added.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversRead), // TODO: Add global scope with fgac.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversRead, ScopeReceiversAll),
|
||||
readDecryptedAllReceiversEval,
|
||||
)
|
||||
// Asserts read-only access to all decrypted receivers.
|
||||
readDecryptedAllReceiversEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), // TODO: Add global scope with fgac.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets, ScopeReceiversAll),
|
||||
)
|
||||
|
||||
// Asserts read-only access to a specific redacted receiver.
|
||||
readRedactedReceiverEval = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsRead),
|
||||
|
||||
// TODO: The following should be scoped, but are currently interpreted as global. Needs a db migration when scope is added.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversRead), // TODO: Add uid scope with fgac.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversRead, ScopeReceiversProvider.GetResourceScopeUID(uid)),
|
||||
readDecryptedReceiverEval(uid),
|
||||
)
|
||||
}
|
||||
@ -48,7 +52,7 @@ var (
|
||||
// Asserts read-only access to a specific decrypted receiver.
|
||||
readDecryptedReceiverEval = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets), // TODO: Add uid scope with fgac.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets, ScopeReceiversProvider.GetResourceScopeUID(uid)),
|
||||
)
|
||||
}
|
||||
|
||||
@ -68,16 +72,95 @@ var (
|
||||
provisioningExtraReadDecryptedPermissions = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets), // Global provisioning action for all AM config + secrets. Org scope.
|
||||
)
|
||||
|
||||
// Create
|
||||
|
||||
// Asserts pre-conditions for create access to receivers. If this evaluates to false, the user cannot create any receivers.
|
||||
// Create has no scope, so these permissions are both necessary and sufficient to create any and all receivers.
|
||||
createReceiversEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite), // Global action for all AM config. Org scope.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversCreate), // Action for receivers. Org scope.
|
||||
)
|
||||
|
||||
// Update
|
||||
|
||||
// Asserts pre-conditions for update access to receivers. If this evaluates to false, the user cannot update any receivers.
|
||||
updateReceiversPreConditionsEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite), // Global action for all AM config. Org scope.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversUpdate), // Action for receivers. UID scope.
|
||||
)
|
||||
|
||||
// Asserts update access to all receivers.
|
||||
updateAllReceiversEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversUpdate, ScopeReceiversAll),
|
||||
)
|
||||
|
||||
// Asserts update access to a specific receiver.
|
||||
updateReceiverEval = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversUpdate, ScopeReceiversProvider.GetResourceScopeUID(uid)),
|
||||
)
|
||||
}
|
||||
|
||||
// Delete
|
||||
|
||||
// Asserts pre-conditions for delete access to receivers. If this evaluates to false, the user cannot delete any receivers.
|
||||
deleteReceiversPreConditionsEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite), // Global action for all AM config. Org scope.
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversDelete), // Action for receivers. UID scope.
|
||||
)
|
||||
|
||||
// Asserts delete access to all receivers.
|
||||
deleteAllReceiversEval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversDelete, ScopeReceiversAll),
|
||||
)
|
||||
|
||||
// Asserts delete access to a specific receiver.
|
||||
deleteReceiverEval = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingNotificationsWrite),
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversDelete, ScopeReceiversProvider.GetResourceScopeUID(uid)),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
type ReceiverAccess[T models.Identified] struct {
|
||||
read actionAccess[T]
|
||||
readDecrypted actionAccess[T]
|
||||
create actionAccess[T]
|
||||
update actionAccess[T]
|
||||
delete actionAccess[models.Identified]
|
||||
}
|
||||
|
||||
// NewReceiverAccess creates a new ReceiverAccess service. If includeProvisioningActions is true, the service will include
|
||||
// permissions specific to the provisioning API.
|
||||
func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvisioningActions bool) *ReceiverAccess[T] {
|
||||
// If this service is meant for the provisioning API, we include the provisioning actions as possible permissions.
|
||||
// TODO: Improve this monkey patching.
|
||||
readRedactedReceiversPreConditionsEval := readRedactedReceiversPreConditionsEval
|
||||
readDecryptedReceiversPreConditionsEval := readDecryptedReceiversPreConditionsEval
|
||||
readRedactedReceiverEval := readRedactedReceiverEval
|
||||
readDecryptedReceiverEval := readDecryptedReceiverEval
|
||||
readRedactedAllReceiversEval := readRedactedAllReceiversEval
|
||||
readDecryptedAllReceiversEval := readDecryptedAllReceiversEval
|
||||
if includeProvisioningActions {
|
||||
readRedactedReceiversPreConditionsEval = ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedReceiversPreConditionsEval)
|
||||
readDecryptedReceiversPreConditionsEval = ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedReceiversPreConditionsEval)
|
||||
|
||||
readRedactedReceiverEval = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedReceiverEval(uid))
|
||||
}
|
||||
readDecryptedReceiverEval = func(uid string) ac.Evaluator {
|
||||
return ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedReceiverEval(uid))
|
||||
}
|
||||
|
||||
readRedactedAllReceiversEval = ac.EvalAny(provisioningExtraReadRedactedPermissions, readRedactedAllReceiversEval)
|
||||
readDecryptedAllReceiversEval = ac.EvalAny(provisioningExtraReadDecryptedPermissions, readDecryptedAllReceiversEval)
|
||||
}
|
||||
|
||||
rcvAccess := &ReceiverAccess[T]{
|
||||
read: actionAccess[T]{
|
||||
genericService: genericService{
|
||||
@ -86,7 +169,7 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision
|
||||
resource: "receiver",
|
||||
action: "read",
|
||||
authorizeSome: readRedactedReceiversPreConditionsEval,
|
||||
authorizeOne: func(receiver T) ac.Evaluator {
|
||||
authorizeOne: func(receiver models.Identified) ac.Evaluator {
|
||||
return readRedactedReceiverEval(receiver.GetUID())
|
||||
},
|
||||
authorizeAll: readRedactedAllReceiversEval,
|
||||
@ -98,27 +181,47 @@ func NewReceiverAccess[T models.Identified](a ac.AccessControl, includeProvision
|
||||
resource: "decrypted receiver",
|
||||
action: "read",
|
||||
authorizeSome: readDecryptedReceiversPreConditionsEval,
|
||||
authorizeOne: func(receiver T) ac.Evaluator {
|
||||
authorizeOne: func(receiver models.Identified) ac.Evaluator {
|
||||
return readDecryptedReceiverEval(receiver.GetUID())
|
||||
},
|
||||
authorizeAll: readDecryptedAllReceiversEval,
|
||||
},
|
||||
}
|
||||
|
||||
// If this service is meant for the provisioning API, we include the provisioning actions as possible permissions.
|
||||
if includeProvisioningActions {
|
||||
rcvAccess.read.authorizeSome = ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeSome)
|
||||
rcvAccess.readDecrypted.authorizeSome = ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeSome)
|
||||
|
||||
rcvAccess.read.authorizeOne = func(receiver T) ac.Evaluator {
|
||||
return ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeOne(receiver))
|
||||
}
|
||||
rcvAccess.readDecrypted.authorizeOne = func(receiver T) ac.Evaluator {
|
||||
return ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeOne(receiver))
|
||||
}
|
||||
|
||||
rcvAccess.read.authorizeAll = ac.EvalAny(provisioningExtraReadRedactedPermissions, rcvAccess.read.authorizeAll)
|
||||
rcvAccess.readDecrypted.authorizeAll = ac.EvalAny(provisioningExtraReadDecryptedPermissions, rcvAccess.readDecrypted.authorizeAll)
|
||||
create: actionAccess[T]{
|
||||
genericService: genericService{
|
||||
ac: a,
|
||||
},
|
||||
resource: "receiver",
|
||||
action: "create",
|
||||
authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval),
|
||||
authorizeOne: func(receiver models.Identified) ac.Evaluator {
|
||||
return ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval)
|
||||
},
|
||||
authorizeAll: ac.EvalAll(readRedactedReceiversPreConditionsEval, createReceiversEval),
|
||||
},
|
||||
update: actionAccess[T]{
|
||||
genericService: genericService{
|
||||
ac: a,
|
||||
},
|
||||
resource: "receiver",
|
||||
action: "update",
|
||||
authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, updateReceiversPreConditionsEval),
|
||||
authorizeOne: func(receiver models.Identified) ac.Evaluator {
|
||||
return ac.EvalAll(readRedactedReceiverEval(receiver.GetUID()), updateReceiverEval(receiver.GetUID()))
|
||||
},
|
||||
authorizeAll: ac.EvalAll(readRedactedAllReceiversEval, updateAllReceiversEval),
|
||||
},
|
||||
delete: actionAccess[models.Identified]{
|
||||
genericService: genericService{
|
||||
ac: a,
|
||||
},
|
||||
resource: "receiver",
|
||||
action: "delete",
|
||||
authorizeSome: ac.EvalAll(readRedactedReceiversPreConditionsEval, deleteReceiversPreConditionsEval),
|
||||
authorizeOne: func(receiver models.Identified) ac.Evaluator {
|
||||
return ac.EvalAll(readRedactedReceiverEval(receiver.GetUID()), deleteReceiverEval(receiver.GetUID()))
|
||||
},
|
||||
authorizeAll: ac.EvalAll(readRedactedAllReceiversEval, deleteAllReceiversEval),
|
||||
},
|
||||
}
|
||||
|
||||
return rcvAccess
|
||||
@ -145,11 +248,6 @@ func (s ReceiverAccess[T]) HasRead(ctx context.Context, user identity.Requester,
|
||||
return s.read.Has(ctx, user, receiver)
|
||||
}
|
||||
|
||||
// HasReadAll checks if user has access to read all redacted receivers. Returns false if user does not have access.
|
||||
func (s ReceiverAccess[T]) HasReadAll(ctx context.Context, user identity.Requester) (bool, error) { // TODO: Temporary for legacy compatibility.
|
||||
return s.read.HasAccess(ctx, user, s.read.authorizeAll)
|
||||
}
|
||||
|
||||
// FilterReadDecrypted filters the given list of receivers based on the read decrypted access control permissions of the user.
|
||||
// This method is preferred when many receivers need to be checked.
|
||||
func (s ReceiverAccess[T]) FilterReadDecrypted(ctx context.Context, user identity.Requester, receivers ...T) ([]T, error) {
|
||||
@ -166,9 +264,46 @@ func (s ReceiverAccess[T]) HasReadDecrypted(ctx context.Context, user identity.R
|
||||
return s.readDecrypted.Has(ctx, user, receiver)
|
||||
}
|
||||
|
||||
// AuthorizeReadDecryptedAll checks if user has access to read all decrypted receiver. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeReadDecryptedAll(ctx context.Context, user identity.Requester) error { // TODO: Temporary for legacy compatibility.
|
||||
return s.readDecrypted.HasAccessOrError(ctx, user, s.readDecrypted.authorizeAll, func() string {
|
||||
return fmt.Sprintf("%s %s", s.readDecrypted.action, s.readDecrypted.resource)
|
||||
})
|
||||
// AuthorizeUpdate checks if user has access to update a receiver. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeUpdate(ctx context.Context, user identity.Requester, receiver T) error {
|
||||
return s.update.Authorize(ctx, user, receiver)
|
||||
}
|
||||
|
||||
// Global
|
||||
|
||||
// AuthorizeCreate checks if user has access to create receivers. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeCreate(ctx context.Context, user identity.Requester) error {
|
||||
return s.create.AuthorizeAll(ctx, user)
|
||||
}
|
||||
|
||||
// By UID
|
||||
|
||||
type identified struct {
|
||||
uid string
|
||||
}
|
||||
|
||||
func (i identified) GetUID() string {
|
||||
return i.uid
|
||||
}
|
||||
|
||||
// AuthorizeDeleteByUID checks if user has access to delete a receiver by uid. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeDeleteByUID(ctx context.Context, user identity.Requester, uid string) error {
|
||||
return s.delete.Authorize(ctx, user, identified{uid: uid})
|
||||
}
|
||||
|
||||
// AuthorizeReadByUID checks if user has access to read a redacted receiver by uid. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeReadByUID(ctx context.Context, user identity.Requester, uid string) error {
|
||||
return s.read.Authorize(ctx, user, identified{uid: uid})
|
||||
}
|
||||
|
||||
// AuthorizeUpdateByUID checks if user has access to update a receiver by uid. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeUpdateByUID(ctx context.Context, user identity.Requester, uid string) error {
|
||||
return s.update.Authorize(ctx, user, identified{uid: uid})
|
||||
}
|
||||
|
||||
// Preconditions
|
||||
|
||||
// AuthorizeReadSome checks if user has access to read some redacted receivers. Returns an error if user does not have access.
|
||||
func (s ReceiverAccess[T]) AuthorizeReadSome(ctx context.Context, user identity.Requester) error {
|
||||
return s.read.AuthorizePreConditions(ctx, user)
|
||||
}
|
||||
|
@ -8,7 +8,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
@ -19,8 +18,8 @@ type NotificationSrv struct {
|
||||
}
|
||||
|
||||
type ReceiverService interface {
|
||||
GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error)
|
||||
ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error)
|
||||
GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error)
|
||||
ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]*models.Receiver, error)
|
||||
}
|
||||
|
||||
func (srv *NotificationSrv) RouteGetTimeInterval(c *contextmodel.ReqContext, name string) response.Response {
|
||||
@ -51,7 +50,12 @@ func (srv *NotificationSrv) RouteGetReceiver(c *contextmodel.ReqContext, name st
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get receiver", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, receiver)
|
||||
gettable, err := GettableApiReceiverFromReceiver(receiver)
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to convert receiver", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, gettable)
|
||||
}
|
||||
|
||||
func (srv *NotificationSrv) RouteGetReceivers(c *contextmodel.ReqContext) response.Response {
|
||||
@ -67,5 +71,10 @@ func (srv *NotificationSrv) RouteGetReceivers(c *contextmodel.ReqContext) respon
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get receiver groups", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, receivers)
|
||||
gettables, err := GettableApiReceiversFromReceivers(receivers)
|
||||
if err != nil {
|
||||
return response.ErrOrFallback(http.StatusInternalServerError, "failed to convert receivers", err)
|
||||
}
|
||||
|
||||
return response.JSON(http.StatusOK, gettables)
|
||||
}
|
||||
|
@ -25,7 +25,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/web"
|
||||
|
||||
am_config "github.com/prometheus/alertmanager/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@ -33,35 +32,33 @@ func TestRouteGetReceiver(t *testing.T) {
|
||||
fakeReceiverSvc := fakes.NewFakeReceiverService()
|
||||
|
||||
t.Run("returns expected model", func(t *testing.T) {
|
||||
expected := definitions.GettableApiReceiver{
|
||||
Receiver: am_config.Receiver{
|
||||
expected := &models.Receiver{
|
||||
Name: "receiver1",
|
||||
},
|
||||
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
|
||||
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{
|
||||
Integrations: []*models.Integration{
|
||||
{
|
||||
UID: "uid1",
|
||||
Name: "receiver1",
|
||||
Type: "slack",
|
||||
},
|
||||
Config: models.IntegrationConfig{Type: "slack"},
|
||||
},
|
||||
},
|
||||
}
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
|
||||
return expected, nil
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
rc := testReqCtx("GET")
|
||||
resp := handler.handleRouteGetReceiver(&rc, "receiver1")
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
json, err := json.Marshal(expected)
|
||||
gettables, err := GettableApiReceiverFromReceiver(expected)
|
||||
require.NoError(t, err)
|
||||
json, err := json.Marshal(gettables)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, json, resp.Body())
|
||||
})
|
||||
|
||||
t.Run("builds query from request context and url param", func(t *testing.T) {
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
return definitions.GettableApiReceiver{}, nil
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
|
||||
return &models.Receiver{}, nil
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
rc := testReqCtx("GET")
|
||||
@ -80,8 +77,8 @@ func TestRouteGetReceiver(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should pass along not found response", func(t *testing.T) {
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
return definitions.GettableApiReceiver{}, notifier.ErrReceiverNotFound.Errorf("")
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
|
||||
return nil, legacy_storage.ErrReceiverNotFound.Errorf("")
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
rc := testReqCtx("GET")
|
||||
@ -90,8 +87,8 @@ func TestRouteGetReceiver(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should pass along permission denied response", func(t *testing.T) {
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
return definitions.GettableApiReceiver{}, ac.ErrAuthorizationBase.Errorf("")
|
||||
fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
|
||||
return nil, ac.ErrAuthorizationBase.Errorf("")
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
rc := testReqCtx("GET")
|
||||
@ -104,23 +101,19 @@ func TestRouteGetReceivers(t *testing.T) {
|
||||
fakeReceiverSvc := fakes.NewFakeReceiverService()
|
||||
|
||||
t.Run("returns expected model", func(t *testing.T) {
|
||||
expected := []definitions.GettableApiReceiver{
|
||||
expected := []*models.Receiver{
|
||||
{
|
||||
Receiver: am_config.Receiver{
|
||||
Name: "receiver1",
|
||||
},
|
||||
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
|
||||
GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{
|
||||
Integrations: []*models.Integration{
|
||||
{
|
||||
UID: "uid1",
|
||||
Name: "receiver1",
|
||||
Type: "slack",
|
||||
},
|
||||
Config: models.IntegrationConfig{Type: "slack"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
|
||||
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
|
||||
return expected, nil
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
@ -128,14 +121,16 @@ func TestRouteGetReceivers(t *testing.T) {
|
||||
rc.Context.Req.Form.Set("names", "receiver1")
|
||||
resp := handler.handleRouteGetReceivers(&rc)
|
||||
require.Equal(t, http.StatusOK, resp.Status())
|
||||
json, err := json.Marshal(expected)
|
||||
gettables, err := GettableApiReceiversFromReceivers(expected)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, json, resp.Body())
|
||||
jsonBody, err := json.Marshal(gettables)
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, string(jsonBody), string(resp.Body()))
|
||||
})
|
||||
|
||||
t.Run("builds query from request context", func(t *testing.T) {
|
||||
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
|
||||
return []definitions.GettableApiReceiver{}, nil
|
||||
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
rc := testReqCtx("GET")
|
||||
@ -159,7 +154,7 @@ func TestRouteGetReceivers(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("should pass along permission denied response", func(t *testing.T) {
|
||||
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
|
||||
fakeReceiverSvc.ListReceiversFn = func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
|
||||
return nil, ac.ErrAuthorizationBase.Errorf("")
|
||||
}
|
||||
handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc))
|
||||
@ -221,7 +216,7 @@ func TestRouteGetReceiversResponses(t *testing.T) {
|
||||
{limit: 4, offset: 0, expected: expected[:4]},
|
||||
{limit: 1, offset: 1, expected: expected[1:2]},
|
||||
{limit: 2, offset: 2, expected: expected[2:4]},
|
||||
{limit: 2, offset: 99, expected: nil},
|
||||
{limit: 2, offset: 99, expected: []definitions.GettableApiReceiver{}},
|
||||
{limit: 0, offset: 0, expected: expected},
|
||||
{limit: 0, offset: 1, expected: expected[1:]},
|
||||
}
|
||||
@ -237,7 +232,7 @@ func TestRouteGetReceiversResponses(t *testing.T) {
|
||||
err := json.Unmarshal(response.Body(), &configs)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, configs, tc.expected)
|
||||
require.Equal(t, tc.expected, configs)
|
||||
})
|
||||
}
|
||||
})
|
||||
@ -331,8 +326,8 @@ func TestRouteGetReceiversResponses(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("json body content is as expected", func(t *testing.T) {
|
||||
expectedRedactedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"secureFields":{}}]}`
|
||||
expectedDecryptedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"testpass","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"secureFields":{}}]}`
|
||||
expectedRedactedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"[REDACTED]","use_discord_username":true},"secureFields":{"url":true}}]}`
|
||||
expectedDecryptedResponse := `{"name":"multiple integrations","grafana_managed_receiver_configs":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","name":"multiple integrations","type":"prometheus-alertmanager","disableResolveMessage":true,"settings":{"basicAuthPassword":"testpass","basicAuthUser":"test","url":"http://localhost:9093"},"secureFields":{"basicAuthPassword":true}},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","name":"multiple integrations","type":"discord","disableResolveMessage":false,"settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"secureFields":{"url":true}}]}`
|
||||
t.Run("decrypt false", func(t *testing.T) {
|
||||
env := createTestEnv(t, testContactPointConfig)
|
||||
sut := createNotificationSrvSutFromEnv(t, &env)
|
||||
@ -375,6 +370,7 @@ func createNotificationSrvSutFromEnv(t *testing.T, env *testEnvironment) Notific
|
||||
ac.NewReceiverAccess[*models.Receiver](env.ac, false),
|
||||
legacy_storage.NewAlertmanagerConfigStore(env.configs),
|
||||
env.prov,
|
||||
env.store,
|
||||
env.secrets,
|
||||
env.xact,
|
||||
env.log,
|
||||
|
@ -1632,7 +1632,7 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("json body content is as expected", func(t *testing.T) {
|
||||
expectedRedactedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"[REDACTED]","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"[REDACTED]"},"disableResolveMessage":true}]}]}`
|
||||
expectedRedactedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"[REDACTED]","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"[REDACTED]","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"[REDACTED]"},"disableResolveMessage":true}]}]}`
|
||||
t.Run("decrypt false", func(t *testing.T) {
|
||||
env := createTestEnv(t, testContactPointConfig)
|
||||
sut := createProvisioningSrvSutFromEnv(t, &env)
|
||||
@ -1685,14 +1685,14 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
|
||||
response := sut.RouteGetContactPointsExport(&rc)
|
||||
|
||||
expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]}]}`
|
||||
expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"[REDACTED]","use_discord_username":true},"disableResolveMessage":false}]}]}`
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, expectedResponse, string(response.Body()))
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("yaml body content is as expected", func(t *testing.T) {
|
||||
expectedRedactedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: <example@email.com>\n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: '[REDACTED]'\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: '[REDACTED]'\n disableResolveMessage: true\n"
|
||||
expectedRedactedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: <example@email.com>\n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: '[REDACTED]'\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: '[REDACTED]'\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: '[REDACTED]'\n disableResolveMessage: true\n"
|
||||
t.Run("decrypt false", func(t *testing.T) {
|
||||
env := createTestEnv(t, testContactPointConfig)
|
||||
sut := createProvisioningSrvSutFromEnv(t, &env)
|
||||
@ -1745,7 +1745,7 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
|
||||
response := sut.RouteGetContactPointsExport(&rc)
|
||||
|
||||
expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n"
|
||||
expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: '[REDACTED]'\n use_discord_username: true\n disableResolveMessage: false\n"
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Equal(t, expectedResponse, string(response.Body()))
|
||||
})
|
||||
@ -1897,6 +1897,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi
|
||||
ac.NewReceiverAccess[*models.Receiver](env.ac, true),
|
||||
configStore,
|
||||
env.prov,
|
||||
env.store,
|
||||
env.secrets,
|
||||
env.xact,
|
||||
env.log,
|
||||
@ -2301,10 +2302,11 @@ var testContactPointConfig = `
|
||||
"disableResolveMessage":false,
|
||||
"settings":{
|
||||
"avatar_url":"some avatar",
|
||||
"url":"some url",
|
||||
"use_discord_username":true
|
||||
},
|
||||
"secureSettings":{}
|
||||
"secureSettings":{
|
||||
"url":"some url"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
@ -78,7 +78,6 @@ func (api *API) authorize(method, path string) web.Handler {
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
|
||||
)
|
||||
case http.MethodGet + "/api/v1/notifications/receivers/{Name}":
|
||||
// TODO: scope to :Name
|
||||
eval = ac.EvalAny(
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversRead),
|
||||
ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
amConfig "github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/common/model"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
@ -501,3 +502,56 @@ func ApiRecordFromModelRecord(r *models.Record) *definitions.Record {
|
||||
From: r.From,
|
||||
}
|
||||
}
|
||||
|
||||
func GettableGrafanaReceiverFromReceiver(r *models.Integration, provenance models.Provenance) (definitions.GettableGrafanaReceiver, error) {
|
||||
out := definitions.GettableGrafanaReceiver{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
Type: r.Config.Type,
|
||||
Provenance: definitions.Provenance(provenance),
|
||||
DisableResolveMessage: r.DisableResolveMessage,
|
||||
SecureFields: r.SecureFields(),
|
||||
}
|
||||
|
||||
if len(r.Settings) > 0 {
|
||||
jsonBytes, err := json.Marshal(r.Settings)
|
||||
if err != nil {
|
||||
return definitions.GettableGrafanaReceiver{}, err
|
||||
}
|
||||
out.Settings = jsonBytes
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GettableApiReceiverFromReceiver(r *models.Receiver) (*definitions.GettableApiReceiver, error) {
|
||||
out := definitions.GettableApiReceiver{
|
||||
Receiver: amConfig.Receiver{
|
||||
Name: r.Name,
|
||||
},
|
||||
GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{
|
||||
GrafanaManagedReceivers: make([]*definitions.GettableGrafanaReceiver, 0, len(r.Integrations)),
|
||||
},
|
||||
}
|
||||
|
||||
for _, integration := range r.Integrations {
|
||||
gettable, err := GettableGrafanaReceiverFromReceiver(integration, r.Provenance)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.GrafanaManagedReceivers = append(out.GrafanaManagedReceivers, &gettable)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func GettableApiReceiversFromReceivers(recvs []*models.Receiver) ([]*definitions.GettableApiReceiver, error) {
|
||||
out := make([]*definitions.GettableApiReceiver, 0, len(recvs))
|
||||
for _, r := range recvs {
|
||||
gettables, err := GettableApiReceiverFromReceiver(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, gettables)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
@ -1,6 +1,21 @@
|
||||
package models
|
||||
|
||||
import "github.com/grafana/alerting/notify"
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"maps"
|
||||
"math"
|
||||
"sort"
|
||||
"unsafe"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
)
|
||||
|
||||
// GetReceiverQuery represents a query for a single receiver.
|
||||
type GetReceiverQuery struct {
|
||||
@ -30,15 +45,430 @@ type ListReceiversQuery struct {
|
||||
type Receiver struct {
|
||||
UID string
|
||||
Name string
|
||||
Integrations []*notify.GrafanaIntegrationConfig
|
||||
Integrations []*Integration
|
||||
Provenance Provenance
|
||||
Version string
|
||||
}
|
||||
|
||||
func (r *Receiver) Clone() Receiver {
|
||||
clone := Receiver{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
Provenance: r.Provenance,
|
||||
Version: r.Version,
|
||||
}
|
||||
|
||||
if r.Integrations != nil {
|
||||
clone.Integrations = make([]*Integration, len(r.Integrations))
|
||||
for i, integration := range r.Integrations {
|
||||
cloneIntegration := integration.Clone()
|
||||
clone.Integrations[i] = &cloneIntegration
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// Encrypt encrypts all integrations.
|
||||
func (r *Receiver) Encrypt(encryptFn EncryptFn) error {
|
||||
for _, integration := range r.Integrations {
|
||||
if err := integration.Encrypt(encryptFn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts all integrations.
|
||||
func (r *Receiver) Decrypt(decryptFn DecryptFn) error {
|
||||
var errs []error
|
||||
for _, integration := range r.Integrations {
|
||||
if err := integration.Decrypt(decryptFn); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to decrypt integration %s: %w", integration.UID, err))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Redact redacts all integrations.
|
||||
func (r *Receiver) Redact(redactFn RedactFn) {
|
||||
for _, integration := range r.Integrations {
|
||||
integration.Redact(redactFn)
|
||||
}
|
||||
}
|
||||
|
||||
// WithExistingSecureFields copies secure settings from an existing receivers for each integration. Which fields to copy
|
||||
// is determined by the integrationSecureFields map, which contains a list of secure fields for each integration UID.
|
||||
func (r *Receiver) WithExistingSecureFields(existing *Receiver, integrationSecureFields map[string][]string) {
|
||||
existingIntegrations := make(map[string]*Integration, len(existing.Integrations))
|
||||
for _, integration := range existing.Integrations {
|
||||
existingIntegrations[integration.UID] = integration
|
||||
}
|
||||
|
||||
for _, integration := range r.Integrations {
|
||||
if integration.UID == "" {
|
||||
// This is a new integration, so we don't need to copy any secure fields.
|
||||
continue
|
||||
}
|
||||
fields := integrationSecureFields[integration.UID]
|
||||
if len(fields) > 0 {
|
||||
integration.WithExistingSecureFields(existingIntegrations[integration.UID], fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates all integration settings, ensuring that the integrations are correctly configured.
|
||||
func (r *Receiver) Validate(decryptFn DecryptFn) error {
|
||||
var errs []error
|
||||
for _, integration := range r.Integrations {
|
||||
if err := integration.Validate(decryptFn); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Integration is the domain model representation of an integration.
|
||||
type Integration struct {
|
||||
UID string
|
||||
Name string
|
||||
Config IntegrationConfig
|
||||
DisableResolveMessage bool
|
||||
// Settings can contain both secure and non-secure settings either unencrypted or redacted.
|
||||
Settings map[string]any
|
||||
// SecureSettings can contain only secure settings either encrypted or redacted.
|
||||
SecureSettings map[string]string
|
||||
}
|
||||
|
||||
// IntegrationConfig represents the configuration of an integration. It contains the type and information about the fields.
|
||||
type IntegrationConfig struct {
|
||||
Type string
|
||||
Fields map[string]IntegrationField
|
||||
}
|
||||
|
||||
// IntegrationField represents a field in an integration configuration.
|
||||
type IntegrationField struct {
|
||||
Name string
|
||||
Secure bool
|
||||
}
|
||||
|
||||
// IntegrationConfigFromType returns an integration configuration for a given integration type. If the integration type is
|
||||
// not found an error is returned.
|
||||
func IntegrationConfigFromType(integrationType string) (IntegrationConfig, error) {
|
||||
config, err := channels_config.ConfigForIntegrationType(integrationType)
|
||||
if err != nil {
|
||||
return IntegrationConfig{}, err
|
||||
}
|
||||
|
||||
integrationConfig := IntegrationConfig{Type: config.Type, Fields: make(map[string]IntegrationField, len(config.Options))}
|
||||
for _, option := range config.Options {
|
||||
integrationConfig.Fields[option.PropertyName] = IntegrationField{
|
||||
Name: option.PropertyName,
|
||||
Secure: option.Secure,
|
||||
}
|
||||
}
|
||||
return integrationConfig, nil
|
||||
}
|
||||
|
||||
// IsSecureField returns true if the field is both known and marked as secure in the integration configuration.
|
||||
func (config *IntegrationConfig) IsSecureField(field string) bool {
|
||||
if config.Fields != nil {
|
||||
if f, ok := config.Fields[field]; ok {
|
||||
return f.Secure
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (config *IntegrationConfig) Clone() IntegrationConfig {
|
||||
clone := IntegrationConfig{
|
||||
Type: config.Type,
|
||||
}
|
||||
|
||||
if len(config.Fields) > 0 {
|
||||
clone.Fields = make(map[string]IntegrationField, len(config.Fields))
|
||||
for key, field := range config.Fields {
|
||||
clone.Fields[key] = field.Clone()
|
||||
}
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func (field *IntegrationField) Clone() IntegrationField {
|
||||
return IntegrationField{
|
||||
Name: field.Name,
|
||||
Secure: field.Secure,
|
||||
}
|
||||
}
|
||||
|
||||
func (integration *Integration) Clone() Integration {
|
||||
return Integration{
|
||||
UID: integration.UID,
|
||||
Name: integration.Name,
|
||||
Config: integration.Config.Clone(),
|
||||
DisableResolveMessage: integration.DisableResolveMessage,
|
||||
Settings: maps.Clone(integration.Settings),
|
||||
SecureSettings: maps.Clone(integration.SecureSettings),
|
||||
}
|
||||
}
|
||||
|
||||
// Encrypt encrypts all fields in Settings that are marked as secure in the integration configuration. The encrypted values
|
||||
// are stored in SecureSettings and the original values are removed from Settings.
|
||||
// If a field is already in SecureSettings it is not encrypted again.
|
||||
func (integration *Integration) Encrypt(encryptFn EncryptFn) error {
|
||||
var errs []error
|
||||
for key, val := range integration.Settings {
|
||||
if isSecureField := integration.Config.IsSecureField(key); !isSecureField {
|
||||
continue
|
||||
}
|
||||
|
||||
delete(integration.Settings, key)
|
||||
unencryptedSecureValue, isString := val.(string)
|
||||
if !isString {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := integration.SecureSettings[key]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
encrypted, err := encryptFn(unencryptedSecureValue)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to encrypt secure setting '%s': %w", key, err))
|
||||
}
|
||||
|
||||
integration.SecureSettings[key] = encrypted
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Decrypt decrypts all fields in SecureSettings and moves them to Settings.
|
||||
// The original values are removed from SecureSettings.
|
||||
func (integration *Integration) Decrypt(decryptFn DecryptFn) error {
|
||||
var errs []error
|
||||
for key, secureVal := range integration.SecureSettings {
|
||||
decrypted, err := decryptFn(secureVal)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to decrypt secure setting '%s': %w", key, err))
|
||||
}
|
||||
delete(integration.SecureSettings, key)
|
||||
integration.Settings[key] = decrypted
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Redact redacts all fields in SecureSettings and moves them to Settings.
|
||||
// The original values are removed from SecureSettings.
|
||||
func (integration *Integration) Redact(redactFn RedactFn) {
|
||||
for key, secureVal := range integration.SecureSettings { // TODO: Should we trust that the receiver is stored correctly or use known secure settings?
|
||||
integration.Settings[key] = redactFn(secureVal)
|
||||
delete(integration.SecureSettings, key)
|
||||
}
|
||||
|
||||
// We don't trust that the receiver is stored correctly, so we redact secure fields in the settings as well.
|
||||
for key, val := range integration.Settings {
|
||||
if val != "" && integration.Config.IsSecureField(key) {
|
||||
s, isString := val.(string)
|
||||
if !isString {
|
||||
continue
|
||||
}
|
||||
integration.Settings[key] = redactFn(s)
|
||||
delete(integration.SecureSettings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithExistingSecureFields copies secure settings from an existing integration. Which fields to copy is determined by the
|
||||
// fields slice.
|
||||
// Any fields found in Settings or SecureSettings are removed, even if they don't appear in the existing integration.
|
||||
func (integration *Integration) WithExistingSecureFields(existing *Integration, fields []string) {
|
||||
// Now for each field marked as secure, we copy the value from the existing receiver.
|
||||
for _, secureField := range fields {
|
||||
delete(integration.Settings, secureField) // Ensure secure fields are removed from new settings and secure settings.
|
||||
delete(integration.SecureSettings, secureField)
|
||||
if existing != nil {
|
||||
if existingVal, ok := existing.SecureSettings[secureField]; ok {
|
||||
integration.SecureSettings[secureField] = existingVal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SecureFields returns a map of all secure fields in the integration. This includes fields in SecureSettings and fields
|
||||
// in Settings that are marked as secure in the integration configuration.
|
||||
func (integration *Integration) SecureFields() map[string]bool {
|
||||
secureFields := make(map[string]bool, len(integration.SecureSettings))
|
||||
if len(integration.SecureSettings) > 0 {
|
||||
for key := range integration.SecureSettings {
|
||||
secureFields[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
// We mark secure fields in the settings as well. This is to ensure legacy behaviour for redacted secure settings.
|
||||
for key, val := range integration.Settings {
|
||||
if val != "" && integration.Config.IsSecureField(key) {
|
||||
secureFields[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
return secureFields
|
||||
}
|
||||
|
||||
// Validate validates the integration settings, ensuring that the integration is correctly configured.
|
||||
func (integration *Integration) Validate(decryptFn DecryptFn) error {
|
||||
decrypted := integration.Clone()
|
||||
if err := decrypted.Decrypt(decryptFn); err != nil {
|
||||
return err
|
||||
}
|
||||
jsonBytes, err := json.Marshal(decrypted.Settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ValidateIntegration(context.Background(), alertingNotify.GrafanaIntegrationConfig{
|
||||
UID: decrypted.UID,
|
||||
Name: decrypted.Name,
|
||||
Type: decrypted.Config.Type,
|
||||
DisableResolveMessage: decrypted.DisableResolveMessage,
|
||||
Settings: jsonBytes,
|
||||
SecureSettings: decrypted.SecureSettings,
|
||||
}, alertingNotify.NoopDecrypt)
|
||||
}
|
||||
|
||||
func ValidateIntegration(ctx context.Context, integration alertingNotify.GrafanaIntegrationConfig, decryptFunc alertingNotify.GetDecryptedValueFn) error {
|
||||
if integration.Type == "" {
|
||||
return fmt.Errorf("type should not be an empty string")
|
||||
}
|
||||
if integration.Settings == nil {
|
||||
return fmt.Errorf("settings should not be empty")
|
||||
}
|
||||
|
||||
_, err := alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{
|
||||
GrafanaIntegrations: alertingNotify.GrafanaIntegrations{
|
||||
Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration},
|
||||
},
|
||||
}, decryptFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type EncryptFn = func(string) (string, error)
|
||||
type DecryptFn = func(string) (string, error)
|
||||
type RedactFn = func(string) string
|
||||
|
||||
// Identified describes a class of resources that have a UID. Created to abstract required fields for authorization.
|
||||
type Identified interface {
|
||||
GetUID() string
|
||||
}
|
||||
|
||||
func (r Receiver) GetUID() string {
|
||||
func (r *Receiver) GetUID() string {
|
||||
return r.UID
|
||||
}
|
||||
|
||||
func (r *Receiver) Fingerprint() string {
|
||||
sum := fnv.New64()
|
||||
|
||||
writeBytes := func(b []byte) {
|
||||
_, _ = sum.Write(b)
|
||||
// add a byte sequence that cannot happen in UTF-8 strings.
|
||||
_, _ = sum.Write([]byte{255})
|
||||
}
|
||||
writeString := func(s string) {
|
||||
if len(s) == 0 {
|
||||
writeBytes(nil)
|
||||
return
|
||||
}
|
||||
// #nosec G103
|
||||
// avoid allocation when converting string to byte slice
|
||||
writeBytes(unsafe.Slice(unsafe.StringData(s), len(s)))
|
||||
}
|
||||
// this temp slice is used to convert ints to bytes.
|
||||
tmp := make([]byte, 8)
|
||||
writeInt := func(u int) {
|
||||
binary.LittleEndian.PutUint64(tmp, uint64(u))
|
||||
writeBytes(tmp)
|
||||
}
|
||||
|
||||
writeIntegration := func(in *Integration) {
|
||||
writeString(in.UID)
|
||||
writeString(in.Name)
|
||||
|
||||
// Do not include fields in fingerprint as these are not part of the receiver definition.
|
||||
writeString(in.Config.Type)
|
||||
|
||||
if in.DisableResolveMessage {
|
||||
writeInt(1)
|
||||
} else {
|
||||
writeInt(0)
|
||||
}
|
||||
|
||||
// allocate a slice that will be used for sorting keys, so we allocate it only once
|
||||
var keys []string
|
||||
maxLen := int(math.Max(float64(len(in.Settings)), float64(len(in.SecureSettings))))
|
||||
if maxLen > 0 {
|
||||
keys = make([]string, maxLen)
|
||||
}
|
||||
|
||||
writeSecureSettings := func(secureSettings map[string]string) {
|
||||
// maps do not guarantee predictable sequence of keys.
|
||||
// Therefore, to make hash stable, we need to sort keys
|
||||
if len(secureSettings) == 0 {
|
||||
return
|
||||
}
|
||||
idx := 0
|
||||
for k := range secureSettings {
|
||||
keys[idx] = k
|
||||
idx++
|
||||
}
|
||||
sub := keys[:idx]
|
||||
sort.Strings(sub)
|
||||
for _, name := range sub {
|
||||
writeString(name)
|
||||
writeString(secureSettings[name])
|
||||
}
|
||||
}
|
||||
writeSecureSettings(in.SecureSettings)
|
||||
|
||||
writeSettings := func(settings map[string]any) {
|
||||
// maps do not guarantee predictable sequence of keys.
|
||||
// Therefore, to make hash stable, we need to sort keys
|
||||
if len(settings) == 0 {
|
||||
return
|
||||
}
|
||||
idx := 0
|
||||
for k := range settings {
|
||||
keys[idx] = k
|
||||
idx++
|
||||
}
|
||||
sub := keys[:idx]
|
||||
sort.Strings(sub)
|
||||
for _, name := range sub {
|
||||
writeString(name)
|
||||
|
||||
// TODO: Improve this.
|
||||
v := settings[name]
|
||||
bytes, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
writeString(fmt.Sprintf("%+v", v))
|
||||
} else {
|
||||
writeBytes(bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeSettings(in.Settings)
|
||||
}
|
||||
|
||||
// fields that determine the rule state
|
||||
writeString(r.UID)
|
||||
writeString(r.Name)
|
||||
writeString(string(r.Provenance))
|
||||
|
||||
for _, integration := range r.Integrations {
|
||||
writeIntegration(integration)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%016x", sum.Sum64())
|
||||
}
|
||||
|
399
pkg/services/ngalert/models/receivers_test.go
Normal file
399
pkg/services/ngalert/models/receivers_test.go
Normal file
@ -0,0 +1,399 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
)
|
||||
|
||||
func TestReceiver_Clone(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
receiver Receiver
|
||||
}{
|
||||
{name: "empty receiver", receiver: Receiver{}},
|
||||
{name: "empty integration", receiver: Receiver{Integrations: []*Integration{{Config: IntegrationConfig{}}}}},
|
||||
{name: "random receiver", receiver: ReceiverGen()()},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
receiverClone := tc.receiver.Clone()
|
||||
assert.Equal(t, tc.receiver, receiverClone)
|
||||
|
||||
for _, integration := range tc.receiver.Integrations {
|
||||
integrationClone := integration.Clone()
|
||||
assert.Equal(t, *integration, integrationClone)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceiver_EncryptDecrypt(t *testing.T) {
|
||||
encryptFn := Base64Enrypt
|
||||
decryptnFn := Base64Decrypt
|
||||
// Test that all known integration types encrypt and decrypt their secrets.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
decrypedIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
|
||||
encrypted := decrypedIntegration.Clone()
|
||||
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
for _, key := range secrets {
|
||||
if val, ok := encrypted.Settings[key]; ok {
|
||||
if s, isString := val.(string); isString {
|
||||
encryptedVal, err := encryptFn(s)
|
||||
assert.NoError(t, err)
|
||||
encrypted.SecureSettings[key] = encryptedVal
|
||||
delete(encrypted.Settings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testIntegration := decrypedIntegration.Clone()
|
||||
err = testIntegration.Encrypt(encryptFn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, encrypted, testIntegration)
|
||||
|
||||
err = testIntegration.Decrypt(decryptnFn)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, decrypedIntegration, testIntegration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Redact(t *testing.T) {
|
||||
redactFn := func(key string) string {
|
||||
return "TESTREDACTED"
|
||||
}
|
||||
// Test that all known integration types redact their secrets.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
|
||||
expected := validIntegration.Clone()
|
||||
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
for _, key := range secrets {
|
||||
if val, ok := expected.Settings[key]; ok {
|
||||
if s, isString := val.(string); isString && s != "" {
|
||||
expected.Settings[key] = redactFn(s)
|
||||
delete(expected.SecureSettings, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validIntegration.Redact(redactFn)
|
||||
|
||||
assert.Equal(t, expected, validIntegration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_Validate(t *testing.T) {
|
||||
// Test that all known integration types are valid.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
assert.NoError(t, validIntegration.Encrypt(Base64Enrypt))
|
||||
assert.NoErrorf(t, validIntegration.Validate(Base64Decrypt), "integration should be valid")
|
||||
|
||||
invalidIntegration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
|
||||
assert.NoError(t, invalidIntegration.Encrypt(Base64Enrypt))
|
||||
assert.Errorf(t, invalidIntegration.Validate(Base64Decrypt), "integration should be invalid")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegration_WithExistingSecureFields(t *testing.T) {
|
||||
// Test that WithExistingSecureFields will copy over the secure fields from the existing integration.
|
||||
testCases := []struct {
|
||||
name string
|
||||
integration Integration
|
||||
secureFields []string
|
||||
existing Integration
|
||||
expected Integration
|
||||
}{
|
||||
{
|
||||
name: "test receiver",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{
|
||||
"f1": "newVal1",
|
||||
"f2": "newVal2",
|
||||
"f3": "newVal3",
|
||||
"f5": "newVal5",
|
||||
},
|
||||
},
|
||||
secureFields: []string{"f2", "f4", "f5"},
|
||||
existing: Integration{
|
||||
SecureSettings: map[string]string{
|
||||
"f1": "oldVal1",
|
||||
"f2": "oldVal2",
|
||||
"f3": "oldVal3",
|
||||
"f4": "oldVal4",
|
||||
},
|
||||
},
|
||||
expected: Integration{
|
||||
SecureSettings: map[string]string{
|
||||
"f1": "newVal1",
|
||||
"f2": "oldVal2",
|
||||
"f3": "newVal3",
|
||||
"f4": "oldVal4",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Integration[exists], SecureFields[true], Existing[exists]: old value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
},
|
||||
{
|
||||
name: "Integration[exists], SecureFields[true], Existing[missing]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Integration[exists], SecureFields[false], Existing[exists]: new value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "newVal1"}},
|
||||
},
|
||||
{
|
||||
name: "Integration[exists], SecureFields[false], Existing[missing]: new value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{"f1": "newVal1"},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "newVal1"}},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Integration[missing], SecureFields[true], Existing[exists]: old value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
},
|
||||
{
|
||||
name: "Integration[missing], SecureFields[true], Existing[missing]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
secureFields: []string{"f1"},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
|
||||
{
|
||||
name: "Integration[missing], SecureFields[false], Existing[exists]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{"f1": "oldVal1"}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
{
|
||||
name: "Integration[missing], SecureFields[false], Existing[missing]: no value",
|
||||
integration: Integration{
|
||||
SecureSettings: map[string]string{},
|
||||
},
|
||||
existing: Integration{SecureSettings: map[string]string{}},
|
||||
expected: Integration{SecureSettings: map[string]string{}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.integration.WithExistingSecureFields(&tc.existing, tc.secureFields)
|
||||
assert.Equal(t, tc.expected, tc.integration)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationConfig(t *testing.T) {
|
||||
// Test that all known integration types have a config and correctly mark their secrets as secure.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
config, err := IntegrationConfigFromType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
|
||||
secrets, err := channels_config.GetSecretKeysForContactPointType(integrationType)
|
||||
assert.NoError(t, err)
|
||||
allSecrets := make(map[string]struct{}, len(secrets))
|
||||
for _, key := range secrets {
|
||||
allSecrets[key] = struct{}{}
|
||||
}
|
||||
|
||||
for field := range config.Fields {
|
||||
_, isSecret := allSecrets[field]
|
||||
assert.Equal(t, isSecret, config.IsSecureField(field))
|
||||
}
|
||||
assert.False(t, config.IsSecureField("__--**unknown_field**--__"))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("Unknown type returns error", func(t *testing.T) {
|
||||
_, err := IntegrationConfigFromType("__--**unknown_type**--__")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_SecureFields(t *testing.T) {
|
||||
// Test that all known integration types have a config and correctly mark their secrets as secure.
|
||||
for integrationType := range alertingNotify.AllKnownConfigsForTesting {
|
||||
t.Run(integrationType, func(t *testing.T) {
|
||||
t.Run("contains SecureSettings", func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
expected := make(map[string]bool, len(validIntegration.SecureSettings))
|
||||
for field := range validIntegration.Config.Fields {
|
||||
if validIntegration.Config.IsSecureField(field) {
|
||||
expected[field] = true
|
||||
validIntegration.SecureSettings[field] = "test"
|
||||
delete(validIntegration.Settings, field)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expected, validIntegration.SecureFields())
|
||||
})
|
||||
|
||||
t.Run("contains secret Settings not in SecureSettings", func(t *testing.T) {
|
||||
validIntegration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
expected := make(map[string]bool, len(validIntegration.SecureSettings))
|
||||
for field := range validIntegration.Config.Fields {
|
||||
if validIntegration.Config.IsSecureField(field) {
|
||||
expected[field] = true
|
||||
validIntegration.Settings[field] = "test"
|
||||
delete(validIntegration.SecureSettings, field)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, expected, validIntegration.SecureFields())
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This is a broken type that will error if marshalled.
|
||||
type broken struct {
|
||||
f1 string
|
||||
}
|
||||
|
||||
func (b broken) MarshalJSON() ([]byte, error) {
|
||||
return nil, assert.AnError
|
||||
}
|
||||
|
||||
func TestReceiver_Fingerprint(t *testing.T) {
|
||||
// Test that the fingerprint is stable.
|
||||
im := IntegrationMuts
|
||||
baseReceiver := ReceiverGen(ReceiverMuts.WithName("test receiver"), ReceiverMuts.WithIntegrations(
|
||||
IntegrationGen(im.WithName("test receiver"), im.WithValidConfig("slack"))(),
|
||||
))()
|
||||
baseReceiver.Integrations[0].UID = "stable UID"
|
||||
baseReceiver.Integrations[0].DisableResolveMessage = true
|
||||
baseReceiver.Integrations[0].SecureSettings = map[string]string{"test2": "test2"}
|
||||
baseReceiver.Integrations[0].Settings["broken"] = broken{f1: "this"} // Add a broken type to ensure it is stable in the fingerprint.
|
||||
baseReceiver.Integrations[0].Config = IntegrationConfig{Type: baseReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
|
||||
|
||||
completelyDifferentReceiver := ReceiverGen(ReceiverMuts.WithName("test receiver2"), ReceiverMuts.WithIntegrations(
|
||||
IntegrationGen(im.WithName("test receiver2"), im.WithValidConfig("discord"))(),
|
||||
))()
|
||||
completelyDifferentReceiver.Integrations[0].UID = "stable UID2"
|
||||
completelyDifferentReceiver.Integrations[0].DisableResolveMessage = false
|
||||
completelyDifferentReceiver.Integrations[0].SecureSettings = map[string]string{"test": "test"}
|
||||
completelyDifferentReceiver.Provenance = ProvenanceAPI
|
||||
completelyDifferentReceiver.Integrations[0].Config = IntegrationConfig{Type: completelyDifferentReceiver.Integrations[0].Config.Type} // Remove all fields except Type.
|
||||
|
||||
t.Run("stable across code changes", func(t *testing.T) {
|
||||
expectedFingerprint := "ae141b582965f4f5" // If this is a valid fingerprint generation change, update the expected value.
|
||||
assert.Equal(t, expectedFingerprint, baseReceiver.Fingerprint())
|
||||
})
|
||||
t.Run("stable across clones", func(t *testing.T) {
|
||||
fingerprint := baseReceiver.Fingerprint()
|
||||
receiverClone := baseReceiver.Clone()
|
||||
assert.Equal(t, fingerprint, receiverClone.Fingerprint())
|
||||
})
|
||||
t.Run("stable across Version field modification", func(t *testing.T) {
|
||||
fingerprint := baseReceiver.Fingerprint()
|
||||
receiverClone := baseReceiver.Clone()
|
||||
receiverClone.Version = "new version"
|
||||
assert.Equal(t, fingerprint, receiverClone.Fingerprint())
|
||||
})
|
||||
t.Run("unstable across field modification", func(t *testing.T) {
|
||||
fingerprint := baseReceiver.Fingerprint()
|
||||
excludedFields := map[string]struct{}{
|
||||
"Version": {},
|
||||
}
|
||||
|
||||
reflectVal := reflect.ValueOf(&completelyDifferentReceiver).Elem()
|
||||
|
||||
receiverType := reflect.TypeOf((*Receiver)(nil)).Elem()
|
||||
for i := 0; i < receiverType.NumField(); i++ {
|
||||
field := receiverType.Field(i).Name
|
||||
if _, ok := excludedFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
cp := baseReceiver.Clone()
|
||||
|
||||
// Get the current field being modified.
|
||||
v := reflect.ValueOf(&cp).Elem()
|
||||
vf := v.Field(i)
|
||||
|
||||
otherField := reflectVal.Field(i)
|
||||
if reflect.DeepEqual(otherField.Interface(), vf.Interface()) {
|
||||
assert.Failf(t, "filds are identical", "Receiver field %s is the same as the original, test does not ensure instability across the field", field)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the field to the value of the completelyDifferentReceiver.
|
||||
vf.Set(otherField)
|
||||
|
||||
f2 := cp.Fingerprint()
|
||||
assert.NotEqualf(t, fingerprint, f2, "Receiver field %s does not seem to be used in fingerprint", field)
|
||||
}
|
||||
|
||||
excludedFields = map[string]struct{}{}
|
||||
|
||||
reflectVal = reflect.ValueOf(completelyDifferentReceiver.Integrations[0]).Elem()
|
||||
integrationType := reflect.TypeOf((*Integration)(nil)).Elem()
|
||||
for i := 0; i < integrationType.NumField(); i++ {
|
||||
field := integrationType.Field(i).Name
|
||||
if _, ok := excludedFields[field]; ok {
|
||||
continue
|
||||
}
|
||||
cp := baseReceiver.Clone()
|
||||
integrationCp := cp.Integrations[0]
|
||||
|
||||
// Get the current field being modified.
|
||||
v := reflect.ValueOf(integrationCp).Elem()
|
||||
vf := v.Field(i)
|
||||
|
||||
otherField := reflectVal.Field(i)
|
||||
if reflect.DeepEqual(otherField.Interface(), vf.Interface()) {
|
||||
assert.Failf(t, "filds are identical", "Integration field %s is the same as the original, test does not ensure instability across the field", field)
|
||||
continue
|
||||
}
|
||||
|
||||
// Set the field to the value of the completelyDifferentReceiver.
|
||||
vf.Set(otherField)
|
||||
|
||||
f2 := cp.Fingerprint()
|
||||
assert.NotEqualf(t, fingerprint, f2, "Integration field %s does not seem to be used in fingerprint", field)
|
||||
}
|
||||
})
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
@ -10,11 +11,13 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-openapi/strfmt"
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
amv2 "github.com/prometheus/alertmanager/api/v2/models"
|
||||
"github.com/prometheus/alertmanager/pkg/labels"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
alertingModels "github.com/grafana/alerting/models"
|
||||
|
||||
@ -1092,6 +1095,220 @@ func (n SilenceMutators) WithEmptyId() Mutator[Silence] {
|
||||
}
|
||||
}
|
||||
|
||||
// Receivers
|
||||
|
||||
// CopyReceiverWith creates a deep copy of Receiver and then applies mutators to it.
|
||||
func CopyReceiverWith(r Receiver, mutators ...Mutator[Receiver]) Receiver {
|
||||
c := r.Clone()
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
c.Version = c.Fingerprint()
|
||||
return c
|
||||
}
|
||||
|
||||
// ReceiverGen generates Receiver using a base and mutators.
|
||||
func ReceiverGen(mutators ...Mutator[Receiver]) func() Receiver {
|
||||
return func() Receiver {
|
||||
name := util.GenerateShortUID()
|
||||
integration := IntegrationGen(IntegrationMuts.WithName(name))()
|
||||
c := Receiver{
|
||||
UID: nameToUid(name),
|
||||
Name: name,
|
||||
Integrations: []*Integration{&integration},
|
||||
Provenance: ProvenanceNone,
|
||||
}
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
c.Version = c.Fingerprint()
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
ReceiverMuts = ReceiverMutators{}
|
||||
)
|
||||
|
||||
type ReceiverMutators struct{}
|
||||
|
||||
func (n ReceiverMutators) WithName(name string) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
r.Name = name
|
||||
r.UID = nameToUid(name)
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithProvenance(provenance Provenance) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
r.Provenance = provenance
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithValidIntegration(integrationType string) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
integration := IntegrationGen(IntegrationMuts.WithValidConfig(integrationType))()
|
||||
r.Integrations = []*Integration{&integration}
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithInvalidIntegration(integrationType string) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
integration := IntegrationGen(IntegrationMuts.WithInvalidConfig(integrationType))()
|
||||
r.Integrations = []*Integration{&integration}
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) WithIntegrations(integration ...Integration) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
integrations := make([]*Integration, len(integration))
|
||||
for i, v := range integration {
|
||||
clone := v.Clone()
|
||||
integrations[i] = &clone
|
||||
}
|
||||
r.Integrations = integrations
|
||||
}
|
||||
}
|
||||
|
||||
func (n ReceiverMutators) Encrypted(fn EncryptFn) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
_ = r.Encrypt(fn)
|
||||
}
|
||||
}
|
||||
func (n ReceiverMutators) Decrypted(fn DecryptFn) Mutator[Receiver] {
|
||||
return func(r *Receiver) {
|
||||
_ = r.Decrypt(fn)
|
||||
}
|
||||
}
|
||||
|
||||
// Integrations
|
||||
|
||||
// CopyIntegrationWith creates a deep copy of Integration and then applies mutators to it.
|
||||
func CopyIntegrationWith(r Integration, mutators ...Mutator[Integration]) Integration {
|
||||
c := r.Clone()
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// IntegrationGen generates Integration using a base and mutators.
|
||||
func IntegrationGen(mutators ...Mutator[Integration]) func() Integration {
|
||||
return func() Integration {
|
||||
name := util.GenerateShortUID()
|
||||
randomIntegrationType, _ := randomMapKey(alertingNotify.AllKnownConfigsForTesting)
|
||||
|
||||
c := Integration{
|
||||
UID: util.GenerateShortUID(),
|
||||
Name: name,
|
||||
DisableResolveMessage: rand.Intn(2) == 1,
|
||||
Settings: make(map[string]any),
|
||||
SecureSettings: make(map[string]string),
|
||||
}
|
||||
|
||||
IntegrationMuts.WithValidConfig(randomIntegrationType)(&c)
|
||||
|
||||
for _, mutator := range mutators {
|
||||
mutator(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
IntegrationMuts = IntegrationMutators{}
|
||||
Base64Enrypt = func(s string) (string, error) {
|
||||
return base64.StdEncoding.EncodeToString([]byte(s)), nil
|
||||
}
|
||||
Base64Decrypt = func(s string) (string, error) {
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
return string(b), err
|
||||
}
|
||||
)
|
||||
|
||||
type IntegrationMutators struct{}
|
||||
|
||||
func (n IntegrationMutators) WithUID(uid string) Mutator[Integration] {
|
||||
return func(s *Integration) {
|
||||
s.UID = uid
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithName(name string) Mutator[Integration] {
|
||||
return func(s *Integration) {
|
||||
s.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithValidConfig(integrationType string) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
config := alertingNotify.AllKnownConfigsForTesting[integrationType].GetRawNotifierConfig(c.Name)
|
||||
integrationConfig, _ := IntegrationConfigFromType(integrationType)
|
||||
c.Config = integrationConfig
|
||||
|
||||
var settings map[string]any
|
||||
_ = json.Unmarshal(config.Settings, &settings)
|
||||
|
||||
c.Settings = settings
|
||||
|
||||
// Decrypt secure settings over to normal settings.
|
||||
for k, v := range c.SecureSettings {
|
||||
decodeValue, _ := base64.StdEncoding.DecodeString(v)
|
||||
settings[k] = string(decodeValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithInvalidConfig(integrationType string) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
integrationConfig, _ := IntegrationConfigFromType(integrationType)
|
||||
c.Config = integrationConfig
|
||||
c.Settings = map[string]interface{}{}
|
||||
c.SecureSettings = map[string]string{}
|
||||
if integrationType == "webex" {
|
||||
// Webex passes validation without any settings but should fail with an unparsable URL.
|
||||
c.Settings["api_url"] = "(*^$*^%!@#$*()"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithSettings(settings map[string]any) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
c.Settings = maps.Clone(settings)
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) AddSetting(key string, val any) Mutator[Integration] {
|
||||
return func(c *Integration) {
|
||||
c.Settings[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) WithSecureSettings(secureSettings map[string]string) Mutator[Integration] {
|
||||
return func(r *Integration) {
|
||||
r.SecureSettings = maps.Clone(secureSettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (n IntegrationMutators) AddSecureSetting(key, val string) Mutator[Integration] {
|
||||
return func(r *Integration) {
|
||||
r.SecureSettings[key] = val
|
||||
}
|
||||
}
|
||||
|
||||
func randomMapKey[K comparable, V any](m map[K]V) (K, V) {
|
||||
randIdx := rand.Intn(len(m))
|
||||
i := 0
|
||||
|
||||
for key, val := range m {
|
||||
if i == randIdx {
|
||||
return key, val
|
||||
}
|
||||
i++
|
||||
}
|
||||
return *new(K), *new(V)
|
||||
}
|
||||
|
||||
func ConvertToRecordingRule(rule *AlertRule) {
|
||||
if rule.Record == nil {
|
||||
rule.Record = &Record{}
|
||||
@ -1108,3 +1325,7 @@ func ConvertToRecordingRule(rule *AlertRule) {
|
||||
rule.For = 0
|
||||
rule.NotificationSettings = nil
|
||||
}
|
||||
|
||||
func nameToUid(name string) string { // Avoid legacy_storage.NameToUid import cycle.
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(name))
|
||||
}
|
||||
|
@ -419,6 +419,7 @@ func (ng *AlertNG) init() error {
|
||||
ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, false),
|
||||
configStore,
|
||||
ng.store,
|
||||
ng.store,
|
||||
ng.SecretsService,
|
||||
ng.store,
|
||||
ng.Log,
|
||||
@ -427,6 +428,7 @@ func (ng *AlertNG) init() error {
|
||||
ac.NewReceiverAccess[*models.Receiver](ng.accesscontrol, true),
|
||||
configStore,
|
||||
ng.store,
|
||||
ng.store,
|
||||
ng.SecretsService,
|
||||
ng.store,
|
||||
ng.Log,
|
||||
|
@ -1601,7 +1601,7 @@ func GetAvailableNotifiers() []*NotifierPlugin {
|
||||
func GetSecretKeysForContactPointType(contactPointType string) ([]string, error) {
|
||||
notifiers := GetAvailableNotifiers()
|
||||
for _, n := range notifiers {
|
||||
if n.Type == contactPointType {
|
||||
if strings.EqualFold(n.Type, contactPointType) {
|
||||
var secureFields []string
|
||||
for _, field := range n.Options {
|
||||
if field.Secure {
|
||||
@ -1613,3 +1613,14 @@ func GetSecretKeysForContactPointType(contactPointType string) ([]string, error)
|
||||
}
|
||||
return nil, fmt.Errorf("no secrets configured for type '%s'", contactPointType)
|
||||
}
|
||||
|
||||
// ConfigForIntegrationType returns the config for the given integration type. Returns error is integration type is not known.
|
||||
func ConfigForIntegrationType(contactPointType string) (*NotifierPlugin, error) {
|
||||
notifiers := GetAvailableNotifiers()
|
||||
for _, n := range notifiers {
|
||||
if strings.EqualFold(n.Type, contactPointType) {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("unknown integration type '%s'", contactPointType)
|
||||
}
|
||||
|
@ -2,17 +2,103 @@ package notifier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"fmt"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
alertingTemplates "github.com/grafana/alerting/templates"
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
|
||||
)
|
||||
|
||||
func PostableApiReceiversToReceivers(postables []*apimodels.PostableApiReceiver, storedProvenances map[string]models.Provenance) ([]*models.Receiver, error) {
|
||||
receivers := make([]*models.Receiver, 0, len(postables))
|
||||
for _, postable := range postables {
|
||||
r, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receivers = append(receivers, r)
|
||||
}
|
||||
return receivers, nil
|
||||
}
|
||||
|
||||
func PostableApiReceiverToReceiver(postable *apimodels.PostableApiReceiver, provenance models.Provenance) (*models.Receiver, error) {
|
||||
integrations, err := PostableGrafanaReceiversToIntegrations(postable.GrafanaManagedReceivers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &models.Receiver{
|
||||
UID: legacy_storage.NameToUid(postable.GetName()), // TODO replace with stable UID.
|
||||
Name: postable.GetName(),
|
||||
Integrations: integrations,
|
||||
Provenance: provenance,
|
||||
}
|
||||
r.Version = r.Fingerprint()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func PostableGrafanaReceiversToIntegrations(postables []*apimodels.PostableGrafanaReceiver) ([]*models.Integration, error) {
|
||||
integrations := make([]*models.Integration, 0, len(postables))
|
||||
for _, cfg := range postables {
|
||||
integration, err := PostableGrafanaReceiverToIntegration(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
integrations = append(integrations, integration)
|
||||
}
|
||||
|
||||
return integrations, nil
|
||||
}
|
||||
|
||||
func PostableGrafanaReceiverToIntegration(p *apimodels.PostableGrafanaReceiver) (*models.Integration, error) {
|
||||
config, err := models.IntegrationConfigFromType(p.Type)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
integration := &models.Integration{
|
||||
UID: p.UID,
|
||||
Name: p.Name,
|
||||
Config: config,
|
||||
DisableResolveMessage: p.DisableResolveMessage,
|
||||
Settings: make(map[string]any, len(p.Settings)),
|
||||
SecureSettings: make(map[string]string, len(p.SecureSettings)),
|
||||
}
|
||||
|
||||
if p.Settings != nil {
|
||||
if err := json.Unmarshal(p.Settings, &integration.Settings); err != nil {
|
||||
return nil, fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", integration.Config.Type, p.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range p.SecureSettings {
|
||||
if v != "" {
|
||||
integration.SecureSettings[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return integration, nil
|
||||
}
|
||||
|
||||
// getReceiverProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
|
||||
func getReceiverProvenance(storedProvenances map[string]models.Provenance, r *apimodels.PostableApiReceiver) models.Provenance {
|
||||
if len(r.GrafanaManagedReceivers) == 0 {
|
||||
return models.ProvenanceNone
|
||||
}
|
||||
|
||||
// Current provisioning works on the integration level, so we need some way to determine the provenance of the
|
||||
// entire receiver. All integrations in a receiver should have the same provenance, but we don't want to rely on
|
||||
// this assumption in case the first provenance is None and a later one is not. To this end, we return the first
|
||||
// non-zero provenance we find.
|
||||
for _, contactPoint := range r.GrafanaManagedReceivers {
|
||||
if p, exists := storedProvenances[contactPoint.UID]; exists && p != models.ProvenanceNone {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return models.ProvenanceNone
|
||||
}
|
||||
|
||||
func PostableGrafanaReceiverToGrafanaIntegrationConfig(p *apimodels.PostableGrafanaReceiver) *alertingNotify.GrafanaIntegrationConfig {
|
||||
return &alertingNotify.GrafanaIntegrationConfig{
|
||||
UID: p.UID,
|
||||
@ -46,76 +132,6 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf
|
||||
return apiReceivers
|
||||
}
|
||||
|
||||
type DecryptFn = func(value string) string
|
||||
|
||||
func PostableToGettableGrafanaReceiver(r *apimodels.PostableGrafanaReceiver, provenance *models.Provenance, decryptFn DecryptFn) (apimodels.GettableGrafanaReceiver, error) {
|
||||
out := apimodels.GettableGrafanaReceiver{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
Type: r.Type,
|
||||
DisableResolveMessage: r.DisableResolveMessage,
|
||||
SecureFields: make(map[string]bool, len(r.SecureSettings)),
|
||||
}
|
||||
if provenance != nil {
|
||||
out.Provenance = apimodels.Provenance(*provenance)
|
||||
}
|
||||
|
||||
if r.Settings == nil && r.SecureSettings == nil {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
settings := simplejson.New()
|
||||
if r.Settings != nil {
|
||||
var err error
|
||||
settings, err = simplejson.NewJson(r.Settings)
|
||||
if err != nil {
|
||||
return apimodels.GettableGrafanaReceiver{}, err
|
||||
}
|
||||
}
|
||||
|
||||
for k, v := range r.SecureSettings {
|
||||
decryptedValue := decryptFn(v)
|
||||
if decryptedValue == "" {
|
||||
continue
|
||||
} else {
|
||||
settings.Set(k, decryptedValue)
|
||||
}
|
||||
out.SecureFields[k] = true
|
||||
}
|
||||
|
||||
jsonBytes, err := settings.MarshalJSON()
|
||||
if err != nil {
|
||||
return apimodels.GettableGrafanaReceiver{}, err
|
||||
}
|
||||
|
||||
out.Settings = jsonBytes
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances map[string]models.Provenance, decryptFn DecryptFn) (apimodels.GettableApiReceiver, error) {
|
||||
out := apimodels.GettableApiReceiver{
|
||||
Receiver: config.Receiver{
|
||||
Name: r.Receiver.Name,
|
||||
},
|
||||
}
|
||||
|
||||
for _, gr := range r.GrafanaManagedReceivers {
|
||||
var prov *models.Provenance
|
||||
if p, ok := provenances[gr.UID]; ok {
|
||||
prov = &p
|
||||
}
|
||||
|
||||
gettable, err := PostableToGettableGrafanaReceiver(gr, prov, decryptFn)
|
||||
if err != nil {
|
||||
return apimodels.GettableApiReceiver{}, err
|
||||
}
|
||||
out.GrafanaManagedReceivers = append(out.GrafanaManagedReceivers, &gettable)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ToTemplateDefinitions converts the given PostableUserConfig's TemplateFiles to a slice of TemplateDefinitions.
|
||||
func ToTemplateDefinitions(cfg *apimodels.PostableUserConfig) []alertingTemplates.TemplateDefinition {
|
||||
out := make([]alertingTemplates.TemplateDefinition, 0, len(cfg.TemplateFiles))
|
||||
|
@ -2,6 +2,13 @@ package legacy_storage
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"maps"
|
||||
|
||||
alertingNotify "github.com/grafana/alerting/notify"
|
||||
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
func NameToUid(name string) string {
|
||||
@ -15,3 +22,42 @@ func UidToName(uid string) (string, error) {
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func IntegrationToPostableGrafanaReceiver(integration *models.Integration) (*apimodels.PostableGrafanaReceiver, error) {
|
||||
postable := &apimodels.PostableGrafanaReceiver{
|
||||
UID: integration.UID,
|
||||
Name: integration.Name,
|
||||
Type: integration.Config.Type,
|
||||
DisableResolveMessage: integration.DisableResolveMessage,
|
||||
SecureSettings: maps.Clone(integration.SecureSettings),
|
||||
}
|
||||
|
||||
if len(integration.Settings) > 0 {
|
||||
jsonBytes, err := json.Marshal(integration.Settings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postable.Settings = jsonBytes
|
||||
}
|
||||
return postable, nil
|
||||
}
|
||||
|
||||
func ReceiverToPostableApiReceiver(r *models.Receiver) (*apimodels.PostableApiReceiver, error) {
|
||||
integrations := apimodels.PostableGrafanaReceivers{
|
||||
GrafanaManagedReceivers: make([]*apimodels.PostableGrafanaReceiver, 0, len(r.Integrations)),
|
||||
}
|
||||
for _, cfg := range r.Integrations {
|
||||
postable, err := IntegrationToPostableGrafanaReceiver(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
integrations.GrafanaManagedReceivers = append(integrations.GrafanaManagedReceivers, postable)
|
||||
}
|
||||
|
||||
return &apimodels.PostableApiReceiver{
|
||||
Receiver: alertingNotify.ConfigReceiver{
|
||||
Name: r.Name,
|
||||
},
|
||||
PostableGrafanaReceivers: integrations,
|
||||
}, nil
|
||||
}
|
||||
|
@ -5,6 +5,13 @@ import "github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
var (
|
||||
ErrNoAlertmanagerConfiguration = errutil.Internal("alerting.notification.configMissing", errutil.WithPublicMessage("No alertmanager configuration present in this organization"))
|
||||
ErrBadAlertmanagerConfiguration = errutil.Internal("alerting.notification.configCorrupted").MustTemplate("Failed to unmarshal the Alertmanager configuration", errutil.WithPublic("Current Alertmanager configuration in the storage is corrupted. Reset the configuration or rollback to a recent valid one."))
|
||||
|
||||
ErrReceiverNotFound = errutil.NotFound("alerting.notifications.receivers.notFound", errutil.WithPublicMessage("Receiver not found"))
|
||||
ErrReceiverExists = errutil.BadRequest("alerting.notifications.receivers.exists", errutil.WithPublicMessage("Receiver with this name already exists. Use a different name or update an existing one."))
|
||||
ErrReceiverInvalid = errutil.Conflict("alerting.notifications.receivers.invalid").MustTemplate(
|
||||
"Invalid receiver: '{{ .Public.Reason }}'",
|
||||
errutil.WithPublic("Invalid receiver: '{{ .Public.Reason }}'"),
|
||||
)
|
||||
)
|
||||
|
||||
func makeErrBadAlertmanagerConfiguration(err error) error {
|
||||
@ -16,3 +23,13 @@ func makeErrBadAlertmanagerConfiguration(err error) error {
|
||||
}
|
||||
return ErrBadAlertmanagerConfiguration.Build(data)
|
||||
}
|
||||
|
||||
func MakeErrReceiverInvalid(err error) error {
|
||||
data := errutil.TemplateData{
|
||||
Public: map[string]interface{}{
|
||||
"Reason": err.Error(),
|
||||
},
|
||||
Error: err,
|
||||
}
|
||||
return ErrReceiverInvalid.Build(data)
|
||||
}
|
||||
|
@ -1,9 +1,13 @@
|
||||
package legacy_storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
)
|
||||
|
||||
func (rev *ConfigRevision) DeleteReceiver(uid string) {
|
||||
@ -13,17 +17,70 @@ func (rev *ConfigRevision) DeleteReceiver(uid string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (rev *ConfigRevision) CreateReceiver(receiver *models.Receiver) error {
|
||||
// Check if the receiver already exists.
|
||||
_, err := rev.GetReceiver(receiver.GetUID())
|
||||
if err == nil {
|
||||
return ErrReceiverExists.Errorf("")
|
||||
}
|
||||
if !errors.Is(err, ErrReceiverNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateAndSetIntegrationUIDs(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
postable, err := ReceiverToPostableApiReceiver(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rev.Config.AlertmanagerConfig.Receivers = append(rev.Config.AlertmanagerConfig.Receivers, postable)
|
||||
|
||||
if err := rev.ValidateReceiver(postable); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rev *ConfigRevision) UpdateReceiver(receiver *models.Receiver) error {
|
||||
existing, err := rev.GetReceiver(receiver.GetUID())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateAndSetIntegrationUIDs(receiver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
postable, err := ReceiverToPostableApiReceiver(receiver)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update receiver in the configuration.
|
||||
*existing = *postable
|
||||
|
||||
if err := rev.ValidateReceiver(existing); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rev *ConfigRevision) ReceiverNameUsedByRoutes(name string) bool {
|
||||
return isReceiverInUse(name, []*definitions.Route{rev.Config.AlertmanagerConfig.Route})
|
||||
}
|
||||
|
||||
func (rev *ConfigRevision) GetReceiver(uid string) *definitions.PostableApiReceiver {
|
||||
func (rev *ConfigRevision) GetReceiver(uid string) (*definitions.PostableApiReceiver, error) {
|
||||
for _, r := range rev.Config.AlertmanagerConfig.Receivers {
|
||||
if NameToUid(r.GetName()) == uid {
|
||||
return r
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return nil, ErrReceiverNotFound.Errorf("")
|
||||
}
|
||||
|
||||
func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableApiReceiver {
|
||||
@ -36,6 +93,52 @@ func (rev *ConfigRevision) GetReceivers(uids []string) []*definitions.PostableAp
|
||||
return receivers
|
||||
}
|
||||
|
||||
// RenameReceiverInRoutes renames all references to a receiver in routes.
|
||||
func (rev *ConfigRevision) RenameReceiverInRoutes(oldName, newName string) {
|
||||
RenameReceiverInRoute(oldName, newName, rev.Config.AlertmanagerConfig.Route)
|
||||
}
|
||||
|
||||
// ValidateReceiver checks if the given receiver conflicts in name or integration UID with existing receivers.
|
||||
// We only check the receiver being modified to prevent existing issues from other receivers being reported.
|
||||
func (rev *ConfigRevision) ValidateReceiver(p *definitions.PostableApiReceiver) error {
|
||||
uids := make(map[string]struct{}, len(rev.Config.AlertmanagerConfig.Receivers))
|
||||
for _, integrations := range p.GrafanaManagedReceivers {
|
||||
if _, exists := uids[integrations.UID]; exists {
|
||||
return MakeErrReceiverInvalid(fmt.Errorf("integration with UID %q already exists", integrations.UID))
|
||||
}
|
||||
uids[integrations.UID] = struct{}{}
|
||||
}
|
||||
|
||||
for _, r := range rev.Config.AlertmanagerConfig.Receivers {
|
||||
if p == r {
|
||||
// Skip the receiver itself.
|
||||
continue
|
||||
}
|
||||
if r.GetName() == p.GetName() {
|
||||
return MakeErrReceiverInvalid(fmt.Errorf("name %q already exists", r.GetName()))
|
||||
}
|
||||
|
||||
for _, gr := range r.GrafanaManagedReceivers {
|
||||
if _, exists := uids[gr.UID]; exists {
|
||||
return MakeErrReceiverInvalid(fmt.Errorf("integration with UID %q already exists", gr.UID))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RenameReceiverInRoute(oldName, newName string, routes ...*definitions.Route) {
|
||||
if len(routes) == 0 {
|
||||
return
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.Receiver == oldName {
|
||||
route.Receiver = newName
|
||||
}
|
||||
RenameReceiverInRoute(oldName, newName, route.Routes...)
|
||||
}
|
||||
}
|
||||
|
||||
// isReceiverInUse checks if a receiver is used in a route or any of its sub-routes.
|
||||
func isReceiverInUse(name string, routes []*definitions.Route) bool {
|
||||
if len(routes) == 0 {
|
||||
@ -51,3 +154,15 @@ func isReceiverInUse(name string, routes []*definitions.Route) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateAndSetIntegrationUIDs validates existing integration UIDs and generates them if they are empty.
|
||||
func validateAndSetIntegrationUIDs(receiver *models.Receiver) error {
|
||||
for _, integration := range receiver.Integrations {
|
||||
if integration.UID == "" {
|
||||
integration.UID = util.GenerateShortUID()
|
||||
} else if err := util.ValidateUID(integration.UID); err != nil {
|
||||
return MakeErrReceiverInvalid(fmt.Errorf("integration UID %q is invalid: %w", integration.UID, err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -3,8 +3,11 @@ package notifier
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/alerting/definition"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/errutil"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
@ -17,8 +20,14 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReceiverNotFound = errutil.NotFound("alerting.notifications.receiver.notFound")
|
||||
ErrReceiverInUse = errutil.Conflict("alerting.notifications.receiver.used").MustTemplate("Receiver is used by notification policies or alert rules")
|
||||
ErrReceiverInUse = errutil.Conflict("alerting.notifications.receivers.used").MustTemplate(
|
||||
"Receiver is used by '{{ .Public.UsedBy }}'",
|
||||
errutil.WithPublic("Receiver is used by {{ .Public.UsedBy }}"),
|
||||
)
|
||||
ErrReceiverVersionConflict = errutil.Conflict("alerting.notifications.receivers.conflict").MustTemplate(
|
||||
"Provided version '{{ .Public.Version }}' of receiver '{{ .Public.Name }}' does not match current version '{{ .Public.CurrentVersion }}'",
|
||||
errutil.WithPublic("Provided version '{{ .Public.Version }}' of receiver '{{ .Public.Name }}' does not match current version '{{ .Public.CurrentVersion }}'"),
|
||||
)
|
||||
)
|
||||
|
||||
// ReceiverService is the service for managing alertmanager receivers.
|
||||
@ -26,17 +35,34 @@ type ReceiverService struct {
|
||||
authz receiverAccessControlService
|
||||
provisioningStore provisoningStore
|
||||
cfgStore alertmanagerConfigStore
|
||||
encryptionService secrets.Service
|
||||
ruleNotificationsStore alertRuleNotificationSettingsStore
|
||||
encryptionService secretService
|
||||
xact transactionManager
|
||||
log log.Logger
|
||||
validator validation.ProvenanceStatusTransitionValidator
|
||||
provenanceValidator validation.ProvenanceStatusTransitionValidator
|
||||
}
|
||||
|
||||
type alertRuleNotificationSettingsStore interface {
|
||||
RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error)
|
||||
ListNotificationSettings(ctx context.Context, q models.ListNotificationSettingsQuery) (map[models.AlertRuleKey][]models.NotificationSettings, error)
|
||||
}
|
||||
|
||||
type secretService interface {
|
||||
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
|
||||
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// receiverAccessControlService provides access control for receivers.
|
||||
type receiverAccessControlService interface {
|
||||
FilterRead(context.Context, identity.Requester, ...*models.Receiver) ([]*models.Receiver, error)
|
||||
AuthorizeRead(context.Context, identity.Requester, *models.Receiver) error
|
||||
FilterReadDecrypted(context.Context, identity.Requester, ...*models.Receiver) ([]*models.Receiver, error)
|
||||
AuthorizeReadDecrypted(context.Context, identity.Requester, *models.Receiver) error
|
||||
HasList(ctx context.Context, user identity.Requester) (bool, error)
|
||||
HasReadAll(ctx context.Context, user identity.Requester) (bool, error)
|
||||
AuthorizeReadDecryptedAll(ctx context.Context, user identity.Requester) error
|
||||
|
||||
AuthorizeCreate(context.Context, identity.Requester) error
|
||||
AuthorizeUpdate(context.Context, identity.Requester, *models.Receiver) error
|
||||
AuthorizeDeleteByUID(context.Context, identity.Requester, string) error
|
||||
}
|
||||
|
||||
type alertmanagerConfigStore interface {
|
||||
@ -58,7 +84,8 @@ func NewReceiverService(
|
||||
authz receiverAccessControlService,
|
||||
cfgStore alertmanagerConfigStore,
|
||||
provisioningStore provisoningStore,
|
||||
encryptionService secrets.Service,
|
||||
ruleNotificationsStore alertRuleNotificationSettingsStore,
|
||||
encryptionService secretService,
|
||||
xact transactionManager,
|
||||
log log.Logger,
|
||||
) *ReceiverService {
|
||||
@ -66,53 +93,51 @@ func NewReceiverService(
|
||||
authz: authz,
|
||||
provisioningStore: provisioningStore,
|
||||
cfgStore: cfgStore,
|
||||
ruleNotificationsStore: ruleNotificationsStore,
|
||||
encryptionService: encryptionService,
|
||||
xact: xact,
|
||||
log: log,
|
||||
validator: validation.ValidateProvenanceRelaxed,
|
||||
provenanceValidator: validation.ValidateProvenanceRelaxed,
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) shouldDecrypt(ctx context.Context, user identity.Requester, reqDecrypt bool) (bool, error) {
|
||||
if !reqDecrypt {
|
||||
return false, nil
|
||||
}
|
||||
if err := rs.authz.AuthorizeReadDecryptedAll(ctx, user); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetReceiver returns a receiver by name.
|
||||
// The receiver's secure settings are decrypted if requested and the user has access to do so.
|
||||
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) {
|
||||
revision, err := rs.cfgStore.Get(ctx, q.OrgID)
|
||||
if err != nil {
|
||||
return definitions.GettableApiReceiver{}, err
|
||||
return nil, err
|
||||
}
|
||||
postable := revision.GetReceiver(legacy_storage.NameToUid(q.Name))
|
||||
if postable == nil {
|
||||
return definitions.GettableApiReceiver{}, ErrReceiverNotFound.Errorf("")
|
||||
}
|
||||
|
||||
decrypt, err := rs.shouldDecrypt(ctx, user, q.Decrypt)
|
||||
postable, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name))
|
||||
if err != nil {
|
||||
return definitions.GettableApiReceiver{}, err
|
||||
return nil, err
|
||||
}
|
||||
decryptFn := rs.decryptOrRedact(ctx, decrypt, q.Name, "")
|
||||
|
||||
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
|
||||
if err != nil {
|
||||
return definitions.GettableApiReceiver{}, err
|
||||
return nil, err
|
||||
}
|
||||
rcv, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return PostableToGettableApiReceiver(postable, storedProvenances, decryptFn)
|
||||
auth := rs.authz.AuthorizeReadDecrypted
|
||||
if !q.Decrypt {
|
||||
auth = rs.authz.AuthorizeRead
|
||||
}
|
||||
if err := auth(ctx, user, rcv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rs.decryptOrRedactSecureSettings(ctx, rcv, q.Decrypt)
|
||||
|
||||
return rcv, nil
|
||||
}
|
||||
|
||||
// GetReceivers returns a list of receivers a user has access to.
|
||||
// Receivers can be filtered by name, and secure settings are decrypted if requested and the user has access to do so.
|
||||
func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) {
|
||||
func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, user identity.Requester) ([]*models.Receiver, error) {
|
||||
uids := make([]string, 0, len(q.Names))
|
||||
for _, name := range q.Names {
|
||||
uids = append(uids, legacy_storage.NameToUid(name))
|
||||
@ -128,41 +153,25 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
decrypt, err := rs.shouldDecrypt(ctx, user, q.Decrypt)
|
||||
receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
readRedactedAccess, err := rs.authz.HasReadAll(ctx, user)
|
||||
filterFn := rs.authz.FilterReadDecrypted
|
||||
if !q.Decrypt {
|
||||
filterFn = rs.authz.FilterRead
|
||||
}
|
||||
filtered, err := filterFn(ctx, user, receivers...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// User doesn't have any permissions on the receivers.
|
||||
// This is mostly a safeguard as it should not be possible with current API endpoints + middleware authentication.
|
||||
if !readRedactedAccess {
|
||||
return nil, nil
|
||||
for _, r := range filtered {
|
||||
rs.decryptOrRedactSecureSettings(ctx, r, q.Decrypt)
|
||||
}
|
||||
|
||||
var output []definitions.GettableApiReceiver
|
||||
for i := q.Offset; i < len(postables); i++ {
|
||||
r := postables[i]
|
||||
|
||||
decryptFn := rs.decryptOrRedact(ctx, decrypt, r.Name, "")
|
||||
res, err := PostableToGettableApiReceiver(r, storedProvenances, decryptFn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
output = append(output, res)
|
||||
// stop if we have reached the limit or we have found all the requested receivers
|
||||
if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return output, nil
|
||||
return limitOffset(filtered, q.Offset, q.Limit), nil
|
||||
}
|
||||
|
||||
// ListReceivers returns a list of receivers a user has access to.
|
||||
@ -170,17 +179,12 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive
|
||||
// This offers an looser permissions compared to GetReceivers. When a user doesn't have read access it will check for list access instead of returning an empty list.
|
||||
// If the users has list access, all receiver settings will be removed from the response. This option is for backwards compatibility with the v1/receivers endpoint
|
||||
// and should be removed when FGAC is fully implemented.
|
||||
func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]definitions.GettableApiReceiver, error) { // TODO: Remove this method with FGAC.
|
||||
func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, user identity.Requester) ([]*models.Receiver, error) { // TODO: Remove this method with FGAC.
|
||||
listAccess, err := rs.authz.HasList(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
readRedactedAccess, err := rs.authz.HasReadAll(ctx, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uids := make([]string, 0, len(q.Names))
|
||||
for _, name := range q.Names {
|
||||
uids = append(uids, legacy_storage.NameToUid(name))
|
||||
@ -196,66 +200,75 @@ func (rs *ReceiverService) ListReceivers(ctx context.Context, q models.ListRecei
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// User doesn't have any permissions on the receivers.
|
||||
// This is mostly a safeguard as it should not be possible with current API endpoints + middleware authentication.
|
||||
if !listAccess && !readRedactedAccess {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var output []definitions.GettableApiReceiver
|
||||
for i := q.Offset; i < len(postables); i++ {
|
||||
r := postables[i]
|
||||
|
||||
// Remove settings.
|
||||
for _, integration := range r.GrafanaManagedReceivers {
|
||||
integration.Settings = nil
|
||||
integration.SecureSettings = nil
|
||||
integration.DisableResolveMessage = false
|
||||
}
|
||||
|
||||
decryptFn := rs.decryptOrRedact(ctx, false, r.Name, "")
|
||||
res, err := PostableToGettableApiReceiver(r, storedProvenances, decryptFn)
|
||||
receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
output = append(output, res)
|
||||
// stop if we have reached the limit or we have found all the requested receivers
|
||||
if (len(output) == q.Limit && q.Limit > 0) || (len(output) == len(q.Names)) {
|
||||
break
|
||||
if !listAccess {
|
||||
var err error
|
||||
receivers, err = rs.authz.FilterRead(ctx, user, receivers...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return output, nil
|
||||
// Remove settings.
|
||||
for _, r := range receivers {
|
||||
for _, integration := range r.Integrations {
|
||||
integration.Settings = nil
|
||||
integration.SecureSettings = nil
|
||||
integration.DisableResolveMessage = false
|
||||
}
|
||||
}
|
||||
|
||||
return limitOffset(receivers, q.Offset, q.Limit), nil
|
||||
}
|
||||
|
||||
// DeleteReceiver deletes a receiver by uid.
|
||||
// UID field currently does not exist, we assume the uid is a particular hashed value of the receiver name.
|
||||
func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, orgID int64, callerProvenance definitions.Provenance, version string) error {
|
||||
//TODO: Check delete permissions.
|
||||
func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, callerProvenance definitions.Provenance, version string, orgID int64, user identity.Requester) error {
|
||||
if err := rs.authz.AuthorizeDeleteByUID(ctx, user, uid); err != nil {
|
||||
return err
|
||||
}
|
||||
revision, err := rs.cfgStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
postable := revision.GetReceiver(uid)
|
||||
if postable == nil {
|
||||
return ErrReceiverNotFound.Errorf("")
|
||||
postable, err := revision.GetReceiver(uid)
|
||||
if err != nil {
|
||||
if errors.Is(err, legacy_storage.ErrReceiverNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Implement + check optimistic concurrency.
|
||||
|
||||
storedProvenance, err := rs.getContactPointProvenance(ctx, postable, orgID)
|
||||
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := rs.validator(storedProvenance, models.Provenance(callerProvenance)); err != nil {
|
||||
// Check optimistic concurrency.
|
||||
// Optimistic concurrency is optional for delete operations, but we still check it if a version is provided.
|
||||
if version != "" {
|
||||
err = rs.checkOptimisticConcurrency(existing, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
rs.log.Debug("ignoring optimistic concurrency check because version was not provided", "receiver", existing.Name, "operation", "delete")
|
||||
}
|
||||
|
||||
if err := rs.provenanceValidator(existing.Provenance, models.Provenance(callerProvenance)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usedByRoutes := revision.ReceiverNameUsedByRoutes(postable.GetName())
|
||||
usedByRules, err := rs.UsedByRules(ctx, orgID, uid)
|
||||
usedByRoutes := revision.ReceiverNameUsedByRoutes(existing.Name)
|
||||
usedByRules, err := rs.UsedByRules(ctx, orgID, existing.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -271,26 +284,172 @@ func (rs *ReceiverService) DeleteReceiver(ctx context.Context, uid string, orgID
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rs.deleteProvenances(ctx, orgID, postable.GrafanaManagedReceivers)
|
||||
return rs.deleteProvenances(ctx, orgID, existing.Integrations)
|
||||
})
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) CreateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) {
|
||||
// TODO: Stub
|
||||
panic("not implemented")
|
||||
func (rs *ReceiverService) CreateReceiver(ctx context.Context, r *models.Receiver, orgID int64, user identity.Requester) (*models.Receiver, error) {
|
||||
if err := rs.authz.AuthorizeCreate(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
revision, err := rs.cfgStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
createdReceiver := r.Clone()
|
||||
err = createdReceiver.Encrypt(rs.encryptor(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := createdReceiver.Validate(rs.decryptor(ctx)); err != nil {
|
||||
return nil, legacy_storage.MakeErrReceiverInvalid(err)
|
||||
}
|
||||
|
||||
err = revision.CreateReceiver(&createdReceiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
createdReceiver.Version = createdReceiver.Fingerprint()
|
||||
|
||||
err = rs.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
err = rs.cfgStore.Save(ctx, revision, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rs.setReceiverProvenance(ctx, orgID, &createdReceiver)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &createdReceiver, nil
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r definitions.GettableApiReceiver, orgID int64) (definitions.GettableApiReceiver, error) {
|
||||
// TODO: Stub
|
||||
panic("not implemented")
|
||||
func (rs *ReceiverService) UpdateReceiver(ctx context.Context, r *models.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*models.Receiver, error) {
|
||||
// TODO: To support receiver renaming, we need to consider permissions on old and new UID since UIDs are tied to names.
|
||||
if err := rs.authz.AuthorizeUpdate(ctx, user, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
revision, err := rs.cfgStore.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postable, err := revision.GetReceiver(r.GetUID())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := PostableApiReceiverToReceiver(postable, getReceiverProvenance(storedProvenances, postable))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check optimistic concurrency.
|
||||
err = rs.checkOptimisticConcurrency(existing, r.Version)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := rs.provenanceValidator(existing.Provenance, r.Provenance); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// We need to perform two important steps to process settings on an updated integration:
|
||||
// 1. Encrypt new or updated secret fields as they will arrive in plain text.
|
||||
// 2. For updates, callers do not re-send unchanged secure settings and instead mark them in SecureFields. We need
|
||||
// to load these secure settings from the existing integration.
|
||||
updatedReceiver := r.Clone()
|
||||
err = updatedReceiver.Encrypt(rs.encryptor(ctx))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(storedSecureFields) > 0 {
|
||||
updatedReceiver.WithExistingSecureFields(existing, storedSecureFields)
|
||||
}
|
||||
|
||||
if err := updatedReceiver.Validate(rs.decryptor(ctx)); err != nil {
|
||||
return nil, legacy_storage.MakeErrReceiverInvalid(err)
|
||||
}
|
||||
|
||||
err = revision.UpdateReceiver(&updatedReceiver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updatedReceiver.Version = updatedReceiver.Fingerprint()
|
||||
|
||||
err = rs.xact.InTransaction(ctx, func(ctx context.Context) error {
|
||||
// If the name of the receiver changed, we must update references to it in both routes and notification settings.
|
||||
// TODO: Needs to check provenance status compatibility: For example, if we rename a receiver via UI but rules are provisioned, this call should be rejected.
|
||||
if existing.Name != r.Name {
|
||||
affected, err := rs.ruleNotificationsStore.RenameReceiverInNotificationSettings(ctx, orgID, existing.Name, r.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected > 0 {
|
||||
rs.log.Info("Renamed receiver in notification settings", "oldName", existing.Name, "newName", r.Name, "affectedSettings", affected)
|
||||
}
|
||||
revision.RenameReceiverInRoutes(existing.Name, r.Name)
|
||||
}
|
||||
|
||||
err = rs.cfgStore.Save(ctx, revision, orgID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = rs.deleteProvenances(ctx, orgID, removedIntegrations(existing, &updatedReceiver))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return rs.setReceiverProvenance(ctx, orgID, &updatedReceiver)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &updatedReceiver, nil
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, uid string) ([]models.AlertRuleKey, error) {
|
||||
//TODO: Implement
|
||||
return []models.AlertRuleKey{}, nil
|
||||
func (rs *ReceiverService) UsedByRules(ctx context.Context, orgID int64, name string) ([]models.AlertRuleKey, error) {
|
||||
keys, err := rs.ruleNotificationsStore.ListNotificationSettings(ctx, models.ListNotificationSettingsQuery{OrgID: orgID, ReceiverName: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return maps.Keys(keys), nil
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, integrations []*definition.PostableGrafanaReceiver) error {
|
||||
func removedIntegrations(old, new *models.Receiver) []*models.Integration {
|
||||
updatedUIDs := make(map[string]struct{}, len(new.Integrations))
|
||||
for _, integration := range new.Integrations {
|
||||
updatedUIDs[integration.UID] = struct{}{}
|
||||
}
|
||||
removed := make([]*models.Integration, 0)
|
||||
for _, existingIntegration := range old.Integrations {
|
||||
if _, ok := updatedUIDs[existingIntegration.UID]; !ok {
|
||||
removed = append(removed, existingIntegration)
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) setReceiverProvenance(ctx context.Context, orgID int64, receiver *models.Receiver) error {
|
||||
// Add provenance for all integrations in the receiver.
|
||||
for _, integration := range receiver.Integrations {
|
||||
target := definitions.EmbeddedContactPoint{UID: integration.UID}
|
||||
if err := rs.provisioningStore.SetProvenance(ctx, &target, orgID, receiver.Provenance); err != nil { // TODO: Should we set ProvenanceNone?
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, integrations []*models.Integration) error {
|
||||
// Delete provenance for all integrations.
|
||||
for _, integration := range integrations {
|
||||
target := definitions.EmbeddedContactPoint{UID: integration.UID}
|
||||
@ -301,47 +460,73 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rs *ReceiverService) decryptOrRedact(ctx context.Context, decrypt bool, name, fallback string) func(value string) string {
|
||||
return func(value string) string {
|
||||
if !decrypt {
|
||||
return definitions.RedactedValue
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
func (rs *ReceiverService) decryptOrRedactSecureSettings(ctx context.Context, recv *models.Receiver, decrypt bool) {
|
||||
if decrypt {
|
||||
err := recv.Decrypt(rs.decryptor(ctx))
|
||||
if err != nil {
|
||||
rs.log.Warn("failed to decode secure setting", "name", name, "error", err)
|
||||
return fallback
|
||||
rs.log.Warn("failed to decrypt secure settings", "name", recv.Name, "error", err)
|
||||
}
|
||||
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
|
||||
if err != nil {
|
||||
rs.log.Warn("failed to decrypt secure setting", "name", name, "error", err)
|
||||
return fallback
|
||||
}
|
||||
return string(decrypted)
|
||||
} else {
|
||||
recv.Redact(rs.redactor())
|
||||
}
|
||||
}
|
||||
|
||||
// getContactPointProvenance determines the provenance of a definitions.PostableApiReceiver based on the provenance of its integrations.
|
||||
func (rs *ReceiverService) getContactPointProvenance(ctx context.Context, r *definitions.PostableApiReceiver, orgID int64) (models.Provenance, error) {
|
||||
if len(r.GrafanaManagedReceivers) == 0 {
|
||||
return models.ProvenanceNone, nil
|
||||
}
|
||||
|
||||
storedProvenances, err := rs.provisioningStore.GetProvenances(ctx, orgID, (&definitions.EmbeddedContactPoint{}).ResourceType())
|
||||
// decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used.
|
||||
func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn {
|
||||
return func(value string) (string, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(decrypted), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Current provisioning works on the integration level, so we need some way to determine the provenance of the
|
||||
// entire receiver. All integrations in a receiver should have the same provenance, but we don't want to rely on
|
||||
// this assumption in case the first provenance is None and a later one is not. To this end, we return the first
|
||||
// non-zero provenance we find.
|
||||
for _, contactPoint := range r.GrafanaManagedReceivers {
|
||||
if p, exists := storedProvenances[contactPoint.UID]; exists && p != models.ProvenanceNone {
|
||||
return p, nil
|
||||
// redactor returns a models.RedactFn that redacts a secure setting.
|
||||
func (rs *ReceiverService) redactor() models.RedactFn {
|
||||
return func(value string) string {
|
||||
return definitions.RedactedValue
|
||||
}
|
||||
}
|
||||
|
||||
// encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result.
|
||||
func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn {
|
||||
return func(payload string) (string, error) {
|
||||
s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return models.ProvenanceNone, nil
|
||||
return base64.StdEncoding.EncodeToString(s), nil
|
||||
}
|
||||
}
|
||||
|
||||
// checkOptimisticConcurrency checks if the existing receiver's version matches the desired version.
|
||||
func (rs *ReceiverService) checkOptimisticConcurrency(receiver *models.Receiver, desiredVersion string) error {
|
||||
if receiver.Version != desiredVersion {
|
||||
return makeErrReceiverVersionConflict(receiver, desiredVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// limitOffset returns a subslice of items with the given offset and limit. Returns the same underlying array, not a copy.
|
||||
func limitOffset[T any](items []T, offset, limit int) []T {
|
||||
if limit == 0 && offset == 0 {
|
||||
return items
|
||||
}
|
||||
if offset >= len(items) {
|
||||
return nil
|
||||
}
|
||||
if offset+limit >= len(items) {
|
||||
return items[offset:]
|
||||
}
|
||||
if limit == 0 {
|
||||
limit = len(items) - offset
|
||||
}
|
||||
return items[offset : offset+limit]
|
||||
}
|
||||
|
||||
func makeReceiverInUseErr(usedByRoutes bool, rules []models.AlertRuleKey) error {
|
||||
@ -349,16 +534,34 @@ func makeReceiverInUseErr(usedByRoutes bool, rules []models.AlertRuleKey) error
|
||||
for _, key := range rules {
|
||||
uids = append(uids, key.UID)
|
||||
}
|
||||
data := make(map[string]any, 2)
|
||||
|
||||
var usedBy []string
|
||||
data := make(map[string]any)
|
||||
if len(uids) > 0 {
|
||||
usedBy = append(usedBy, fmt.Sprintf("%d rule(s)", len(uids)))
|
||||
data["UsedByRules"] = uids
|
||||
}
|
||||
if usedByRoutes {
|
||||
usedBy = append(usedBy, "one or more routes")
|
||||
data["UsedByRoutes"] = true
|
||||
}
|
||||
if len(usedBy) > 0 {
|
||||
data["UsedBy"] = strings.Join(usedBy, ", ")
|
||||
}
|
||||
|
||||
return ErrReceiverInUse.Build(errutil.TemplateData{
|
||||
Public: data,
|
||||
Error: nil,
|
||||
})
|
||||
}
|
||||
|
||||
func makeErrReceiverVersionConflict(current *models.Receiver, desiredVersion string) error {
|
||||
data := errutil.TemplateData{
|
||||
Public: map[string]interface{}{
|
||||
"Version": desiredVersion,
|
||||
"CurrentVersion": current.Version,
|
||||
"Name": current.Name,
|
||||
},
|
||||
}
|
||||
return ErrReceiverVersionConflict.Build(data)
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,28 @@ func (f *fakeConfigStore) ListNotificationSettings(ctx context.Context, q models
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (f *fakeConfigStore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error) {
|
||||
if oldReceiver == newReceiver {
|
||||
return 0, nil
|
||||
}
|
||||
settings, ok := f.notificationSettings[orgID]
|
||||
if !ok {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var updated int
|
||||
for _, notificationSettings := range settings {
|
||||
for i, setting := range notificationSettings {
|
||||
if setting.Receiver == oldReceiver {
|
||||
updated++
|
||||
notificationSettings[i].Receiver = newReceiver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// Saves the image or returns an error.
|
||||
func (f *fakeConfigStore) SaveImage(ctx context.Context, img *models.Image) error {
|
||||
return alertingImages.ErrImageNotFound
|
||||
|
@ -46,28 +46,22 @@ func PostableGrafanaReceiverToEmbeddedContactPoint(contactPoint *definitions.Pos
|
||||
return embeddedContactPoint, nil
|
||||
}
|
||||
|
||||
func GettableGrafanaReceiverToEmbeddedContactPoint(r *definitions.GettableGrafanaReceiver) (definitions.EmbeddedContactPoint, error) {
|
||||
func GrafanaIntegrationConfigToEmbeddedContactPoint(r *models.Integration, provenance models.Provenance) definitions.EmbeddedContactPoint {
|
||||
settingJson := simplejson.New()
|
||||
if r.Settings != nil {
|
||||
var err error
|
||||
settingJson, err = simplejson.NewJson(r.Settings)
|
||||
if err != nil {
|
||||
return definitions.EmbeddedContactPoint{}, err
|
||||
}
|
||||
settingJson = simplejson.NewFromAny(r.Settings)
|
||||
}
|
||||
|
||||
for k := range r.SecureFields {
|
||||
if settingJson.Get(k).MustString() == "" {
|
||||
settingJson.Set(k, definitions.RedactedValue)
|
||||
}
|
||||
}
|
||||
// We explicitly do not copy the secure settings to the settings field. This is because the provisioning API
|
||||
// never returns decrypted or encrypted values, only redacted values. Redacted values should already exist in the
|
||||
// settings field.
|
||||
|
||||
return definitions.EmbeddedContactPoint{
|
||||
UID: r.UID,
|
||||
Name: r.Name,
|
||||
Type: r.Type,
|
||||
Type: r.Config.Type,
|
||||
DisableResolveMessage: r.DisableResolveMessage,
|
||||
Settings: settingJson,
|
||||
Provenance: string(r.Provenance),
|
||||
}, nil
|
||||
Provenance: string(provenance),
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +40,7 @@ type ContactPointService struct {
|
||||
}
|
||||
|
||||
type receiverService interface {
|
||||
GetReceivers(ctx context.Context, query models.GetReceiversQuery, user identity.Requester) ([]apimodels.GettableApiReceiver, error)
|
||||
GetReceivers(ctx context.Context, query models.GetReceiversQuery, user identity.Requester) ([]*models.Receiver, error)
|
||||
}
|
||||
|
||||
func NewContactPointService(store alertmanagerConfigStore, encryptionService secrets.Service,
|
||||
@ -79,23 +79,15 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP
|
||||
if err != nil {
|
||||
return nil, convertRecSvcErr(err)
|
||||
}
|
||||
grafanaReceivers := []*apimodels.GettableGrafanaReceiver{}
|
||||
if q.Name != "" && len(res) > 0 {
|
||||
grafanaReceivers = res[0].GettableGrafanaReceivers.GrafanaManagedReceivers // we only expect one receiver group
|
||||
} else {
|
||||
for _, r := range res {
|
||||
grafanaReceivers = append(grafanaReceivers, r.GettableGrafanaReceivers.GrafanaManagedReceivers...)
|
||||
}
|
||||
res = []*models.Receiver{res[0]} // we only expect one receiver group
|
||||
}
|
||||
|
||||
contactPoints := make([]apimodels.EmbeddedContactPoint, len(grafanaReceivers))
|
||||
for i, gr := range grafanaReceivers {
|
||||
contactPoint, err := GettableGrafanaReceiverToEmbeddedContactPoint(gr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
contactPoints := make([]apimodels.EmbeddedContactPoint, 0, len(res))
|
||||
for _, recv := range res {
|
||||
for _, gr := range recv.Integrations {
|
||||
contactPoints = append(contactPoints, GrafanaIntegrationConfigToEmbeddedContactPoint(gr, recv.Provenance))
|
||||
}
|
||||
|
||||
contactPoints[i] = contactPoint
|
||||
}
|
||||
|
||||
sort.SliceStable(contactPoints, func(i, j int) bool {
|
||||
@ -428,7 +420,7 @@ groupLoop:
|
||||
// If we're renaming, we'll need to fix up the macro receiver group for consistency.
|
||||
// Firstly, if we're the only receiver in the group, simply rename the group to match. Done!
|
||||
if len(receiverGroup.GrafanaManagedReceivers) == 1 {
|
||||
replaceReferences(receiverGroup.Name, target.Name, cfg.AlertmanagerConfig.Route)
|
||||
legacy_storage.RenameReceiverInRoute(receiverGroup.Name, target.Name, cfg.AlertmanagerConfig.Route)
|
||||
receiverGroup.Name = target.Name
|
||||
receiverGroup.GrafanaManagedReceivers[i] = target
|
||||
renamedReceiver = receiverGroup.Name
|
||||
@ -476,38 +468,12 @@ groupLoop:
|
||||
return configModified, renamedReceiver
|
||||
}
|
||||
|
||||
func replaceReferences(oldName, newName string, routes ...*apimodels.Route) {
|
||||
if len(routes) == 0 {
|
||||
return
|
||||
}
|
||||
for _, route := range routes {
|
||||
if route.Receiver == oldName {
|
||||
route.Receiver = newName
|
||||
}
|
||||
replaceReferences(oldName, newName, route.Routes...)
|
||||
}
|
||||
}
|
||||
|
||||
func ValidateContactPoint(ctx context.Context, e apimodels.EmbeddedContactPoint, decryptFunc alertingNotify.GetDecryptedValueFn) error {
|
||||
if e.Type == "" {
|
||||
return fmt.Errorf("type should not be an empty string")
|
||||
}
|
||||
if e.Settings == nil {
|
||||
return fmt.Errorf("settings should not be empty")
|
||||
}
|
||||
integration, err := EmbeddedContactPointToGrafanaIntegrationConfig(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = alertingNotify.BuildReceiverConfiguration(ctx, &alertingNotify.APIReceiver{
|
||||
GrafanaIntegrations: alertingNotify.GrafanaIntegrations{
|
||||
Integrations: []*alertingNotify.GrafanaIntegrationConfig{&integration},
|
||||
},
|
||||
}, decryptFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return models.ValidateIntegration(ctx, integration, decryptFunc)
|
||||
}
|
||||
|
||||
// RemoveSecretsForContactPoint removes all secrets from the contact point's settings and returns them as a map. Returns error if contact point type is not known.
|
||||
|
@ -392,6 +392,7 @@ func createContactPointServiceSutWithConfigStore(t *testing.T, secretService sec
|
||||
ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), true),
|
||||
legacy_storage.NewAlertmanagerConfigStore(configStore),
|
||||
provisioningStore,
|
||||
notifier.NewFakeConfigStore(t, nil),
|
||||
secretService,
|
||||
xact,
|
||||
log.NewNopLogger(),
|
||||
|
@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
)
|
||||
|
||||
@ -15,8 +14,8 @@ type ReceiverServiceMethodCall struct {
|
||||
|
||||
type FakeReceiverService struct {
|
||||
MethodCalls []ReceiverServiceMethodCall
|
||||
GetReceiverFn func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error)
|
||||
ListReceiversFn func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error)
|
||||
GetReceiverFn func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error)
|
||||
ListReceiversFn func(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error)
|
||||
}
|
||||
|
||||
func NewFakeReceiverService() *FakeReceiverService {
|
||||
@ -26,12 +25,12 @@ func NewFakeReceiverService() *FakeReceiverService {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
func (f *FakeReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
|
||||
f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceiver", Args: []interface{}{ctx, q}})
|
||||
return f.GetReceiverFn(ctx, q, u)
|
||||
}
|
||||
|
||||
func (f *FakeReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
|
||||
func (f *FakeReceiverService) ListReceivers(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
|
||||
f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "ListReceivers", Args: []interface{}{ctx, q}})
|
||||
return f.ListReceiversFn(ctx, q, u)
|
||||
}
|
||||
@ -51,10 +50,10 @@ func (f *FakeReceiverService) Reset() {
|
||||
f.ListReceiversFn = defaultReceiversFn
|
||||
}
|
||||
|
||||
func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) {
|
||||
return definitions.GettableApiReceiver{}, nil
|
||||
}
|
||||
|
||||
func defaultReceiversFn(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) {
|
||||
func defaultReceiverFn(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (*models.Receiver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func defaultReceiversFn(ctx context.Context, q models.ListReceiversQuery, u identity.Requester) ([]*models.Receiver, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
@ -278,6 +278,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error
|
||||
alertingauthz.NewReceiverAccess[*ngmodels.Receiver](ps.ac, true),
|
||||
configStore,
|
||||
st,
|
||||
st,
|
||||
ps.secretService,
|
||||
ps.SQLStore,
|
||||
ps.log,
|
||||
|
@ -127,6 +127,8 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||
ualert.AddStateResolvedAtColumns(mg)
|
||||
|
||||
enableTraceQLStreaming(mg, oss.features != nil && oss.features.IsEnabledGlobally(featuremgmt.FlagTraceQLStreaming))
|
||||
|
||||
ualert.AddReceiverActionScopesMigration(mg)
|
||||
}
|
||||
|
||||
func addStarMigrations(mg *Migrator) {
|
||||
|
@ -0,0 +1,49 @@
|
||||
package ualert
|
||||
|
||||
import (
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
)
|
||||
|
||||
const (
|
||||
AlertingAddReceiverActionScopes = "Add scope to alert.notifications.receivers:read and alert.notifications.receivers.secrets:read"
|
||||
)
|
||||
|
||||
// AddReceiverActionScopesMigration is a migration that will add scopes to alert.notifications.receivers:read and
|
||||
// alert.notifications.receivers.secrets:read actions.
|
||||
// Originally, they were created without any scope, but treated as if all actions were globally scoped.
|
||||
// With the introduction of receiver FGAC, we need to scope these actions to UID so any existing roles should be updated
|
||||
// to explicitly have the global scope.
|
||||
func AddReceiverActionScopesMigration(mg *migrator.Migrator) {
|
||||
mg.AddMigration(AlertingAddReceiverActionScopes, &addReceiverActionScopesMigrator{})
|
||||
}
|
||||
|
||||
var _ migrator.CodeMigration = (*addReceiverActionScopesMigrator)(nil)
|
||||
|
||||
type addReceiverActionScopesMigrator struct {
|
||||
migrator.MigrationBase
|
||||
}
|
||||
|
||||
func (p addReceiverActionScopesMigrator) SQL(migrator.Dialect) string {
|
||||
return codeMigration
|
||||
}
|
||||
|
||||
func (p addReceiverActionScopesMigrator) Exec(sess *xorm.Session, migrator *migrator.Migrator) error {
|
||||
// Vendored.
|
||||
actionAlertingReceiversRead := "alert.notifications.receivers:read"
|
||||
actionAlertingReceiversReadSecrets := "alert.notifications.receivers.secrets:read"
|
||||
|
||||
_, err := sess.Exec("UPDATE permission SET `scope` = 'receivers:*', `kind` = 'receivers', `attribute` = '*', `identifier` = '*' WHERE action = ?", actionAlertingReceiversRead)
|
||||
if err != nil {
|
||||
migrator.Logger.Error("Failed to update permissions for action", "action", actionAlertingReceiversRead, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = sess.Exec("UPDATE permission SET `scope` = 'receivers:*', `kind` = 'receivers', `attribute` = '*', `identifier` = '*' WHERE action = ?", actionAlertingReceiversReadSecrets)
|
||||
if err != nil {
|
||||
migrator.Logger.Error("Failed to update permissions for action", "action", actionAlertingReceiversReadSecrets, "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
@ -0,0 +1,183 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/google/go-cmp/cmp/cmpopts"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestScopeMigration(t *testing.T) {
|
||||
x := setupTestDB(t)
|
||||
now := time.Now()
|
||||
|
||||
// Vendored.
|
||||
actionAlertingReceiversRead := "alert.notifications.receivers:read"
|
||||
actionAlertingReceiversReadSecrets := "alert.notifications.receivers.secrets:read"
|
||||
|
||||
type migrationTestCase struct {
|
||||
desc string
|
||||
permissionSeed []*accesscontrol.Permission
|
||||
wantPermissions []*accesscontrol.Permission
|
||||
}
|
||||
testCases := []migrationTestCase{
|
||||
{
|
||||
desc: "convert existing alert.notifications.receivers:read regardless of scope",
|
||||
permissionSeed: []*accesscontrol.Permission{
|
||||
{
|
||||
RoleID: 1,
|
||||
Action: actionAlertingReceiversRead,
|
||||
Scope: "",
|
||||
Kind: "",
|
||||
Attribute: "",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
{
|
||||
RoleID: 2,
|
||||
Action: actionAlertingReceiversRead,
|
||||
Scope: "Scope",
|
||||
Kind: "Kind",
|
||||
Attribute: "Attribute",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
wantPermissions: []*accesscontrol.Permission{
|
||||
{
|
||||
RoleID: 1,
|
||||
Action: actionAlertingReceiversRead,
|
||||
Scope: "receivers:*",
|
||||
Kind: "receivers",
|
||||
Attribute: "*",
|
||||
Identifier: "*",
|
||||
},
|
||||
{
|
||||
RoleID: 2,
|
||||
Action: actionAlertingReceiversRead,
|
||||
Scope: "receivers:*",
|
||||
Kind: "receivers",
|
||||
Attribute: "*",
|
||||
Identifier: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "convert existing alert.notifications.receivers:read regardless of scope",
|
||||
permissionSeed: []*accesscontrol.Permission{
|
||||
{
|
||||
RoleID: 1,
|
||||
Action: actionAlertingReceiversReadSecrets,
|
||||
Scope: "",
|
||||
Kind: "",
|
||||
Attribute: "",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
{
|
||||
RoleID: 2,
|
||||
Action: actionAlertingReceiversReadSecrets,
|
||||
Scope: "Scope",
|
||||
Kind: "Kind",
|
||||
Attribute: "Attribute",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
wantPermissions: []*accesscontrol.Permission{
|
||||
{
|
||||
RoleID: 1,
|
||||
Action: actionAlertingReceiversReadSecrets,
|
||||
Scope: "receivers:*",
|
||||
Kind: "receivers",
|
||||
Attribute: "*",
|
||||
Identifier: "*",
|
||||
},
|
||||
{
|
||||
RoleID: 2,
|
||||
Action: actionAlertingReceiversReadSecrets,
|
||||
Scope: "receivers:*",
|
||||
Kind: "receivers",
|
||||
Attribute: "*",
|
||||
Identifier: "*",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
desc: "empty perms",
|
||||
permissionSeed: []*accesscontrol.Permission{},
|
||||
wantPermissions: []*accesscontrol.Permission{},
|
||||
},
|
||||
{
|
||||
desc: "unrelated perms",
|
||||
permissionSeed: []*accesscontrol.Permission{
|
||||
{
|
||||
RoleID: 1,
|
||||
Action: "some.other.resource:read",
|
||||
Scope: "Scope",
|
||||
Kind: "Kind",
|
||||
Attribute: "Attribute",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
wantPermissions: []*accesscontrol.Permission{
|
||||
{
|
||||
RoleID: 1,
|
||||
Action: "some.other.resource:read",
|
||||
Scope: "Scope",
|
||||
Kind: "Kind",
|
||||
Attribute: "Attribute",
|
||||
Created: now,
|
||||
Updated: now,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.desc, func(t *testing.T) {
|
||||
// Remove migration and permissions
|
||||
_, errDeleteMig := x.Exec(`DELETE FROM migration_log WHERE migration_id = ?`, ualert.AlertingAddReceiverActionScopes)
|
||||
require.NoError(t, errDeleteMig)
|
||||
_, errDeletePerms := x.Exec(`DELETE FROM permission`)
|
||||
require.NoError(t, errDeletePerms)
|
||||
|
||||
// seed DB with permissions
|
||||
if len(tc.permissionSeed) != 0 {
|
||||
permissionsCount, err := x.Insert(tc.permissionSeed)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(tc.permissionSeed)), permissionsCount)
|
||||
}
|
||||
|
||||
// Run RBAC action name migration
|
||||
acmigrator := migrator.NewMigrator(x, &setting.Cfg{Logger: log.New("acmigration.test")})
|
||||
ualert.AddReceiverActionScopesMigration(acmigrator)
|
||||
|
||||
errRunningMig := acmigrator.Start(false, 0)
|
||||
require.NoError(t, errRunningMig)
|
||||
|
||||
// Check permissions
|
||||
resultingPermissions := []*accesscontrol.Permission{}
|
||||
err := x.Table("permission").Find(&resultingPermissions)
|
||||
require.NoError(t, err)
|
||||
|
||||
// verify got == want
|
||||
cOpt := []cmp.Option{
|
||||
cmpopts.SortSlices(func(a, b accesscontrol.Permission) bool { return a.RoleID < b.RoleID }),
|
||||
cmpopts.IgnoreFields(accesscontrol.Permission{}, "ID", "Created", "Updated"),
|
||||
}
|
||||
if !cmp.Equal(tc.wantPermissions, resultingPermissions, cOpt...) {
|
||||
t.Errorf("Unexpected permissions: %v", cmp.Diff(tc.wantPermissions, resultingPermissions, cOpt...))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
49
pkg/services/sqlstore/migrations/ualert/test/testing.go
Normal file
49
pkg/services/sqlstore/migrations/ualert/test/testing.go
Normal file
@ -0,0 +1,49 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/ini.v1"
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrations"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/sqlutil"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func setupTestDB(t *testing.T) *xorm.Engine {
|
||||
t.Helper()
|
||||
dbType := sqlutil.GetTestDBType()
|
||||
testDB, err := sqlutil.GetTestDB(dbType)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(testDB.Cleanup)
|
||||
|
||||
x, err := xorm.NewEngine(testDB.DriverName, testDB.ConnStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
if err := x.Close(); err != nil {
|
||||
fmt.Printf("failed to close xorm engine: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
err = migrator.NewDialect(x.DriverName()).CleanDB(x)
|
||||
require.NoError(t, err)
|
||||
|
||||
mg := migrator.NewMigrator(x, &setting.Cfg{
|
||||
Logger: log.New("acmigration.test"),
|
||||
Raw: ini.Empty(),
|
||||
})
|
||||
migrations := &migrations.OSSMigrations{}
|
||||
migrations.AddMigration(mg)
|
||||
|
||||
err = mg.Start(false, 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
return x
|
||||
}
|
Loading…
Reference in New Issue
Block a user