From 32f06c6d9ce3d1768eb5ec36e2695a7f86e4c461 Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Mon, 26 Aug 2024 10:47:53 -0400 Subject: [PATCH] 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 --- .../notifications/receiver/authorize.go | 85 +- .../notifications/receiver/conversions.go | 92 +- .../notifications/receiver/legacy_storage.go | 72 +- .../apis/alerting/notifications/register.go | 22 +- .../notifications/timeinterval/conversions.go | 4 +- .../timeinterval/legacy_storage.go | 6 +- pkg/services/accesscontrol/models.go | 3 + pkg/services/ngalert/accesscontrol.go | 14 + pkg/services/ngalert/accesscontrol/models.go | 54 +- .../ngalert/accesscontrol/receivers.go | 209 +++- pkg/services/ngalert/api/api_notifications.go | 19 +- .../ngalert/api/api_notifications_test.go | 78 +- .../ngalert/api/api_provisioning_test.go | 14 +- pkg/services/ngalert/api/authorization.go | 1 - pkg/services/ngalert/api/compat.go | 54 + pkg/services/ngalert/models/receivers.go | 436 ++++++- pkg/services/ngalert/models/receivers_test.go | 399 ++++++ pkg/services/ngalert/models/testing.go | 221 ++++ pkg/services/ngalert/ngalert.go | 2 + .../channels_config/available_channels.go | 13 +- pkg/services/ngalert/notifier/compat.go | 162 +-- .../ngalert/notifier/legacy_storage/compat.go | 46 + .../ngalert/notifier/legacy_storage/errors.go | 17 + .../notifier/legacy_storage/receivers.go | 121 +- pkg/services/ngalert/notifier/receiver_svc.go | 513 +++++--- .../ngalert/notifier/receiver_svc_test.go | 1080 ++++++++++++++++- pkg/services/ngalert/notifier/testing.go | 22 + pkg/services/ngalert/provisioning/compat.go | 22 +- .../ngalert/provisioning/contactpoints.go | 50 +- .../provisioning/contactpoints_test.go | 1 + pkg/services/ngalert/tests/fakes/receivers.go | 19 +- pkg/services/provisioning/provisioning.go | 1 + .../sqlstore/migrations/migrations.go | 2 + .../migrations/ualert/receiver_scope_mig.go | 49 + .../ualert/test/receiver_scope_mig_test.go | 183 +++ .../migrations/ualert/test/testing.go | 49 + 36 files changed, 3585 insertions(+), 550 deletions(-) create mode 100644 pkg/services/ngalert/models/receivers_test.go create mode 100644 pkg/services/sqlstore/migrations/ualert/receiver_scope_mig.go create mode 100644 pkg/services/sqlstore/migrations/ualert/test/receiver_scope_mig_test.go create mode 100644 pkg/services/sqlstore/migrations/ualert/test/testing.go diff --git a/pkg/registry/apis/alerting/notifications/receiver/authorize.go b/pkg/registry/apis/alerting/notifications/receiver/authorize.go index 972ca506dfd..baf4bffd371 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/authorize.go +++ b/pkg/registry/apis/alerting/notifications/receiver/authorize.go @@ -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 + return authorizer.DecisionAllow, "", nil } diff --git a/pkg/registry/apis/alerting/notifications/receiver/conversions.go b/pkg/registry/apis/alerting/notifications/receiver/conversions.go index 9bb8fd15920..bb628b47f03 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/conversions.go +++ b/pkg/registry/apis/alerting/notifications/receiver/conversions.go @@ -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, + Title: receiver.Name, } - provenance := definitions.Provenance(models.ProvenanceNone) - for _, integration := range receiver.GrafanaManagedReceivers { - if integration.Provenance != receiver.GrafanaManagedReceivers[0].Provenance { - return nil, fmt.Errorf("all integrations must have the same provenance") - } - provenance = integration.Provenance - unstruct := common.Unstructured{} - err := json.Unmarshal(integration.Settings, &unstruct) - if err != nil { - return nil, fmt.Errorf("integration '%s' of receiver '%s' has settings that cannot be parsed as JSON: %w", integration.Type, receiver.Name, err) - } + for _, integration := range receiver.Integrations { spec.Integrations = append(spec.Integrations, model.Integration{ 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{ - Name: receiver.Spec.Title, - }, - GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ - GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{}, - }, +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, + 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{ - Name: receiver.Spec.Title, - Type: integration.Type, - Settings: definitions.RawMessage(data), - SecureFields: integration.SecureFields, - Provenance: definitions.Provenance(models.ProvenanceNone), + grafanaIntegration := ngmodels.Integration{ + Name: receiver.Spec.Title, + 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 } diff --git a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go index f103d8e5017..a43e4f2f1af 100644 --- a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go +++ b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go @@ -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{ - OrgID: orgId, + 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{ - OrgID: info.OrgID, - //Decrypt: ctx.QueryBool("decrypt"), // TODO: Query params. + name, err := legacy_storage.UidToName(uid) + if err != nil { + return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid) + } + q := ngmodels.GetReceiverQuery{ + OrgID: info.OrgID, + 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) + return convertToK8sResource(info.OrgID, r, s.namespacer) } 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,8 +233,8 @@ 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 - return old, false, err // false - will be deleted async + 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 } func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { diff --git a/pkg/registry/apis/alerting/notifications/register.go b/pkg/registry/apis/alerting/notifications/register.go index 32c007b5a2a..aa588ff9a53 100644 --- a/pkg/registry/apis/alerting/notifications/register.go +++ b/pkg/registry/apis/alerting/notifications/register.go @@ -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" ) @@ -31,10 +33,11 @@ var _ builder.APIGroupBuilder = (*NotificationsAPIBuilder)(nil) // This is used just so wire has something unique to return type NotificationsAPIBuilder struct { - authz accesscontrol.AccessControl - ng *ngalert.AlertNG - namespacer request.NamespaceMapper - gv schema.GroupVersion + authz accesscontrol.AccessControl + receiverAuth receiver.AccessControlService + ng *ngalert.AlertNG + namespacer request.NamespaceMapper + gv schema.GroupVersion } func RegisterAPIService( @@ -47,10 +50,11 @@ func RegisterAPIService( return nil } builder := &NotificationsAPIBuilder{ - ng: ng, - namespacer: request.GetNamespaceMapper(cfg), - gv: notificationsModels.SchemeGroupVersion, - authz: ng.Api.AccessControl, + ng: ng, + 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 }) diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go b/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go index 7b0bc3ab703..64d08635b26 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/conversions.go @@ -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 diff --git a/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go b/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go index ebfeb1a8f5b..c586c0b1c1b 100644 --- a/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go +++ b/pkg/registry/apis/alerting/notifications/timeinterval/legacy_storage.go @@ -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,8 +195,8 @@ 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 - return old, false, err // false - will be deleted async + 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 } func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) { diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index e8a53e6bb71..94e39d71300 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -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" diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index 84b981fd2ab..a14f41e564f 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -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, + }, }), }, } diff --git a/pkg/services/ngalert/accesscontrol/models.go b/pkg/services/ngalert/accesscontrol/models.go index 3a5d048bfa6..1b0e1c81ec5 100644 --- a/pkg/services/ngalert/accesscontrol/models.go +++ b/pkg/services/ngalert/accesscontrol/models.go @@ -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)) } diff --git a/pkg/services/ngalert/accesscontrol/receivers.go b/pkg/services/ngalert/accesscontrol/receivers.go index 66338f9846d..8629cf55a09 100644 --- a/pkg/services/ngalert/accesscontrol/receivers.go +++ b/pkg/services/ngalert/accesscontrol/receivers.go @@ -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) } diff --git a/pkg/services/ngalert/api/api_notifications.go b/pkg/services/ngalert/api/api_notifications.go index 70accedad4e..c828895c1bf 100644 --- a/pkg/services/ngalert/api/api_notifications.go +++ b/pkg/services/ngalert/api/api_notifications.go @@ -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) } diff --git a/pkg/services/ngalert/api/api_notifications_test.go b/pkg/services/ngalert/api/api_notifications_test.go index 23eee4e38e1..c7f4bf542e4 100644 --- a/pkg/services/ngalert/api/api_notifications_test.go +++ b/pkg/services/ngalert/api/api_notifications_test.go @@ -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{ - Name: "receiver1", - }, - GettableGrafanaReceivers: definitions.GettableGrafanaReceivers{ - GrafanaManagedReceivers: []*definitions.GettableGrafanaReceiver{ - { - UID: "uid1", - Name: "receiver1", - Type: "slack", - }, + expected := &models.Receiver{ + Name: "receiver1", + Integrations: []*models.Integration{ + { + UID: "uid1", + Name: "receiver1", + 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{ - { - UID: "uid1", - Name: "receiver1", - Type: "slack", - }, + Name: "receiver1", + Integrations: []*models.Integration{ + { + UID: "uid1", + Name: "receiver1", + 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, diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index bfa3b9d70cf..8c6cf2ccc60 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -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: \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: \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" + } } ] }, diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index dd03da7ca4c..08b48544c65 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -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), diff --git a/pkg/services/ngalert/api/compat.go b/pkg/services/ngalert/api/compat.go index 0183ab65cf7..a0515c02b08 100644 --- a/pkg/services/ngalert/api/compat.go +++ b/pkg/services/ngalert/api/compat.go @@ -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 +} diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index ec6b11d1363..239eec4bc4f 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -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()) +} diff --git a/pkg/services/ngalert/models/receivers_test.go b/pkg/services/ngalert/models/receivers_test.go new file mode 100644 index 00000000000..8f99166e2aa --- /dev/null +++ b/pkg/services/ngalert/models/receivers_test.go @@ -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) + } + }) +} diff --git a/pkg/services/ngalert/models/testing.go b/pkg/services/ngalert/models/testing.go index f3281f83af8..32763c06891 100644 --- a/pkg/services/ngalert/models/testing.go +++ b/pkg/services/ngalert/models/testing.go @@ -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)) +} diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index e0b83552e7c..6bf152a96a0 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -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, diff --git a/pkg/services/ngalert/notifier/channels_config/available_channels.go b/pkg/services/ngalert/notifier/channels_config/available_channels.go index 2fafacd8716..cf46551cf96 100644 --- a/pkg/services/ngalert/notifier/channels_config/available_channels.go +++ b/pkg/services/ngalert/notifier/channels_config/available_channels.go @@ -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) +} diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go index 29db26528e6..8ba1aaae66c 100644 --- a/pkg/services/ngalert/notifier/compat.go +++ b/pkg/services/ngalert/notifier/compat.go @@ -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)) diff --git a/pkg/services/ngalert/notifier/legacy_storage/compat.go b/pkg/services/ngalert/notifier/legacy_storage/compat.go index ef77ad30888..38cef8552b7 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/compat.go +++ b/pkg/services/ngalert/notifier/legacy_storage/compat.go @@ -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 +} diff --git a/pkg/services/ngalert/notifier/legacy_storage/errors.go b/pkg/services/ngalert/notifier/legacy_storage/errors.go index e6c1487d48c..73cbe59ce0a 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/errors.go +++ b/pkg/services/ngalert/notifier/legacy_storage/errors.go @@ -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) +} diff --git a/pkg/services/ngalert/notifier/legacy_storage/receivers.go b/pkg/services/ngalert/notifier/legacy_storage/receivers.go index 01268a1e8ed..e54f2b3acd4 100644 --- a/pkg/services/ngalert/notifier/legacy_storage/receivers.go +++ b/pkg/services/ngalert/notifier/legacy_storage/receivers.go @@ -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 +} diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index 37542553251..edc07fa78ca 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -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,26 +20,49 @@ 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. type ReceiverService struct { - authz receiverAccessControlService - provisioningStore provisoningStore - cfgStore alertmanagerConfigStore - encryptionService secrets.Service - xact transactionManager - log log.Logger - validator validation.ProvenanceStatusTransitionValidator + authz receiverAccessControlService + provisioningStore provisoningStore + cfgStore alertmanagerConfigStore + ruleNotificationsStore alertRuleNotificationSettingsStore + encryptionService secretService + xact transactionManager + log log.Logger + 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,61 +84,60 @@ func NewReceiverService( authz receiverAccessControlService, cfgStore alertmanagerConfigStore, provisioningStore provisoningStore, - encryptionService secrets.Service, + ruleNotificationsStore alertRuleNotificationSettingsStore, + encryptionService secretService, xact transactionManager, log log.Logger, ) *ReceiverService { return &ReceiverService{ - authz: authz, - provisioningStore: provisioningStore, - cfgStore: cfgStore, - encryptionService: encryptionService, - xact: xact, - log: log, - validator: validation.ValidateProvenanceRelaxed, + authz: authz, + provisioningStore: provisioningStore, + cfgStore: cfgStore, + ruleNotificationsStore: ruleNotificationsStore, + encryptionService: encryptionService, + xact: xact, + log: log, + 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 + receivers, err := PostableApiReceiversToReceivers(postables, storedProvenances) + if err != nil { + return nil, err } - var output []definitions.GettableApiReceiver - for i := q.Offset; i < len(postables); i++ { - r := postables[i] + if !listAccess { + var err error + receivers, err = rs.authz.FilterRead(ctx, user, receivers...) + if err != nil { + return nil, err + } + } - // Remove settings. - for _, integration := range r.GrafanaManagedReceivers { + // Remove settings. + for _, r := range receivers { + for _, integration := range r.Integrations { integration.Settings = nil integration.SecureSettings = nil integration.DisableResolveMessage = false } - - decryptFn := rs.decryptOrRedact(ctx, false, r.Name, "") - res, err := PostableToGettableApiReceiver(r, storedProvenances, decryptFn) - if err != nil { - 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(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()) - if err != nil { - return "", err - } - - // 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 +// 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 } - return models.ProvenanceNone, 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 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) +} diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 38099b4355b..0592bdd0d9a 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -4,12 +4,13 @@ import ( "context" "encoding/json" "fmt" + "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/accesscontrol" @@ -20,11 +21,15 @@ import ( "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" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning/validation" "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" + fake_secrets "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/secrets/manager" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/util" ) func TestReceiverService_GetReceiver(t *testing.T) { @@ -43,15 +48,15 @@ func TestReceiverService_GetReceiver(t *testing.T) { Receiver, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser) require.NoError(t, err) require.Equal(t, "slack receiver", Receiver.Name) - require.Len(t, Receiver.GrafanaManagedReceivers, 1) - require.Equal(t, "UID2", Receiver.GrafanaManagedReceivers[0].UID) + require.Len(t, Receiver.Integrations, 1) + require.Equal(t, "UID2", Receiver.Integrations[0].UID) }) t.Run("service returns error when receiver does not exist", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) _, err := sut.GetReceiver(context.Background(), singleQ(1, "nonexistent"), redactedUser) - require.ErrorIs(t, err, ErrReceiverNotFound.Errorf("")) + require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) }) } @@ -103,7 +108,7 @@ func TestReceiverService_DecryptRedact(t *testing.T) { Permissions: map[int64]map[string][]string{ 1: { accesscontrol.ActionAlertingNotificationsRead: nil, - accesscontrol.ActionAlertingReceiversReadSecrets: nil, + accesscontrol.ActionAlertingReceiversReadSecrets: {ac.ScopeReceiversAll}, }, }, } @@ -124,13 +129,13 @@ func TestReceiverService_DecryptRedact(t *testing.T) { name: "service returns error when trying to decrypt without permission", decrypt: true, user: readUser, - err: "[alerting.unauthorized] user is not authorized to read decrypted receiver", + err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", }, { name: "service returns error if user is nil and decrypt is true", decrypt: true, user: nil, - err: "[alerting.unauthorized] user is not authorized to read decrypted receiver", + err: "[alerting.unauthorized] user is not authorized to read any decrypted receiver", }, { name: "service decrypts receivers with permission", @@ -143,7 +148,7 @@ func TestReceiverService_DecryptRedact(t *testing.T) { t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - var res definitions.GettableApiReceiver + var res *models.Receiver var err error if method == "single" { q := singleQ(1, "slack receiver") @@ -152,7 +157,7 @@ func TestReceiverService_DecryptRedact(t *testing.T) { } else { q := multiQ(1, "slack receiver") q.Decrypt = tc.decrypt - var multiRes []definitions.GettableApiReceiver + var multiRes []*models.Receiver multiRes, err = sut.GetReceivers(context.Background(), q, tc.user) if tc.err == "" { require.Len(t, multiRes, 1) @@ -167,15 +172,14 @@ func TestReceiverService_DecryptRedact(t *testing.T) { if tc.err == "" { require.Equal(t, "slack receiver", res.Name) - require.Len(t, res.GrafanaManagedReceivers, 1) - require.Equal(t, "UID2", res.GrafanaManagedReceivers[0].UID) + require.Len(t, res.Integrations, 1) + require.Equal(t, "UID2", res.Integrations[0].UID) - testedSettings, err := simplejson.NewJson([]byte(res.GrafanaManagedReceivers[0].Settings)) require.NoError(t, err) if tc.decrypt { - require.Equal(t, "secure url", testedSettings.Get("url").MustString()) + require.Equal(t, "secure url", res.Integrations[0].Settings["url"]) } else { - require.Equal(t, definitions.RedactedValue, testedSettings.Get("url").MustString()) + require.Equal(t, definitions.RedactedValue, res.Integrations[0].Settings["url"]) } } }) @@ -183,7 +187,1050 @@ func TestReceiverService_DecryptRedact(t *testing.T) { } } -func createReceiverServiceSut(t *testing.T, encryptSvc secrets.Service) *ReceiverService { +func TestReceiverService_Delete(t *testing.T) { + secretsService := fake_secrets.NewFakeSecretsService() + + writer := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack"))() + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email"))() + baseReceiver := models.ReceiverGen(models.ReceiverMuts.WithName("test receiver"), models.ReceiverMuts.WithIntegrations(slackIntegration))() + + for _, tc := range []struct { + name string + user identity.Requester + deleteUID string + callerProvenance definitions.Provenance + version string + storeSettings map[models.AlertRuleKey][]models.NotificationSettings + existing *models.Receiver + expectedErr error + }{ + { + name: "service deletes receiver", + user: writer, + deleteUID: baseReceiver.UID, + existing: util.Pointer(baseReceiver.Clone()), + }, + { + name: "service deletes receiver with multiple integrations", + user: writer, + deleteUID: baseReceiver.UID, + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations(slackIntegration, emailIntegration))), + }, + { + name: "service deletes receiver with provenance", + user: writer, + deleteUID: baseReceiver.UID, + callerProvenance: definitions.Provenance(models.ProvenanceAPI), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI), models.ReceiverMuts.WithIntegrations(slackIntegration, emailIntegration))), + }, + { + name: "non-existing receiver doesn't fail", + user: writer, + deleteUID: "non-existent", + }, + { + name: "delete receiver used by route fails", + user: writer, + deleteUID: legacy_storage.NameToUid("grafana-default-email"), + version: "1fd7897966a2adc5", // Correct version for grafana-default-email. + expectedErr: makeReceiverInUseErr(true, nil), + }, + { + name: "delete receiver used by rule fails", + user: writer, + deleteUID: baseReceiver.UID, + existing: util.Pointer(baseReceiver.Clone()), + storeSettings: map[models.AlertRuleKey][]models.NotificationSettings{ + models.AlertRuleKey{OrgID: 1, UID: "rule1"}: { + models.NotificationSettingsGen(models.NSMuts.WithReceiver(baseReceiver.Name))(), + }, + }, + expectedErr: makeReceiverInUseErr(false, []models.AlertRuleKey{{OrgID: 1, UID: "rule1"}}), + }, + { + name: "delete provisioning provenance fails when caller is ProvenanceNone", + user: writer, + deleteUID: baseReceiver.UID, + callerProvenance: definitions.Provenance(models.ProvenanceNone), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceFile))), + expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceFile, models.ProvenanceNone), + }, + { + name: "delete provisioning provenance fails when caller is a different type", // TODO: This should fail once we move from lenient to strict validation. + user: writer, + deleteUID: baseReceiver.UID, + callerProvenance: definitions.Provenance(models.ProvenanceFile), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI))), + //expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceAPI, models.ProvenanceFile), + }, + { + name: "delete receiver with optimistic version mismatch fails", + user: writer, + deleteUID: baseReceiver.UID, + existing: util.Pointer(baseReceiver.Clone()), + version: "wrong version", + expectedErr: ErrReceiverVersionConflict, + }, + } { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + store := sut.ruleNotificationsStore.(*fakeConfigStore) + store.notificationSettings = map[int64]map[models.AlertRuleKey][]models.NotificationSettings{ + 1: make(map[models.AlertRuleKey][]models.NotificationSettings), + } + + for key, settings := range tc.storeSettings { + store.notificationSettings[tc.user.GetOrgID()][key] = settings + } + + if tc.existing != nil { + created, err := sut.CreateReceiver(context.Background(), tc.existing, tc.user.GetOrgID(), tc.user) + require.NoError(t, err) + + if tc.version == "" { + tc.version = created.Version + } + } + + err := sut.DeleteReceiver(context.Background(), tc.deleteUID, tc.callerProvenance, tc.version, tc.user.GetOrgID(), tc.user) + if tc.expectedErr == nil { + require.NoError(t, err) + } else { + assert.ErrorIs(t, err, tc.expectedErr) + return + } + // Ensure receiver saved to store is correct. + name, err := legacy_storage.UidToName(tc.deleteUID) + require.NoError(t, err) + q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name} + _, err = sut.GetReceiver(context.Background(), q, writer) + assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound) + + provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) + require.NoError(t, err) + assert.Len(t, provenances, 0) + }) + } +} + +func TestReceiverService_Create(t *testing.T) { + secretsService := fake_secrets.NewFakeSecretsService() + + writer := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + decryptUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingReceiversReadSecrets: {ac.ScopeReceiversAll}, + }, + }} + + // Used to mark generated fields to replace during test runtime. + generated := func(n int) string { return fmt.Sprintf("[GENERATED]%d", n) } + isGenerated := func(s string) bool { return strings.HasPrefix(s, "[GENERATED]") } + + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack"))() + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email"))() + baseReceiver := models.ReceiverGen(models.ReceiverMuts.WithName("test receiver"), models.ReceiverMuts.WithIntegrations(slackIntegration))() + + for _, tc := range []struct { + name string + user identity.Requester + receiver models.Receiver + expectedCreate models.Receiver + expectedErr error + expectedProvenances map[string]models.Provenance + }{ + { + name: "service creates receiver", + user: writer, + receiver: baseReceiver.Clone(), + expectedCreate: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone}, + }, + { + name: "service creates receiver with multiple integrations", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations(slackIntegration, emailIntegration)), + expectedCreate: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations(slackIntegration, emailIntegration), models.ReceiverMuts.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone, emailIntegration.UID: models.ProvenanceNone}, + }, + { + name: "service creates receiver with provenance", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI), models.ReceiverMuts.WithIntegrations(slackIntegration, emailIntegration)), + expectedCreate: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI), models.ReceiverMuts.WithIntegrations(slackIntegration, emailIntegration), models.ReceiverMuts.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceAPI, emailIntegration.UID: models.ProvenanceAPI}, + }, + { + name: "existing receiver fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithName("grafana-default-email")), + expectedErr: legacy_storage.ErrReceiverExists, + }, + { + name: "create integration with empty UID generates a new UID", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, models.IntegrationMuts.WithUID("")), + models.CopyIntegrationWith(emailIntegration, models.IntegrationMuts.WithUID("")), + )), + expectedCreate: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, models.IntegrationMuts.WithUID(generated(0))), // Mark UIDs as generated so that test will insert generated UID. + models.CopyIntegrationWith(emailIntegration, models.IntegrationMuts.WithUID(generated(1))), + ), models.ReceiverMuts.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{generated(0): models.ProvenanceNone, generated(1): models.ProvenanceNone}, // Mark UIDs as generated so that test will insert generated UID. + }, + { + name: "create integration with invalid UID fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, models.IntegrationMuts.WithUID("///@#$%^&*(")), + )), + expectedErr: legacy_storage.ErrReceiverInvalid, + }, + { + name: "create integration with existing UID fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, models.IntegrationMuts.WithUID("UID1")), // UID of grafana-default-email. + )), + expectedErr: legacy_storage.ErrReceiverInvalid, + }, + { + name: "create with invalid integration fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithInvalidIntegration("slack")), + expectedErr: legacy_storage.ErrReceiverInvalid, + }, + } { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + created, err := sut.CreateReceiver(context.Background(), &tc.receiver, tc.user.GetOrgID(), tc.user) + if tc.expectedErr == nil { + require.NoError(t, err) + } else { + assert.ErrorIs(t, err, tc.expectedErr) + return + } + + // First verify generated UIDs. We can't compare set them directly in expected because they are generated, + // so we ensure that all empty UIDs in expectedUpdate are not empty in updated. + generatedUIDs := make(map[string]string) + for i, integration := range tc.expectedCreate.Integrations { + if isGenerated(integration.UID) { + // Check that the UID was, in fact, generated. + if created.Integrations[i].UID != "" { + generatedUIDs[integration.UID] = created.Integrations[i].UID + // This ensures the following assert.Equal will pass for this generated field. + integration.UID = created.Integrations[i].UID + } + } + } + if len(generatedUIDs) > 0 { + // Version was calculated without generated UIDs. + tc.expectedCreate.Version = tc.expectedCreate.Fingerprint() + + // Set UIDs in expected provenance. + for k, v := range tc.expectedProvenances { + if gen, ok := generatedUIDs[k]; ok { + tc.expectedProvenances[gen] = v + delete(tc.expectedProvenances, k) + } + } + } + + assert.Equal(t, tc.expectedCreate, *created) + + // Ensure receiver saved to store is correct. + q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} + stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + require.NoError(t, err) + decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) + decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption. + assert.Equal(t, decrypted, *stored) + + provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) + require.NoError(t, err) + assert.Equal(t, tc.expectedProvenances, provenances) + }) + } +} + +func TestReceiverService_Update(t *testing.T) { + secretsService := fake_secrets.NewFakeSecretsService() + + writer := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + decryptUser := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingReceiversReadSecrets: {ac.ScopeReceiversAll}, + }, + }} + + // Used to mark generated fields to replace during test runtime. + generated := func(n int) string { return fmt.Sprintf("[GENERATED]%d", n) } + isGenerated := func(s string) bool { return strings.HasPrefix(s, "[GENERATED]") } + + rm := models.ReceiverMuts + im := models.IntegrationMuts + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack"))() + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email"))() + baseReceiver := models.ReceiverGen(models.ReceiverMuts.WithName("test receiver"), models.ReceiverMuts.WithIntegrations(slackIntegration))() + + for _, tc := range []struct { + name string + user identity.Requester + receiver models.Receiver + version string + secureFields map[string][]string + existing *models.Receiver + expectedUpdate models.Receiver + expectedProvenances map[string]models.Provenance + expectedErr error + }{ + { + name: "copies existing secure fields", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, im.AddSetting("newField", "newValue"))), + ), + secureFields: map[string][]string{slackIntegration.UID: {"token"}}, + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, + im.AddSecureSetting("token", "ZXhpc3RpbmdUb2tlbg=="), // This will get copied. + im.AddSecureSetting("url", "ZXhpc3RpbmdVcmw="), // This won't get copied. + ), + ))), + expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, + im.AddSetting("newField", "newValue"), + im.AddSecureSetting("token", "ZXhpc3RpbmdUb2tlbg==")), + ), rm.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone}, + }, + { + name: "doesn't copy existing unsecure fields", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, im.AddSetting("newField", "newValue"))), + ), + secureFields: map[string][]string{slackIntegration.UID: {"somefield"}}, + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, + im.AddSetting("somefield", "somevalue"), // This won't get copied. + ), + ))), + expectedUpdate: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations( + models.CopyIntegrationWith(slackIntegration, + im.AddSetting("newField", "newValue")), + ), rm.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone}, + }, + { + name: "creates new provenance when integration is added", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(slackIntegration, emailIntegration), models.ReceiverMuts.WithProvenance(models.ProvenanceFile)), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(slackIntegration), models.ReceiverMuts.WithProvenance(models.ProvenanceFile))), + expectedUpdate: models.CopyReceiverWith(baseReceiver, + rm.WithIntegrations(slackIntegration, emailIntegration), + models.ReceiverMuts.WithProvenance(models.ProvenanceFile), + rm.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceFile, emailIntegration.UID: models.ProvenanceFile}, + }, + { + name: "deletes old provenance when integration is removed", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(slackIntegration), models.ReceiverMuts.WithProvenance(models.ProvenanceFile)), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(slackIntegration, emailIntegration), models.ReceiverMuts.WithProvenance(models.ProvenanceFile))), + expectedUpdate: models.CopyReceiverWith(baseReceiver, + rm.WithIntegrations(slackIntegration), + models.ReceiverMuts.WithProvenance(models.ProvenanceFile), + rm.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceFile}, + }, + { + name: "changing provenance from something to None fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceNone)), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceFile))), + expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceFile, models.ProvenanceNone), + }, + { + name: "changing provenance from one type to another fails", // TODO: This should fail once we move from lenient to strict validation. + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceAPI)), + existing: util.Pointer(models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithProvenance(models.ProvenanceFile))), + //expectedErr: validation.MakeErrProvenanceChangeNotAllowed(models.ProvenanceFile, models.ProvenanceAPI), + expectedUpdate: models.CopyReceiverWith(baseReceiver, + models.ReceiverMuts.WithProvenance(models.ProvenanceAPI), + rm.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceAPI}, + }, + { + name: "update receiver with optimistic version mismatch fails", + user: writer, + receiver: baseReceiver.Clone(), + version: "wrong version", + existing: util.Pointer(baseReceiver.Clone()), + expectedErr: ErrReceiverVersionConflict, + }, + { + name: "update receiver that doesn't exist fails", + user: writer, + receiver: baseReceiver.Clone(), + expectedErr: legacy_storage.ErrReceiverNotFound, + }, + { + name: "update that adds new integration generates a new UID", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(slackIntegration, models.CopyIntegrationWith(emailIntegration, im.WithUID("")))), + existing: util.Pointer(baseReceiver.Clone()), + expectedUpdate: models.CopyReceiverWith(baseReceiver, + rm.WithIntegrations(slackIntegration, models.CopyIntegrationWith(emailIntegration, im.WithUID(generated(0)))), // Mark UID as generated so that test will insert generated UID. + rm.Encrypted(models.Base64Enrypt)), + expectedProvenances: map[string]models.Provenance{slackIntegration.UID: models.ProvenanceNone, generated(0): models.ProvenanceNone}, // Mark UID as generated so that test will insert generated UID. + }, + { + name: "update with integration that has a UID that already exists fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, rm.WithIntegrations(slackIntegration, models.CopyIntegrationWith(emailIntegration, im.WithUID(slackIntegration.UID)))), + existing: util.Pointer(baseReceiver.Clone()), + expectedErr: legacy_storage.ErrReceiverInvalid, + }, + { + name: "update with invalid integration fails", + user: writer, + receiver: models.CopyReceiverWith(baseReceiver, models.ReceiverMuts.WithInvalidIntegration("slack")), + existing: util.Pointer(baseReceiver.Clone()), + expectedErr: legacy_storage.ErrReceiverInvalid, + }, + } { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + if tc.existing != nil { + created, err := sut.CreateReceiver(context.Background(), tc.existing, tc.user.GetOrgID(), tc.user) + require.NoError(t, err) + + if tc.version == "" { + tc.version = created.Version + } + } + + tc.receiver.Version = tc.version + updated, err := sut.UpdateReceiver(context.Background(), &tc.receiver, tc.secureFields, tc.user.GetOrgID(), tc.user) + if tc.expectedErr == nil { + require.NoError(t, err) + } else { + assert.ErrorIs(t, err, tc.expectedErr) + return + } + + // First verify generated UIDs. We can't compare set them directly in expected because they are generated, + // so we ensure that all empty UIDs in expectedUpdate are not empty in updated. + generatedUIDs := make(map[string]string) + for i, integration := range tc.expectedUpdate.Integrations { + if isGenerated(integration.UID) { + // Check that the UID was, in fact, generated. + if updated.Integrations[i].UID != "" { + generatedUIDs[integration.UID] = updated.Integrations[i].UID + // This ensures the following assert.Equal will pass for this generated field. + integration.UID = updated.Integrations[i].UID + } + } + } + if len(generatedUIDs) > 0 { + // Version was calculated without generated UIDs. + tc.expectedUpdate.Version = tc.expectedUpdate.Fingerprint() + + // Set UIDs in expected provenance. + for k, v := range tc.expectedProvenances { + if gen, ok := generatedUIDs[k]; ok { + tc.expectedProvenances[gen] = v + delete(tc.expectedProvenances, k) + } + } + } + + assert.Equal(t, tc.expectedUpdate, *updated) + + // Ensure receiver saved to store is correct. + q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true} + stored, err := sut.GetReceiver(context.Background(), q, decryptUser) + require.NoError(t, err) + decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt)) + decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption. + assert.Equal(t, decrypted, *stored) + + provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType()) + require.NoError(t, err) + assert.Equal(t, tc.expectedProvenances, provenances) + }) + } +} + +func TestReceiverService_UpdateReceiverName(t *testing.T) { + // This test is to ensure that the receiver name is updated in routes and notification settings when the name is changed. + writer := &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + + secretsService := fake_secrets.NewFakeSecretsService() + sut := createReceiverServiceSut(t, &secretsService) + + receiverName := "grafana-default-email" + newReceiverName := "new-name" + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName(receiverName), models.IntegrationMuts.WithValidConfig("slack"))() + baseReceiver := models.ReceiverGen(models.ReceiverMuts.WithName(receiverName), models.ReceiverMuts.WithIntegrations(slackIntegration))() + baseReceiver.Version = "1fd7897966a2adc5" // Correct version for grafana-default-email. + baseReceiver.Name = newReceiverName // Done here instead of in a mutator so we keep the same uid. + + store := sut.ruleNotificationsStore.(*fakeConfigStore) + ns := models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))() + store.notificationSettings = map[int64]map[models.AlertRuleKey][]models.NotificationSettings{ + 1: { + {OrgID: 1, UID: "rule1"}: {ns}, + }, + } + + _, err := sut.UpdateReceiver(context.Background(), &baseReceiver, nil, writer.GetOrgID(), writer) + require.NoError(t, err) + + // Ensure receiver name is updated in notification settings. + oldSettings, err := sut.ruleNotificationsStore.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{ + OrgID: writer.GetOrgID(), + ReceiverName: receiverName, + }) + require.NoError(t, err) + assert.Equal(t, 0, len(oldSettings)) + newSettings, err := sut.ruleNotificationsStore.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{ + OrgID: writer.GetOrgID(), + ReceiverName: baseReceiver.Name, + }) + require.NoError(t, err) + assert.Equal(t, 1, len(newSettings)) + assert.Equal(t, newReceiverName, newSettings[models.AlertRuleKey{OrgID: 1, UID: "rule1"}][0].Receiver) + + // Ensure receiver name is updated in routes. + revision, err := sut.cfgStore.Get(context.Background(), writer.GetOrgID()) + require.NoError(t, err) + + assert.Falsef(t, revision.ReceiverNameUsedByRoutes(receiverName), "old receiver name '%s' should not be used by routes", receiverName) + assert.Truef(t, revision.ReceiverNameUsedByRoutes(newReceiverName), "new receiver name '%s' should be used by routes", newReceiverName) +} + +func TestReceiverServiceAC_Read(t *testing.T) { + var orgId int64 = 1 + secretsService := fake_secrets.NewFakeSecretsService() + + admin := &user.SignedInUser{OrgID: orgId, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{ + orgId: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack")) + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email")) + recv1 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver1"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv2 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver2"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv3 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver3"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + allReceivers := func() []models.Receiver { + return []models.Receiver{recv1, recv2, recv3} + } + testCases := []struct { + name string + permissions map[string][]string + existing []models.Receiver + + visible []models.Receiver + }{ + { + name: "not authorized without permissions", + existing: allReceivers(), + visible: nil, + }, + { + name: "not authorized without receivers scope", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversRead: nil}, + existing: allReceivers(), + visible: nil, + }, + { + name: "global legacy permissions - read all", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsRead: nil}, + existing: allReceivers(), + visible: allReceivers(), + }, + { + name: "global receivers permissions - read all", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversRead: {ac.ScopeReceiversAll}}, + existing: allReceivers(), + visible: allReceivers(), + }, + { + name: "single receivers permissions - read some", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversRead: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }}, + existing: allReceivers(), + visible: []models.Receiver{recv1, recv3}, + }, + { + name: "global receivers secret permissions - read all", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversReadSecrets: {ac.ScopeReceiversAll}}, + existing: allReceivers(), + visible: allReceivers(), + }, + { + name: "single receivers secret permissions - read some", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversReadSecrets: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }}, + existing: allReceivers(), + visible: []models.Receiver{recv1, recv3}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + for _, recv := range tc.existing { + _, err := sut.CreateReceiver(context.Background(), &recv, orgId, admin) + require.NoError(t, err) + } + + usr := &user.SignedInUser{OrgID: orgId, Permissions: map[int64]map[string][]string{ + orgId: tc.permissions, + }} + + isVisible := func(uid string) bool { + for _, recv := range tc.visible { + if recv.UID == uid { + return true + } + } + return false + } + for _, recv := range allReceivers() { + response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr) + if isVisible(recv.UID) { + require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name) + assert.NotNil(t, response) + } else { + assert.ErrorIsf(t, err, ac.ErrAuthorizationBase, "receiver '%s' should not be visible, but is", recv.Name) + } + } + }) + } +} + +func TestReceiverServiceAC_Create(t *testing.T) { + var orgId int64 = 1 + secretsService := fake_secrets.NewFakeSecretsService() + + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack")) + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email")) + recv1 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver1"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv2 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver2"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv3 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver3"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + allReceivers := func() []models.Receiver { + return []models.Receiver{recv1, recv2, recv3} + } + testCases := []struct { + name string + permissions map[string][]string + + hasAccess []models.Receiver + }{ + { + name: "not authorized without permissions", + hasAccess: nil, + }, + { + name: "global legacy permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil}, + hasAccess: nil, + }, + { + name: "receivers permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversCreate: nil}, + hasAccess: nil, + }, + { + name: "global legacy permissions - create all", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil, accesscontrol.ActionAlertingNotificationsRead: nil}, + hasAccess: allReceivers(), + }, + { + name: "receivers permissions - create all", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversCreate: nil, accesscontrol.ActionAlertingReceiversRead: nil}, + hasAccess: allReceivers(), + }, + { + name: "receivers mixed global read permissions - create all", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversCreate: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + hasAccess: allReceivers(), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + usr := &user.SignedInUser{OrgID: orgId, Permissions: map[int64]map[string][]string{ + orgId: tc.permissions, + }} + + hasAccess := func(uid string) bool { + for _, recv := range tc.hasAccess { + if recv.UID == uid { + return true + } + } + return false + } + for _, recv := range allReceivers() { + response, err := sut.CreateReceiver(context.Background(), &recv, orgId, usr) + if hasAccess(recv.UID) { + require.NoErrorf(t, err, "should have access to receiver '%s', but doesn't", recv.Name) + assert.NotNil(t, response) + } else { + assert.ErrorIsf(t, err, ac.ErrAuthorizationBase, "should not have access to receiver '%s', but does", recv.Name) + } + } + }) + } +} + +func TestReceiverServiceAC_Update(t *testing.T) { + var orgId int64 = 1 + secretsService := fake_secrets.NewFakeSecretsService() + + admin := &user.SignedInUser{OrgID: orgId, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{ + orgId: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack")) + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email")) + recv1 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver1"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv2 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver2"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv3 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver3"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + allReceivers := func() []models.Receiver { + return []models.Receiver{recv1, recv2, recv3} + } + testCases := []struct { + name string + permissions map[string][]string + existing []models.Receiver + + hasAccess []models.Receiver + }{ + { + name: "not authorized without permissions", + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "not authorized without receivers scope", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversUpdate: nil}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "global legacy permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "global receivers permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversUpdate: {ac.ScopeReceiversAll}}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "single receivers permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversUpdate: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "global legacy permissions - update all", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil, accesscontrol.ActionAlertingNotificationsRead: nil}, + existing: allReceivers(), + hasAccess: allReceivers(), + }, + { + name: "global receivers permissions - update all", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversUpdate: {ac.ScopeReceiversAll}, accesscontrol.ActionAlertingReceiversRead: {ac.ScopeReceiversAll}}, + existing: allReceivers(), + hasAccess: allReceivers(), + }, + { + name: "single receivers permissions - update some", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversUpdate: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + accesscontrol.ActionAlertingReceiversRead: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + }, + existing: allReceivers(), + hasAccess: []models.Receiver{recv1, recv3}, + }, + { + name: "single receivers mixed read permissions - update some", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversUpdate: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + accesscontrol.ActionAlertingReceiversRead: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv2.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + }, + existing: allReceivers(), + hasAccess: []models.Receiver{recv3}, + }, + { + name: "single receivers mixed global read permissions - update some", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversUpdate: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + existing: allReceivers(), + hasAccess: []models.Receiver{recv1, recv3}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + versions := map[string]string{} + for _, recv := range tc.existing { + created, err := sut.CreateReceiver(context.Background(), &recv, orgId, admin) + require.NoError(t, err) + versions[recv.UID] = created.Version + } + + usr := &user.SignedInUser{OrgID: orgId, Permissions: map[int64]map[string][]string{ + orgId: tc.permissions, + }} + + hasAccess := func(uid string) bool { + for _, recv := range tc.hasAccess { + if recv.UID == uid { + return true + } + } + return false + } + for _, recv := range allReceivers() { + clone := recv.Clone() + clone.Version = versions[recv.UID] + response, err := sut.UpdateReceiver(context.Background(), &clone, nil, orgId, usr) + if hasAccess(clone.UID) { + require.NoErrorf(t, err, "should have access to receiver '%s', but doesn't", clone.Name) + assert.NotNil(t, response) + } else { + assert.ErrorIsf(t, err, ac.ErrAuthorizationBase, "should not have access to receiver '%s', but does", clone.Name) + } + } + }) + } +} + +func TestReceiverServiceAC_Delete(t *testing.T) { + var orgId int64 = 1 + secretsService := fake_secrets.NewFakeSecretsService() + + admin := &user.SignedInUser{OrgID: orgId, OrgRole: org.RoleAdmin, Permissions: map[int64]map[string][]string{ + orgId: { + accesscontrol.ActionAlertingNotificationsWrite: nil, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + }} + + slackIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("slack")) + emailIntegration := models.IntegrationGen(models.IntegrationMuts.WithName("test receiver"), models.IntegrationMuts.WithValidConfig("email")) + recv1 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver1"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv2 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver2"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + recv3 := models.ReceiverGen(models.ReceiverMuts.WithName("receiver3"), models.ReceiverMuts.WithIntegrations(slackIntegration(), emailIntegration()))() + allReceivers := func() []models.Receiver { + return []models.Receiver{recv1, recv2, recv3} + } + testCases := []struct { + name string + permissions map[string][]string + existing []models.Receiver + + hasAccess []models.Receiver + }{ + { + name: "not authorized without permissions", + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "not authorized without receivers scope", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversDelete: nil}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "global legacy permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "global receivers permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversDelete: {ac.ScopeReceiversAll}}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "single receivers permissions - not authorized without read", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversDelete: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }}, + existing: allReceivers(), + hasAccess: nil, + }, + { + name: "global legacy permissions - delete all", + permissions: map[string][]string{accesscontrol.ActionAlertingNotificationsWrite: nil, accesscontrol.ActionAlertingNotificationsRead: nil}, + existing: allReceivers(), + hasAccess: allReceivers(), + }, + { + name: "global receivers permissions - delete all", + permissions: map[string][]string{accesscontrol.ActionAlertingReceiversDelete: {ac.ScopeReceiversAll}, accesscontrol.ActionAlertingReceiversRead: {ac.ScopeReceiversAll}}, + existing: allReceivers(), + hasAccess: allReceivers(), + }, + { + name: "single receivers permissions - delete some", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversDelete: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + accesscontrol.ActionAlertingReceiversRead: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + }, + existing: allReceivers(), + hasAccess: []models.Receiver{recv1, recv3}, + }, + { + name: "single receivers mixed read permissions - delete some", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversDelete: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + accesscontrol.ActionAlertingReceiversRead: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv2.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + }, + existing: allReceivers(), + hasAccess: []models.Receiver{recv3}, + }, + { + name: "single receivers mixed global read permissions - delete some", + permissions: map[string][]string{ + accesscontrol.ActionAlertingReceiversDelete: { + ac.ScopeReceiversProvider.GetResourceScopeUID(recv1.UID), + ac.ScopeReceiversProvider.GetResourceScopeUID(recv3.UID), + }, + accesscontrol.ActionAlertingNotificationsRead: nil, + }, + existing: allReceivers(), + hasAccess: []models.Receiver{recv1, recv3}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + sut := createReceiverServiceSut(t, &secretsService) + + versions := map[string]string{} + for _, recv := range tc.existing { + created, err := sut.CreateReceiver(context.Background(), &recv, orgId, admin) + require.NoError(t, err) + versions[recv.UID] = created.Version + } + + usr := &user.SignedInUser{OrgID: orgId, Permissions: map[int64]map[string][]string{ + orgId: tc.permissions, + }} + + hasAccess := func(uid string) bool { + for _, recv := range tc.hasAccess { + if recv.UID == uid { + return true + } + } + return false + } + for _, recv := range allReceivers() { + err := sut.DeleteReceiver(context.Background(), recv.UID, definitions.Provenance(models.ProvenanceNone), versions[recv.UID], orgId, usr) + if hasAccess(recv.UID) { + require.NoErrorf(t, err, "should have access to receiver '%s', but doesn't", recv.Name) + } else { + assert.ErrorIsf(t, err, ac.ErrAuthorizationBase, "should not have access to receiver '%s', but does", recv.Name) + } + } + }) + } +} + +func createReceiverServiceSut(t *testing.T, encryptSvc secretService) *ReceiverService { cfg := createEncryptedConfig(t, encryptSvc) store := fakes.NewFakeAlertmanagerConfigStore(cfg) xact := newNopTransactionManager() @@ -193,13 +1240,14 @@ func createReceiverServiceSut(t *testing.T, encryptSvc secrets.Service) *Receive ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures(), zanzana.NewNoopClient()), false), legacy_storage.NewAlertmanagerConfigStore(store), provisioningStore, + NewFakeConfigStore(t, nil), encryptSvc, xact, log.NewNopLogger(), ) } -func createEncryptedConfig(t *testing.T, secretService secrets.Service) string { +func createEncryptedConfig(t *testing.T, secretService secretService) string { c := &definitions.PostableUserConfig{} err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c) require.NoError(t, err) diff --git a/pkg/services/ngalert/notifier/testing.go b/pkg/services/ngalert/notifier/testing.go index 3b1a6ea965a..aac14ef988b 100644 --- a/pkg/services/ngalert/notifier/testing.go +++ b/pkg/services/ngalert/notifier/testing.go @@ -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 diff --git a/pkg/services/ngalert/provisioning/compat.go b/pkg/services/ngalert/provisioning/compat.go index 1bfeffa11e4..714452ddcc6 100644 --- a/pkg/services/ngalert/provisioning/compat.go +++ b/pkg/services/ngalert/provisioning/compat.go @@ -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), + } } diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go index ff7700c4a0a..ba9026f5acc 100644 --- a/pkg/services/ngalert/provisioning/contactpoints.go +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -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. diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index a502f9c8798..4f71bddaf11 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -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(), diff --git a/pkg/services/ngalert/tests/fakes/receivers.go b/pkg/services/ngalert/tests/fakes/receivers.go index 6f67b8c4ffd..4be1e44c892 100644 --- a/pkg/services/ngalert/tests/fakes/receivers.go +++ b/pkg/services/ngalert/tests/fakes/receivers.go @@ -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 } diff --git a/pkg/services/provisioning/provisioning.go b/pkg/services/provisioning/provisioning.go index 55c43d7e89c..1fa19b0ec81 100644 --- a/pkg/services/provisioning/provisioning.go +++ b/pkg/services/provisioning/provisioning.go @@ -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, diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index a3188351898..fb77908f9b0 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -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) { diff --git a/pkg/services/sqlstore/migrations/ualert/receiver_scope_mig.go b/pkg/services/sqlstore/migrations/ualert/receiver_scope_mig.go new file mode 100644 index 00000000000..b0f92e8a13f --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/receiver_scope_mig.go @@ -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 +} diff --git a/pkg/services/sqlstore/migrations/ualert/test/receiver_scope_mig_test.go b/pkg/services/sqlstore/migrations/ualert/test/receiver_scope_mig_test.go new file mode 100644 index 00000000000..bcb02dda7d5 --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/test/receiver_scope_mig_test.go @@ -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...)) + } + }) + } +} diff --git a/pkg/services/sqlstore/migrations/ualert/test/testing.go b/pkg/services/sqlstore/migrations/ualert/test/testing.go new file mode 100644 index 00000000000..0e5d82537ac --- /dev/null +++ b/pkg/services/sqlstore/migrations/ualert/test/testing.go @@ -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 +}