Alerting: Migrate notifications API to app-platform SDK application (#104424)

* introduce alerting notification app
* move code as is and remove from old registry
* update api server registration
* update make file and remove unnecessary args, copy some useful make commands from dashboards
* update codeowners

* move constants inside module and remove dependency from grafana
* add support for selectors to the app builder
This commit is contained in:
Yuri Tseretyan
2025-04-30 10:23:56 -04:00
committed by GitHub
parent 454e260207
commit 85344e30c0
50 changed files with 449 additions and 413 deletions
@@ -0,0 +1,82 @@
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/ngalert/accesscontrol"
)
// 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
}
user, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
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":
return authorizer.DecisionAllow, "", nil // Always allow listing, receivers are filtered downstream.
case "create":
if err := ac.AuthorizeCreate(ctx, user); err != nil {
return deny(err)
}
case "patch":
fallthrough
case "update":
if uid == "" {
return deny(err)
}
if err := ac.AuthorizeUpdateByUID(ctx, user, uid); err != nil {
return deny(err)
}
case "delete":
if uid == "" {
return deny(err)
}
if err := ac.AuthorizeDeleteByUID(ctx, user, uid); err != nil {
return deny(err)
}
default:
return authorizer.DecisionNoOpinion, "", nil
}
return authorizer.DecisionAllow, "", nil
}
@@ -0,0 +1,158 @@
package receiver
import (
"fmt"
"maps"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/types"
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/receiver/v0alpha1"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
gapiutil "github.com/grafana/grafana/pkg/services/apiserver/utils"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
)
func convertToK8sResources(
orgID int64,
receivers []*ngmodels.Receiver,
accesses map[string]ngmodels.ReceiverPermissionSet,
metadatas map[string]ngmodels.ReceiverMetadata,
namespacer request.NamespaceMapper,
selector fields.Selector,
) (*model.ReceiverList, error) {
result := &model.ReceiverList{
Items: make([]model.Receiver, 0, len(receivers)),
}
for _, receiver := range receivers {
var access *ngmodels.ReceiverPermissionSet
if accesses != nil {
if a, ok := accesses[receiver.GetUID()]; ok {
access = &a
}
}
var metadata *ngmodels.ReceiverMetadata
if metadatas != nil {
if m, ok := metadatas[receiver.GetUID()]; ok {
metadata = &m
}
}
k8sResource, err := convertToK8sResource(orgID, receiver, access, metadata, namespacer)
if err != nil {
return nil, err
}
if selector != nil && !selector.Empty() && !selector.Matches(model.SelectableFields(k8sResource)) {
continue
}
result.Items = append(result.Items, *k8sResource)
}
return result, nil
}
func convertToK8sResource(
orgID int64,
receiver *ngmodels.Receiver,
access *ngmodels.ReceiverPermissionSet,
metadata *ngmodels.ReceiverMetadata,
namespacer request.NamespaceMapper,
) (*model.Receiver, error) {
spec := model.Spec{
Title: receiver.Name,
Integrations: make([]model.Integration, 0, len(receiver.Integrations)),
}
for _, integration := range receiver.Integrations {
spec.Integrations = append(spec.Integrations, model.Integration{
Uid: &integration.UID,
Type: integration.Config.Type,
DisableResolveMessage: &integration.DisableResolveMessage,
Settings: maps.Clone(integration.Settings),
SecureFields: integration.SecureFields(),
})
}
r := &model.Receiver{
ObjectMeta: metav1.ObjectMeta{
UID: types.UID(receiver.GetUID()), // This is needed to make PATCH work
Name: receiver.GetUID(),
Namespace: namespacer(orgID),
ResourceVersion: receiver.Version,
},
Spec: spec,
}
r.SetProvenanceStatus(string(receiver.Provenance))
if access != nil {
for _, action := range ngmodels.ReceiverPermissions() {
mappedAction, ok := permissionMapper[action]
if !ok {
return nil, fmt.Errorf("unknown action %v", action)
}
if can, _ := access.Has(action); can {
r.SetAccessControl(mappedAction)
}
}
}
if metadata != nil {
rules := make([]string, 0, len(metadata.InUseByRules))
for _, rule := range metadata.InUseByRules {
rules = append(rules, rule.UID)
}
r.SetInUse(metadata.InUseByRoutes, rules)
}
r.UID = gapiutil.CalculateClusterWideUID(r)
return r, nil
}
var permissionMapper = map[ngmodels.ReceiverPermission]string{
ngmodels.ReceiverPermissionReadSecret: "canReadSecrets",
ngmodels.ReceiverPermissionAdmin: "canAdmin",
ngmodels.ReceiverPermissionWrite: "canWrite",
ngmodels.ReceiverPermissionDelete: "canDelete",
}
func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[string][]string, error) {
domain := &ngmodels.Receiver{
UID: receiver.Name,
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 {
config, err := ngmodels.IntegrationConfigFromType(integration.Type)
if err != nil {
return nil, nil, err
}
grafanaIntegration := ngmodels.Integration{
Name: receiver.Spec.Title,
Config: config,
Settings: maps.Clone(integration.Settings),
SecureSettings: make(map[string]string),
}
if integration.Uid != nil {
grafanaIntegration.UID = *integration.Uid
}
if integration.DisableResolveMessage != nil {
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
}
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 domain, storedSecureFields, nil
}
@@ -0,0 +1,282 @@
package receiver
import (
"context"
"errors"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/receiver/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
)
var (
_ grafanarest.Storage = (*legacyStorage)(nil)
)
type ReceiverService interface {
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 MetadataService interface {
AccessControlMetadata(ctx context.Context, user identity.Requester, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverPermissionSet, error)
InUseMetadata(ctx context.Context, orgID int64, receivers ...*ngmodels.Receiver) (map[string]ngmodels.ReceiverMetadata, error)
}
type legacyStorage struct {
service ReceiverService
namespacer request.NamespaceMapper
tableConverter rest.TableConvertor
metadata MetadataService
}
func (s *legacyStorage) New() runtime.Object {
return ResourceInfo.NewFunc()
}
func (s *legacyStorage) Destroy() {}
func (s *legacyStorage) NamespaceScoped() bool {
return true // namespace == org
}
func (s *legacyStorage) GetSingularName() string {
return ResourceInfo.GetSingularName()
}
func (s *legacyStorage) NewList() runtime.Object {
return ResourceInfo.NewListFunc()
}
func (s *legacyStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) {
return s.tableConverter.ConvertToTable(ctx, object, tableOptions)
}
func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOptions) (runtime.Object, error) {
orgId, err := request.OrgIDForList(ctx)
if err != nil {
return nil, err
}
q := ngmodels.GetReceiversQuery{
OrgID: orgId,
Decrypt: false,
//Names: ctx.QueryStrings("names"), // TODO: Query params.
//Limit: ctx.QueryInt("limit"),
//Offset: ctx.QueryInt("offset"),
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
res, err := s.service.GetReceivers(ctx, q, user)
if err != nil {
// This API should not be returning a forbidden error when the user does not have access to any resources.
// This can be true for a contact point creator role, for example.
// This should eventually be changed downstream in the auth logic but provisioning API currently relies on this
// behaviour to return useful forbidden errors when exporting decrypted receivers.
if !errors.Is(err, alertingac.ErrAuthorizationBase) {
return nil, err
}
res = nil
}
accesses, err := s.metadata.AccessControlMetadata(ctx, user, res...)
if err != nil {
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
}
inUses, err := s.metadata.InUseMetadata(ctx, orgId, res...)
if err != nil {
return nil, fmt.Errorf("failed to get in-use metadata: %w", err)
}
return convertToK8sResources(orgId, res, accesses, inUses, s.namespacer, opts.FieldSelector)
}
func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOptions) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
name, err := legacy_storage.UidToName(uid)
if err != nil {
return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid)
}
q := ngmodels.GetReceiverQuery{
OrgID: info.OrgID,
Name: name,
Decrypt: false,
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
r, err := s.service.GetReceiver(ctx, q, user)
if err != nil {
return nil, err
}
var access *ngmodels.ReceiverPermissionSet
accesses, err := s.metadata.AccessControlMetadata(ctx, user, r)
if err == nil {
if a, ok := accesses[r.GetUID()]; ok {
access = &a
}
} else {
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
}
var inUse *ngmodels.ReceiverMetadata
inUses, err := s.metadata.InUseMetadata(ctx, info.OrgID, r)
if err == nil {
if a, ok := inUses[r.GetUID()]; ok {
inUse = &a
}
} else {
return nil, fmt.Errorf("failed to get access control metadata: %w", err)
}
return convertToK8sResource(info.OrgID, r, access, inUse, s.namespacer)
}
func (s *legacyStorage) Create(ctx context.Context,
obj runtime.Object,
createValidation rest.ValidateObjectFunc,
_ *metav1.CreateOptions,
) (runtime.Object, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
return nil, err
}
if createValidation != nil {
if err := createValidation(ctx, obj.DeepCopyObject()); err != nil {
return nil, err
}
}
p, ok := obj.(*model.Receiver)
if !ok {
return nil, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
}
if p.Name != "" { // TODO remove when metadata.name can be defined by user
return nil, apierrors.NewBadRequest("object's metadata.name should be empty")
}
model, _, err := convertToDomainModel(p)
if err != nil {
return nil, err
}
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
}
return convertToK8sResource(info.OrgID, out, nil, nil, s.namespacer)
}
func (s *legacyStorage) Update(ctx context.Context,
uid string,
objInfo rest.UpdatedObjectInfo,
createValidation rest.ValidateObjectFunc,
updateValidation rest.ValidateObjectUpdateFunc,
_ bool,
_ *metav1.UpdateOptions,
) (runtime.Object, bool, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
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
}
obj, err := objInfo.UpdatedObject(ctx, old)
if err != nil {
return old, false, err
}
if updateValidation != nil {
if err := updateValidation(ctx, obj, old); err != nil {
return nil, false, err
}
}
p, ok := obj.(*model.Receiver)
if !ok {
return nil, false, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
}
model, storedSecureFields, err := convertToDomainModel(p)
if err != nil {
return old, false, err
}
updated, err := s.service.UpdateReceiver(ctx, model, storedSecureFields, info.OrgID, user)
if err != nil {
return nil, false, err
}
r, err := convertToK8sResource(info.OrgID, updated, nil, nil, s.namespacer)
return r, false, err
}
// GracefulDeleter
func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
info, err := request.NamespaceInfoFrom(ctx, true)
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
}
if deleteValidation != nil {
if err = deleteValidation(ctx, old); err != nil {
return nil, false, err
}
}
version := ""
if options.Preconditions != nil && options.Preconditions.ResourceVersion != nil {
version = *options.Preconditions.ResourceVersion
}
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) {
return nil, apierrors.NewMethodNotSupported(ResourceInfo.GroupResource(), "deleteCollection")
}
@@ -0,0 +1,19 @@
package receiver
import (
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
)
func NewStorage(
legacySvc ReceiverService,
namespacer request.NamespaceMapper,
metadata MetadataService,
) grafanarest.Storage {
return &legacyStorage{
service: legacySvc,
namespacer: namespacer,
tableConverter: ResourceInfo.TableConverter(),
metadata: metadata,
}
}
@@ -0,0 +1,36 @@
package receiver
import (
"fmt"
"strings"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
model "github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/receiver/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
var kind = model.Kind()
var ResourceInfo = utils.NewResourceInfo(kind.Group(), kind.Version(),
kind.GroupVersionResource().Resource, strings.ToLower(kind.Kind()), kind.Kind(),
func() runtime.Object { return kind.ZeroValue() },
func() runtime.Object { return kind.ZeroListValue() },
utils.TableColumns{
Definition: []metav1.TableColumnDefinition{
{Name: "Name", Type: "string", Format: "name"},
{Name: "Title", Type: "string", Format: "string", Description: "The receiver name"}, // TODO: Add integration types.
},
Reader: func(obj any) ([]interface{}, error) {
r, ok := obj.(*model.Receiver)
if ok {
return []interface{}{
r.Name,
r.Spec.Title,
// r.Spec, //TODO implement formatting for Spec, same as UI?
}, nil
}
return nil, fmt.Errorf("expected resource or info")
},
},
)