From 2ab7d3c725969927434cb7af71c6cdba9fc21df7 Mon Sep 17 00:00:00 2001 From: William Wernert Date: Mon, 5 Feb 2024 13:12:15 -0500 Subject: [PATCH] Alerting: Receivers API (read only endpoints) (#81751) * Add single receiver method * Add receiver permissions * Add single/multi GET endpoints for receivers * Remove stable tag from time intervals See end of PR description here: https://github.com/grafana/grafana/pull/81672 --- pkg/services/accesscontrol/models.go | 5 + pkg/services/ngalert/accesscontrol.go | 9 +- pkg/services/ngalert/api/api.go | 7 +- pkg/services/ngalert/api/api_notifications.go | 83 ++++++++ .../ngalert/api/api_notifications_test.go | 188 ++++++++++++++++++ .../ngalert/api/api_provisioning_test.go | 16 +- pkg/services/ngalert/api/authorization.go | 19 +- .../ngalert/api/authorization_test.go | 2 +- .../api/generated_base_api_notifications.go | 34 ++++ pkg/services/ngalert/api/notifications.go | 28 ++- pkg/services/ngalert/api/tooling/api.json | 76 ++----- .../api/tooling/definitions/receivers.go | 56 ++++++ .../api/tooling/definitions/time_intervals.go | 6 +- pkg/services/ngalert/api/tooling/post.json | 101 +++++++++- pkg/services/ngalert/api/tooling/spec.json | 107 +++++++++- pkg/services/ngalert/models/receivers.go | 7 + pkg/services/ngalert/notifier/compat.go | 58 +++--- pkg/services/ngalert/notifier/receiver_svc.go | 92 +++++++-- .../ngalert/notifier/receiver_svc_test.go | 181 +++++++++++------ .../provisioning/contactpoints_test.go | 2 +- pkg/services/ngalert/tests/fakes/receivers.go | 60 ++++++ public/api-merged.json | 76 ++----- public/openapi3.json | 98 +++------ 23 files changed, 971 insertions(+), 340 deletions(-) create mode 100644 pkg/services/ngalert/api/api_notifications.go create mode 100644 pkg/services/ngalert/api/api_notifications_test.go create mode 100644 pkg/services/ngalert/api/tooling/definitions/receivers.go create mode 100644 pkg/services/ngalert/tests/fakes/receivers.go diff --git a/pkg/services/accesscontrol/models.go b/pkg/services/accesscontrol/models.go index b3ee788c439..9b85f1159f5 100644 --- a/pkg/services/accesscontrol/models.go +++ b/pkg/services/accesscontrol/models.go @@ -449,6 +449,11 @@ const ( ActionAlertingNotificationsTimeIntervalsRead = "alert.notifications.time-intervals:read" ActionAlertingNotificationsTimeIntervalsWrite = "alert.notifications.time-intervals:write" + // Alerting receiver actions + ActionAlertingReceiversList = "alert.notifications.receivers:list" + ActionAlertingReceiversRead = "alert.notifications.receivers:read" + ActionAlertingReceiversReadSecrets = "alert.notifications.receivers.secrets:read" + // 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" ActionAlertingRuleExternalRead = "alert.rules.external:read" diff --git a/pkg/services/ngalert/accesscontrol.go b/pkg/services/ngalert/accesscontrol.go index a8ab39c0111..31fe1bdc894 100644 --- a/pkg/services/ngalert/accesscontrol.go +++ b/pkg/services/ngalert/accesscontrol.go @@ -25,8 +25,12 @@ var ( Action: accesscontrol.ActionAlertingRuleExternalRead, Scope: datasources.ScopeAll, }, + // Following are needed for simplified notification policies { - Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, // This is needed for simplified notification policies + Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, + }, + { + Action: accesscontrol.ActionAlertingReceiversList, }, }, }, @@ -115,6 +119,9 @@ var ( { Action: accesscontrol.ActionAlertingNotificationsTimeIntervalsRead, }, + { + Action: accesscontrol.ActionAlertingReceiversRead, + }, }, }, } diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 58bf3a0635d..09ae53a816b 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -64,6 +64,7 @@ type API struct { StateManager *state.Manager AccessControl ac.AccessControl Policies *provisioning.NotificationPolicyService + ReceiverService *notifier.ReceiverService ContactPointService *provisioning.ContactPointService Templates *provisioning.TemplateService MuteTimings *provisioning.MuteTimingService @@ -152,7 +153,11 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { hist: api.Historian, }), m) - api.RegisterNotificationsApiEndpoints(NewNotificationsApi(api.MuteTimings), m) + api.RegisterNotificationsApiEndpoints(NewNotificationsApi(&NotificationSrv{ + logger: logger, + receiverService: api.ReceiverService, + muteTimingService: api.MuteTimings, + }), m) // Inject upgrade endpoints if legacy alerting is enabled and the feature flag is enabled. if !api.Cfg.UnifiedAlerting.IsEnabled() && api.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingPreviewUpgrade) { diff --git a/pkg/services/ngalert/api/api_notifications.go b/pkg/services/ngalert/api/api_notifications.go new file mode 100644 index 00000000000..1fd4bafec30 --- /dev/null +++ b/pkg/services/ngalert/api/api_notifications.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + "errors" + "net/http" + + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/auth/identity" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" +) + +type NotificationSrv struct { + logger log.Logger + receiverService ReceiverService + muteTimingService MuteTimingService // defined in api_provisioning.go +} + +type ReceiverService interface { + GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) + GetReceivers(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) +} + +func (srv *NotificationSrv) RouteGetTimeInterval(c *contextmodel.ReqContext, name string) response.Response { + muteTimeInterval, err := srv.muteTimingService.GetMuteTiming(c.Req.Context(), name, c.OrgID) + if err != nil { + return errorToResponse(err) + } + return response.JSON(http.StatusOK, muteTimeInterval) // TODO convert to timing interval +} + +func (srv *NotificationSrv) RouteGetTimeIntervals(c *contextmodel.ReqContext) response.Response { + muteTimeIntervals, err := srv.muteTimingService.GetMuteTimings(c.Req.Context(), c.OrgID) + if err != nil { + return errorToResponse(err) + } + return response.JSON(http.StatusOK, muteTimeIntervals) // TODO convert to timing interval +} + +func (srv *NotificationSrv) RouteGetReceiver(c *contextmodel.ReqContext, name string) response.Response { + q := models.GetReceiverQuery{ + OrgID: c.SignedInUser.OrgID, + Name: name, + Decrypt: c.QueryBool("decrypt"), + } + + receiver, err := srv.receiverService.GetReceiver(c.Req.Context(), q, c.SignedInUser) + if err != nil { + if errors.Is(err, notifier.ErrNotFound) { + return ErrResp(http.StatusNotFound, err, "receiver not found") + } + if errors.Is(err, notifier.ErrPermissionDenied) { + return ErrResp(http.StatusForbidden, err, "permission denied") + } + return ErrResp(http.StatusInternalServerError, err, "failed to get receiver") + } + + return response.JSON(http.StatusOK, receiver) +} + +func (srv *NotificationSrv) RouteGetReceivers(c *contextmodel.ReqContext) response.Response { + q := models.GetReceiversQuery{ + OrgID: c.SignedInUser.OrgID, + Names: c.QueryStrings("names"), + Limit: c.QueryInt("limit"), + Offset: c.QueryInt("offset"), + Decrypt: c.QueryBool("decrypt"), + } + + receivers, err := srv.receiverService.GetReceivers(c.Req.Context(), q, c.SignedInUser) + if err != nil { + if errors.Is(err, notifier.ErrPermissionDenied) { + return ErrResp(http.StatusForbidden, err, "permission denied") + } + return ErrResp(http.StatusInternalServerError, err, "failed to get receiver groups") + } + + return response.JSON(http.StatusOK, receivers) +} diff --git a/pkg/services/ngalert/api/api_notifications_test.go b/pkg/services/ngalert/api/api_notifications_test.go new file mode 100644 index 00000000000..1af5cbac066 --- /dev/null +++ b/pkg/services/ngalert/api/api_notifications_test.go @@ -0,0 +1,188 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/log/logtest" + "github.com/grafana/grafana/pkg/services/auth/identity" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/notifier" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "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" +) + +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", + }, + }, + }, + } + fakeReceiverSvc.GetReceiverFn = func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, 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) + 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 + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + rc.Context.Req.Form.Set("decrypt", "true") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusOK, resp.Status()) + + call := fakeReceiverSvc.PopMethodCall() + require.Equal(t, "GetReceiver", call.Method) + expectedQ := models.GetReceiverQuery{ + Name: "receiver1", + Decrypt: true, + OrgID: 1, + } + require.Equal(t, expectedQ, call.Args[1]) + }) + + 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.ErrNotFound + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusNotFound, resp.Status()) + }) + + 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{}, notifier.ErrPermissionDenied + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceiver(&rc, "receiver1") + require.Equal(t, http.StatusForbidden, resp.Status()) + }) +} + +func TestRouteGetReceivers(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", + }, + }, + }, + }, + } + fakeReceiverSvc.GetReceiversFn = func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return expected, nil + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + rc.Context.Req.Form.Set("names", "receiver1") + resp := handler.handleRouteGetReceivers(&rc) + require.Equal(t, http.StatusOK, resp.Status()) + json, err := json.Marshal(expected) + require.NoError(t, err) + require.Equal(t, json, resp.Body()) + }) + + t.Run("builds query from request context", func(t *testing.T) { + fakeReceiverSvc.GetReceiversFn = func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return []definitions.GettableApiReceiver{}, nil + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + rc.Context.Req.Form.Set("names", "receiver1") + rc.Context.Req.Form.Add("names", "receiver2") + rc.Context.Req.Form.Set("limit", "1") + rc.Context.Req.Form.Set("offset", "2") + rc.Context.Req.Form.Set("decrypt", "true") + resp := handler.handleRouteGetReceivers(&rc) + require.Equal(t, http.StatusOK, resp.Status()) + + call := fakeReceiverSvc.PopMethodCall() + require.Equal(t, "GetReceivers", call.Method) + expectedQ := models.GetReceiversQuery{ + Names: []string{"receiver1", "receiver2"}, + Limit: 1, + Offset: 2, + Decrypt: true, + OrgID: 1, + } + require.Equal(t, expectedQ, call.Args[1]) + }) + + t.Run("should pass along permission denied response", func(t *testing.T) { + fakeReceiverSvc.GetReceiversFn = func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return nil, notifier.ErrPermissionDenied + } + handler := NewNotificationsApi(newNotificationSrv(fakeReceiverSvc)) + rc := testReqCtx("GET") + resp := handler.handleRouteGetReceivers(&rc) + require.Equal(t, http.StatusForbidden, resp.Status()) + }) +} + +func newNotificationSrv(receiverService ReceiverService) *NotificationSrv { + return &NotificationSrv{ + logger: log.NewNopLogger(), + receiverService: receiverService, + } +} + +func testReqCtx(method string) contextmodel.ReqContext { + return contextmodel.ReqContext{ + Context: &web.Context{ + Req: &http.Request{ + Header: make(http.Header), + Form: make(url.Values), + }, + Resp: web.NewResponseWriter(method, httptest.NewRecorder()), + }, + SignedInUser: &user.SignedInUser{ + OrgID: 1, + }, + Logger: &logtest.Fake{}, + } +} diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 376d838de7f..f2d196cda2a 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "net/url" "path" + "strings" "testing" "time" @@ -1363,9 +1364,13 @@ func TestProvisioningApiContactPointExport(t *testing.T) { }) t.Run("decrypt true without alert.provisioning.secrets:read permissions returns 403", func(t *testing.T) { + recPermCheck := false env := createTestEnv(t, testConfig) env.ac = &recordingAccessControlFake{ Callback: func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { + if strings.Contains(evaluator.String(), accesscontrol.ActionAlertingProvisioningReadSecrets) { + recPermCheck = true + } return false, nil }, } @@ -1377,16 +1382,18 @@ func TestProvisioningApiContactPointExport(t *testing.T) { response := sut.RouteGetContactPointsExport(&rc) + require.True(t, recPermCheck) require.Equal(t, 403, response.Status()) - require.Len(t, env.ac.EvaluateRecordings, 1) - require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, env.ac.EvaluateRecordings[0].Evaluator.String()) }) t.Run("decrypt true with admin returns 200", func(t *testing.T) { + recPermCheck := false env := createTestEnv(t, testConfig) env.ac = &recordingAccessControlFake{ Callback: func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) { - require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, evaluator.String()) + if strings.Contains(evaluator.String(), accesscontrol.ActionAlertingProvisioningReadSecrets) { + recPermCheck = true + } return true, nil }, } @@ -1399,9 +1406,8 @@ func TestProvisioningApiContactPointExport(t *testing.T) { response := sut.RouteGetContactPointsExport(&rc) response.WriteTo(&rc) + require.True(t, recPermCheck) require.Equal(t, 200, response.Status()) - require.Len(t, env.ac.EvaluateRecordings, 1) - require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, env.ac.EvaluateRecordings[0].Evaluator.String()) }) t.Run("json body content is as expected", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 95fd74e1b4d..44cf3babbdd 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -43,10 +43,27 @@ func (api *API) authorize(method, path string) web.Handler { ac.EvalPermission(ac.ActionAlertingRuleCreate, scope), ac.EvalPermission(ac.ActionAlertingRuleDelete, scope), ) - // Grafana rule state history paths + + // Grafana rule state history paths case http.MethodGet + "/api/v1/rules/history": eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + // Grafana receivers paths + case http.MethodGet + "/api/v1/notifications/receivers": + // additional authorization is done at the service level + eval = ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingNotificationsRead), + ac.EvalPermission(ac.ActionAlertingReceiversList), + ac.EvalPermission(ac.ActionAlertingReceiversRead), + 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), + ) + // Grafana unified alerting upgrade paths case http.MethodGet + "/api/v1/upgrade/org": return middleware.ReqOrgAdmin diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 264c74ba7e3..dcebbbbfd4f 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -40,7 +40,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 62) + require.Len(t, paths, 64) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/generated_base_api_notifications.go b/pkg/services/ngalert/api/generated_base_api_notifications.go index 1423daf23dc..17b82056b7d 100644 --- a/pkg/services/ngalert/api/generated_base_api_notifications.go +++ b/pkg/services/ngalert/api/generated_base_api_notifications.go @@ -19,10 +19,20 @@ import ( ) type NotificationsApi interface { + RouteGetReceiver(*contextmodel.ReqContext) response.Response + RouteGetReceivers(*contextmodel.ReqContext) response.Response RouteNotificationsGetTimeInterval(*contextmodel.ReqContext) response.Response RouteNotificationsGetTimeIntervals(*contextmodel.ReqContext) response.Response } +func (f *NotificationsApiHandler) RouteGetReceiver(ctx *contextmodel.ReqContext) response.Response { + // Parse Path Parameters + nameParam := web.Params(ctx.Req)[":name"] + return f.handleRouteGetReceiver(ctx, nameParam) +} +func (f *NotificationsApiHandler) RouteGetReceivers(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteGetReceivers(ctx) +} func (f *NotificationsApiHandler) RouteNotificationsGetTimeInterval(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] @@ -34,6 +44,30 @@ func (f *NotificationsApiHandler) RouteNotificationsGetTimeIntervals(ctx *contex func (api *API) RegisterNotificationsApiEndpoints(srv NotificationsApi, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { + group.Get( + toMacaronPath("/api/v1/notifications/receivers/{Name}"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/v1/notifications/receivers/{Name}"), + metrics.Instrument( + http.MethodGet, + "/api/v1/notifications/receivers/{Name}", + api.Hooks.Wrap(srv.RouteGetReceiver), + m, + ), + ) + group.Get( + toMacaronPath("/api/v1/notifications/receivers"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodGet, "/api/v1/notifications/receivers"), + metrics.Instrument( + http.MethodGet, + "/api/v1/notifications/receivers", + api.Hooks.Wrap(srv.RouteGetReceivers), + m, + ), + ) group.Get( toMacaronPath("/api/v1/notifications/time-intervals/{name}"), requestmeta.SetOwner(requestmeta.TeamAlerting), diff --git a/pkg/services/ngalert/api/notifications.go b/pkg/services/ngalert/api/notifications.go index 5b91c9a246a..5a189e00fdd 100644 --- a/pkg/services/ngalert/api/notifications.go +++ b/pkg/services/ngalert/api/notifications.go @@ -1,34 +1,32 @@ package api import ( - "net/http" - "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" ) type NotificationsApiHandler struct { - muteTimingService MuteTimingService + notificationSrv *NotificationSrv } -func NewNotificationsApi(muteTimingService MuteTimingService) NotificationsApi { +func NewNotificationsApi(notificationSrv *NotificationSrv) *NotificationsApiHandler { return &NotificationsApiHandler{ - muteTimingService: muteTimingService, + notificationSrv: notificationSrv, } } func (f *NotificationsApiHandler) handleRouteNotificationsGetTimeInterval(ctx *contextmodel.ReqContext, name string) response.Response { - model, err := f.muteTimingService.GetMuteTiming(ctx.Req.Context(), name, ctx.OrgID) - if err != nil { - return errorToResponse(err) - } - return response.JSON(http.StatusOK, model) // TODO convert to timing interval + return f.notificationSrv.RouteGetTimeInterval(ctx, name) } func (f *NotificationsApiHandler) handleRouteNotificationsGetTimeIntervals(ctx *contextmodel.ReqContext) response.Response { - model, err := f.muteTimingService.GetMuteTimings(ctx.Req.Context(), ctx.OrgID) - if err != nil { - return errorToResponse(err) - } - return response.JSON(http.StatusOK, model) // TODO convert to timing interval + return f.notificationSrv.RouteGetTimeIntervals(ctx) +} + +func (f *NotificationsApiHandler) handleRouteGetReceiver(ctx *contextmodel.ReqContext, name string) response.Response { + return f.notificationSrv.RouteGetReceiver(ctx, name) +} + +func (f *NotificationsApiHandler) handleRouteGetReceivers(ctx *contextmodel.ReqContext) response.Response { + return f.notificationSrv.RouteGetReceivers(ctx) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 31333220b78..1ea96215152 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -4511,7 +4511,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -4535,7 +4534,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -4695,13 +4693,13 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4750,6 +4748,7 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, @@ -4900,7 +4899,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -5057,61 +5055,6 @@ "version": "1.1.0" }, "paths": { - "/v1/notifications/time-intervals": { - "get": { - "description": "Get all the time intervals", - "operationId": "RouteNotificationsGetTimeIntervals", - "responses": { - "200": { - "$ref": "#/responses/GetAllIntervalsResponse" - }, - "403": { - "description": "ForbiddenError", - "schema": { - "$ref": "#/definitions/ForbiddenError" - } - } - }, - "tags": [ - "notifications" - ] - } - }, - "/v1/notifications/time-intervals/{name}": { - "get": { - "operationId": "RouteNotificationsGetTimeInterval", - "parameters": [ - { - "description": "Time interval name", - "in": "path", - "name": "name", - "required": true, - "type": "string" - } - ], - "responses": { - "200": { - "$ref": "#/responses/GetIntervalsByNameResponse" - }, - "403": { - "description": "ForbiddenError", - "schema": { - "$ref": "#/definitions/ForbiddenError" - } - }, - "404": { - "description": "NotFound", - "schema": { - "$ref": "#/definitions/NotFound" - } - } - }, - "summary": "Get a time interval by name.", - "tags": [ - "notifications" - ] - } - }, "/v1/provisioning/alert-rules": { "get": { "operationId": "RouteGetAlertRules", @@ -6168,6 +6111,21 @@ "$ref": "#/definitions/GettableTimeIntervals" } }, + "GetReceiverResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableApiReceiver" + }, + "type": "array" + } + }, "GettableHistoricUserConfigs": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/definitions/receivers.go b/pkg/services/ngalert/api/tooling/definitions/receivers.go new file mode 100644 index 00000000000..3e89f819e98 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/receivers.go @@ -0,0 +1,56 @@ +package definitions + +// swagger:route GET /v1/notifications/receivers/{Name} notifications RouteGetReceiver +// +// Get a receiver by name. +// +// Responses: +// 200: GetReceiverResponse +// 403: PermissionDenied +// 404: NotFound + +// swagger:route GET /v1/notifications/receivers notifications RouteGetReceivers +// +// Get all receivers. +// +// Responses: +// 200: GetReceiversResponse +// 403: PermissionDenied + +// swagger:parameters RouteGetReceiver +type GetReceiverParams struct { + // in:path + // required: true + Name string `json:"name"` + // in:query + // required: false + Decrypt bool `json:"decrypt"` +} + +// swagger:parameters RouteGetReceivers +type GetReceiversParams struct { + // in:query + // required: false + Names []string `json:"names"` + // in:query + // required: false + Limit int `json:"limit"` + // in:query + // required: false + Offset int `json:"offset"` + // in:query + // required: false + Decrypt bool `json:"decrypt"` +} + +// swagger:response GetReceiverResponse +type GetReceiverResponse struct { + // in:body + Body GettableApiReceiver +} + +// swagger:response GetReceiversResponse +type GetReceiversResponse struct { + // in:body + Body []GettableApiReceiver +} diff --git a/pkg/services/ngalert/api/tooling/definitions/time_intervals.go b/pkg/services/ngalert/api/tooling/definitions/time_intervals.go index 04ac68aa845..f8d2c9ea829 100644 --- a/pkg/services/ngalert/api/tooling/definitions/time_intervals.go +++ b/pkg/services/ngalert/api/tooling/definitions/time_intervals.go @@ -1,6 +1,6 @@ package definitions -// swagger:route GET /v1/notifications/time-intervals notifications stable RouteNotificationsGetTimeIntervals +// swagger:route GET /v1/notifications/time-intervals notifications RouteNotificationsGetTimeIntervals // // Get all the time intervals // @@ -8,7 +8,7 @@ package definitions // 200: GetAllIntervalsResponse // 403: ForbiddenError -// swagger:route GET /v1/notifications/time-intervals/{name} notifications stable RouteNotificationsGetTimeInterval +// swagger:route GET /v1/notifications/time-intervals/{name} notifications RouteNotificationsGetTimeInterval // // Get a time interval by name. // @@ -17,7 +17,7 @@ package definitions // 404: NotFound // 403: ForbiddenError -// swagger:parameters stable RouteNotificationsGetTimeInterval +// swagger:parameters RouteNotificationsGetTimeInterval type RouteTimeIntervalNameParam struct { // Time interval name // in:path diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index cfb69720ed1..0f28aa4f8b5 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -4512,7 +4512,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -4536,6 +4535,7 @@ "type": "object" }, "alertGroups": { + "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -4640,6 +4640,7 @@ "type": "object" }, "gettableAlert": { + "description": "GettableAlert gettable alert", "properties": { "annotations": { "$ref": "#/definitions/labelSet" @@ -4701,7 +4702,6 @@ "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4756,6 +4756,7 @@ "type": "array" }, "integration": { + "description": "Integration integration", "properties": { "lastNotifyAttempt": { "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", @@ -4936,7 +4937,6 @@ "type": "object" }, "receiver": { - "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -7046,6 +7046,86 @@ ] } }, + "/v1/notifications/receivers": { + "get": { + "operationId": "RouteGetReceivers", + "parameters": [ + { + "in": "query", + "items": { + "type": "string" + }, + "name": "names", + "type": "array" + }, + { + "format": "int64", + "in": "query", + "name": "limit", + "type": "integer" + }, + { + "format": "int64", + "in": "query", + "name": "offset", + "type": "integer" + }, + { + "in": "query", + "name": "decrypt", + "type": "boolean" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiversResponse" + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + }, + "summary": "Get all receivers.", + "tags": [ + "notifications" + ] + } + }, + "/v1/notifications/receivers/{Name}": { + "get": { + "operationId": "RouteGetReceiver", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "decrypt", + "type": "boolean" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiverResponse" + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + }, + "summary": "Get a receiver by name.", + "tags": [ + "notifications" + ] + } + }, "/v1/notifications/time-intervals": { "get": { "description": "Get all the time intervals", @@ -8507,6 +8587,21 @@ "$ref": "#/definitions/GettableTimeIntervals" } }, + "GetReceiverResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/GettableApiReceiver" + }, + "type": "array" + } + }, "GettableHistoricUserConfigs": { "description": "", "schema": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 50e830b39da..9b208a3ea17 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -2007,12 +2007,91 @@ } } }, + "/v1/notifications/receivers": { + "get": { + "tags": [ + "notifications" + ], + "summary": "Get all receivers.", + "operationId": "RouteGetReceivers", + "parameters": [ + { + "type": "array", + "items": { + "type": "string" + }, + "name": "names", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "name": "decrypt", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiversResponse" + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + } + } + }, + "/v1/notifications/receivers/{Name}": { + "get": { + "tags": [ + "notifications" + ], + "summary": "Get a receiver by name.", + "operationId": "RouteGetReceiver", + "parameters": [ + { + "type": "string", + "name": "name", + "in": "path", + "required": true + }, + { + "type": "boolean", + "name": "decrypt", + "in": "query" + } + ], + "responses": { + "200": { + "$ref": "#/responses/GetReceiverResponse" + }, + "404": { + "description": "NotFound", + "schema": { + "$ref": "#/definitions/NotFound" + } + } + } + } + }, "/v1/notifications/time-intervals": { "get": { "description": "Get all the time intervals", "tags": [ - "notifications", - "stable" + "notifications" ], "operationId": "RouteNotificationsGetTimeIntervals", "responses": { @@ -2031,8 +2110,7 @@ "/v1/notifications/time-intervals/{name}": { "get": { "tags": [ - "notifications", - "stable" + "notifications" ], "summary": "Get a time interval by name.", "operationId": "RouteNotificationsGetTimeInterval", @@ -7994,7 +8072,6 @@ } }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -8019,6 +8096,7 @@ "$ref": "#/definitions/alertGroup" }, "alertGroups": { + "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -8124,6 +8202,7 @@ } }, "gettableAlert": { + "description": "GettableAlert gettable alert", "type": "object", "required": [ "labels", @@ -8187,7 +8266,6 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -8244,6 +8322,7 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { + "description": "Integration integration", "type": "object", "required": [ "name", @@ -8426,7 +8505,6 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { - "description": "Receiver receiver", "type": "object", "required": [ "active", @@ -8557,6 +8635,21 @@ "$ref": "#/definitions/GettableTimeIntervals" } }, + "GetReceiverResponse": { + "description": "", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableApiReceiver" + } + } + }, "GettableHistoricUserConfigs": { "description": "", "schema": { diff --git a/pkg/services/ngalert/models/receivers.go b/pkg/services/ngalert/models/receivers.go index 048955428bc..1497395eeec 100644 --- a/pkg/services/ngalert/models/receivers.go +++ b/pkg/services/ngalert/models/receivers.go @@ -1,5 +1,12 @@ package models +// GetReceiverQuery represents a query for a single receiver. +type GetReceiverQuery struct { + OrgID int64 + Name string + Decrypt bool +} + // GetReceiversQuery represents a query for receiver groups. type GetReceiversQuery struct { OrgID int64 diff --git a/pkg/services/ngalert/notifier/compat.go b/pkg/services/ngalert/notifier/compat.go index 1731d384416..b30f43d8f6a 100644 --- a/pkg/services/ngalert/notifier/compat.go +++ b/pkg/services/ngalert/notifier/compat.go @@ -46,47 +46,51 @@ func PostableApiAlertingConfigToApiReceivers(c apimodels.PostableApiAlertingConf type DecryptFn = func(value string) string -func PostableToGettableGrafanaReceiver(r *apimodels.PostableGrafanaReceiver, provenance *models.Provenance, decryptFn DecryptFn) (apimodels.GettableGrafanaReceiver, error) { +func PostableToGettableGrafanaReceiver(r *apimodels.PostableGrafanaReceiver, provenance *models.Provenance, decryptFn DecryptFn, listOnly bool) (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)), + UID: r.UID, + Name: r.Name, + Type: r.Type, } - if provenance != nil { out.Provenance = apimodels.Provenance(*provenance) } - settings, err := simplejson.NewJson([]byte(r.Settings)) - if err != nil { - return apimodels.GettableGrafanaReceiver{}, err - } - - for k, v := range r.SecureSettings { - decryptedValue := decryptFn(v) + // if we aren't only listing, include the settings in the output + if !listOnly { + secureFields := make(map[string]bool, len(r.SecureSettings)) + settings, err := simplejson.NewJson([]byte(r.Settings)) if err != nil { return apimodels.GettableGrafanaReceiver{}, err } - if decryptedValue == "" { - continue - } else { - settings.Set(k, decryptedValue) - } - out.SecureFields[k] = true - } - jsonBytes, err := settings.MarshalJSON() - if err != nil { - return apimodels.GettableGrafanaReceiver{}, err + for k, v := range r.SecureSettings { + decryptedValue := decryptFn(v) + if err != nil { + return apimodels.GettableGrafanaReceiver{}, err + } + if decryptedValue == "" { + continue + } else { + settings.Set(k, decryptedValue) + } + secureFields[k] = true + } + + jsonBytes, err := settings.MarshalJSON() + if err != nil { + return apimodels.GettableGrafanaReceiver{}, err + } + + out.Settings = jsonBytes + out.SecureFields = secureFields + out.DisableResolveMessage = r.DisableResolveMessage } - out.Settings = jsonBytes return out, nil } -func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances map[string]models.Provenance, decryptFn DecryptFn) (apimodels.GettableApiReceiver, error) { +func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances map[string]models.Provenance, decryptFn DecryptFn, listOnly bool) (apimodels.GettableApiReceiver, error) { out := apimodels.GettableApiReceiver{ Receiver: config.Receiver{ Name: r.Receiver.Name, @@ -99,7 +103,7 @@ func PostableToGettableApiReceiver(r *apimodels.PostableApiReceiver, provenances prov = &p } - gettable, err := PostableToGettableGrafanaReceiver(gr, prov, decryptFn) + gettable, err := PostableToGettableGrafanaReceiver(gr, prov, decryptFn, listOnly) if err != nil { return apimodels.GettableApiReceiver{}, err } diff --git a/pkg/services/ngalert/notifier/receiver_svc.go b/pkg/services/ngalert/notifier/receiver_svc.go index cefa1ffd64a..aa1f0fbc445 100644 --- a/pkg/services/ngalert/notifier/receiver_svc.go +++ b/pkg/services/ngalert/notifier/receiver_svc.go @@ -18,6 +18,8 @@ import ( var ( // ErrPermissionDenied is returned when the user does not have permission to perform the requested action. ErrPermissionDenied = errors.New("permission denied") // TODO: convert to errutil + // ErrNotFound is returned when the requested resource does not exist. + ErrNotFound = errors.New("not found") // TODO: convert to errutil ) // ReceiverService is the service for managing alertmanager receivers. @@ -61,19 +63,71 @@ func NewReceiverService( } } -func (rs *ReceiverService) canDecrypt(ctx context.Context, user identity.Requester, name string) (bool, error) { - receiverAccess := false // TODO: stub, check for read secrets access - eval := accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets) - provisioningAccess, err := rs.ac.Evaluate(ctx, user, eval) +func (rs *ReceiverService) shouldDecrypt(ctx context.Context, user identity.Requester, name string, reqDecrypt bool) (bool, error) { + // TODO: migrate to new permission + eval := accesscontrol.EvalAny( + accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversReadSecrets), + accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets), + ) + + decryptAccess, err := rs.ac.Evaluate(ctx, user, eval) if err != nil { return false, err } - return receiverAccess || provisioningAccess, nil + + if reqDecrypt && !decryptAccess { + return false, ErrPermissionDenied + } + + return decryptAccess && reqDecrypt, 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) { + if q.Decrypt && user == nil { + return definitions.GettableApiReceiver{}, ErrPermissionDenied + } + + baseCfg, err := rs.cfgStore.GetLatestAlertmanagerConfiguration(ctx, q.OrgID) + if err != nil { + return definitions.GettableApiReceiver{}, err + } + + cfg := definitions.PostableUserConfig{} + err = json.Unmarshal([]byte(baseCfg.AlertmanagerConfiguration), &cfg) + if err != nil { + return definitions.GettableApiReceiver{}, err + } + + provenances, err := rs.provisioningStore.GetProvenances(ctx, q.OrgID, "contactPoint") + if err != nil { + return definitions.GettableApiReceiver{}, err + } + + receivers := cfg.AlertmanagerConfig.Receivers + for _, r := range receivers { + if r.Name == q.Name { + decrypt, err := rs.shouldDecrypt(ctx, user, q.Name, q.Decrypt) + if err != nil { + return definitions.GettableApiReceiver{}, err + } + decryptFn := rs.decryptOrRedact(ctx, decrypt, q.Name, "") + + return PostableToGettableApiReceiver(r, provenances, decryptFn, false) + } + } + + return definitions.GettableApiReceiver{}, ErrNotFound } // 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) { + if q.Decrypt && user == nil { + return nil, ErrPermissionDenied + } + baseCfg, err := rs.cfgStore.GetLatestAlertmanagerConfiguration(ctx, q.OrgID) if err != nil { return nil, err @@ -90,7 +144,11 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive return nil, err } - // TODO: check for list access + eval := accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversList) + listAccess, err := rs.ac.Evaluate(ctx, user, eval) + if err != nil { + return nil, err + } var output []definitions.GettableApiReceiver for i := q.Offset; i < len(cfg.AlertmanagerConfig.Receivers); i++ { @@ -99,24 +157,18 @@ func (rs *ReceiverService) GetReceivers(ctx context.Context, q models.GetReceive continue } - // TODO: check for scoped read access and continue if not allowed - - decryptAccess, err := rs.canDecrypt(ctx, user, r.Name) - if err != nil { - return nil, err - } - if q.Decrypt && !decryptAccess { - return nil, ErrPermissionDenied - } - - decryptFn := rs.decryptOrRedact(ctx, decryptAccess && q.Decrypt, r.Name, "") - - res, err := PostableToGettableApiReceiver(r, provenances, decryptFn) + decrypt, err := rs.shouldDecrypt(ctx, user, r.Name, q.Decrypt) if err != nil { return nil, err } - // TODO: redact settings if the user only has list access + decryptFn := rs.decryptOrRedact(ctx, decrypt, r.Name, "") + listOnly := !decrypt && listAccess + + res, err := PostableToGettableApiReceiver(r, provenances, decryptFn, listOnly) + 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 diff --git a/pkg/services/ngalert/notifier/receiver_svc_test.go b/pkg/services/ngalert/notifier/receiver_svc_test.go index 86426192767..28e477f8095 100644 --- a/pkg/services/ngalert/notifier/receiver_svc_test.go +++ b/pkg/services/ngalert/notifier/receiver_svc_test.go @@ -3,6 +3,7 @@ package notifier import ( "context" "encoding/json" + "fmt" "testing" "github.com/grafana/grafana/pkg/components/simplejson" @@ -22,24 +23,46 @@ import ( "github.com/stretchr/testify/require" ) +func TestReceiverService_GetReceiver(t *testing.T) { + sqlStore := db.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + + t.Run("service gets receiver from AM config", func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + + Receiver, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), nil) + 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) + }) + + 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"), nil) + require.ErrorIs(t, err, ErrNotFound) + }) +} + func TestReceiverService_GetReceivers(t *testing.T) { sqlStore := db.InitTestDB(t) secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) - t.Run("service gets receiver groups from AM config", func(t *testing.T) { + t.Run("service gets receivers from AM config", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - Receivers, err := sut.GetReceivers(context.Background(), rQuery(1), nil) + Receivers, err := sut.GetReceivers(context.Background(), multiQ(1), nil) require.NoError(t, err) require.Len(t, Receivers, 2) require.Equal(t, "grafana-default-email", Receivers[0].Name) require.Equal(t, "slack receiver", Receivers[1].Name) }) - t.Run("service filters receiver groups by name", func(t *testing.T) { + t.Run("service filters receivers by name", func(t *testing.T) { sut := createReceiverServiceSut(t, secretsService) - Receivers, err := sut.GetReceivers(context.Background(), rQuery(1, "slack receiver"), nil) + Receivers, err := sut.GetReceivers(context.Background(), multiQ(1, "slack receiver"), nil) require.NoError(t, err) require.Len(t, Receivers, 1) require.Equal(t, "slack receiver", Receivers[0].Name) @@ -51,72 +74,95 @@ func TestReceiverService_DecryptRedact(t *testing.T) { secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) ac := acimpl.ProvideAccessControl(setting.NewCfg()) - t.Run("service redacts receiver groups by default", func(t *testing.T) { - sut := createReceiverServiceSut(t, secretsService) - Receivers, err := sut.GetReceivers(context.Background(), rQuery(1, "slack receiver"), nil) - require.NoError(t, err) + getMethods := []string{"single", "multi"} - require.Len(t, Receivers, 1) + readUser := &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: {accesscontrol.ActionAlertingProvisioningRead: nil}, + }, + } - rGroup := Receivers[0] - require.Equal(t, "slack receiver", rGroup.Name) - require.Len(t, rGroup.GrafanaManagedReceivers, 1) - - grafanaReceiver := rGroup.GrafanaManagedReceivers[0] - require.Equal(t, "UID2", grafanaReceiver.UID) - - testedSettings, err := simplejson.NewJson([]byte(grafanaReceiver.Settings)) - require.NoError(t, err) - require.Equal(t, definitions.RedactedValue, testedSettings.Get("url").MustString()) - }) - - t.Run("service returns error when trying to decrypt with nil user", func(t *testing.T) { - sut := createReceiverServiceSut(t, secretsService) - sut.ac = ac - - q := rQuery(1) - q.Decrypt = true - _, err := sut.GetReceivers(context.Background(), q, nil) - require.ErrorIs(t, err, ErrPermissionDenied) - }) - - t.Run("service returns error when trying to decrypt without permission", func(t *testing.T) { - sut := createReceiverServiceSut(t, secretsService) - sut.ac = ac - - q := rQuery(1) - q.Decrypt = true - _, err := sut.GetReceivers(context.Background(), q, &user.SignedInUser{}) - require.ErrorIs(t, err, ErrPermissionDenied) - }) - - t.Run("service decrypts receiver groups with permission", func(t *testing.T) { - sut := createReceiverServiceSut(t, secretsService) - sut.ac = ac - - q := rQuery(1, "slack receiver") - q.Decrypt = true - Receivers, err := sut.GetReceivers(context.Background(), q, &user.SignedInUser{ - OrgID: 1, - Permissions: map[int64]map[string][]string{ - 1: {accesscontrol.ActionAlertingProvisioningReadSecrets: nil}, + secretUser := &user.SignedInUser{ + OrgID: 1, + Permissions: map[int64]map[string][]string{ + 1: { + accesscontrol.ActionAlertingProvisioningRead: nil, + accesscontrol.ActionAlertingProvisioningReadSecrets: nil, }, - }) - require.NoError(t, err) + }, + } - require.Len(t, Receivers, 1) + for _, tc := range []struct { + name string + decrypt bool + user *user.SignedInUser + err error + }{ + { + name: "service redacts receivers by default", + decrypt: false, + user: readUser, + err: nil, + }, + { + name: "service returns error when trying to decrypt without permission", + decrypt: true, + user: readUser, + err: ErrPermissionDenied, + }, + { + name: "service returns error if user is nil and decrypt is true", + decrypt: true, + user: nil, + err: ErrPermissionDenied, + }, + { + name: "service decrypts receivers with permission", + decrypt: true, + user: secretUser, + err: nil, + }, + } { + for _, method := range getMethods { + t.Run(fmt.Sprintf("%s %s", tc.name, method), func(t *testing.T) { + sut := createReceiverServiceSut(t, secretsService) + sut.ac = ac - rGroup := Receivers[0] - require.Equal(t, "slack receiver", rGroup.Name) - require.Len(t, rGroup.GrafanaManagedReceivers, 1) + var res definitions.GettableApiReceiver + var err error + if method == "single" { + q := singleQ(1, "slack receiver") + q.Decrypt = tc.decrypt + res, err = sut.GetReceiver(context.Background(), q, tc.user) + } else { + q := multiQ(1, "slack receiver") + q.Decrypt = tc.decrypt + var multiRes []definitions.GettableApiReceiver + multiRes, err = sut.GetReceivers(context.Background(), q, tc.user) + if tc.err == nil { + require.Len(t, multiRes, 1) + res = multiRes[0] + } + } + require.ErrorIs(t, err, tc.err) - grafanaReceiver := rGroup.GrafanaManagedReceivers[0] - require.Equal(t, "UID2", grafanaReceiver.UID) + if tc.err == nil { + require.Equal(t, "slack receiver", res.Name) + require.Len(t, res.GrafanaManagedReceivers, 1) + require.Equal(t, "UID2", res.GrafanaManagedReceivers[0].UID) - settings, err := simplejson.NewJson([]byte(grafanaReceiver.Settings)) - require.NoError(t, err) - require.Equal(t, "secure url", settings.Get("url").MustString()) - }) + 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()) + } else { + require.Equal(t, definitions.RedactedValue, testedSettings.Get("url").MustString()) + } + } + }) + } + } } func createReceiverServiceSut(t *testing.T, encryptSvc secrets.Service) *ReceiverService { @@ -148,7 +194,14 @@ func createEncryptedConfig(t *testing.T, secretService secrets.Service) string { return string(bytes) } -func rQuery(orgID int64, names ...string) models.GetReceiversQuery { +func singleQ(orgID int64, name string) models.GetReceiverQuery { + return models.GetReceiverQuery{ + OrgID: orgID, + Name: name, + } +} + +func multiQ(orgID int64, names ...string) models.GetReceiversQuery { return models.GetReceiversQuery{ OrgID: orgID, Names: names, diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index 6cd33217446..fb46846630b 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -278,7 +278,7 @@ func TestContactPointServiceDecryptRedact(t *testing.T) { q := cpsQuery(1) q.Decrypt = true - _, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{}) + _, err := sut.GetContactPoints(context.Background(), q, nil) require.ErrorIs(t, err, ErrPermissionDenied) }) t.Run("GetContactPoints errors when Decrypt = true and user is nil", func(t *testing.T) { diff --git a/pkg/services/ngalert/tests/fakes/receivers.go b/pkg/services/ngalert/tests/fakes/receivers.go new file mode 100644 index 00000000000..0840e100f90 --- /dev/null +++ b/pkg/services/ngalert/tests/fakes/receivers.go @@ -0,0 +1,60 @@ +package fakes + +import ( + "context" + + "github.com/grafana/grafana/pkg/services/auth/identity" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" +) + +type ReceiverServiceMethodCall struct { + Method string + Args []interface{} +} + +type FakeReceiverService struct { + MethodCalls []ReceiverServiceMethodCall + GetReceiverFn func(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) + GetReceiversFn func(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) +} + +func NewFakeReceiverService() *FakeReceiverService { + return &FakeReceiverService{ + GetReceiverFn: defaultReceiverFn, + GetReceiversFn: defaultReceiversFn, + } +} + +func (f *FakeReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, u identity.Requester) (definitions.GettableApiReceiver, error) { + f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceiver", Args: []interface{}{ctx, q}}) + return f.GetReceiverFn(ctx, q, u) +} + +func (f *FakeReceiverService) GetReceivers(ctx context.Context, q models.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + f.MethodCalls = append(f.MethodCalls, ReceiverServiceMethodCall{Method: "GetReceivers", Args: []interface{}{ctx, q}}) + return f.GetReceiversFn(ctx, q, u) +} + +func (f *FakeReceiverService) PopMethodCall() ReceiverServiceMethodCall { + if len(f.MethodCalls) == 0 { + return ReceiverServiceMethodCall{} + } + call := f.MethodCalls[len(f.MethodCalls)-1] + f.MethodCalls = f.MethodCalls[:len(f.MethodCalls)-1] + return call +} + +func (f *FakeReceiverService) Reset() { + f.MethodCalls = nil + f.GetReceiverFn = defaultReceiverFn + f.GetReceiversFn = 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.GetReceiversQuery, u identity.Requester) ([]definitions.GettableApiReceiver, error) { + return nil, nil +} diff --git a/public/api-merged.json b/public/api-merged.json index 6113e8c08c3..42a22d19b30 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -10355,61 +10355,6 @@ } } }, - "/v1/notifications/time-intervals": { - "get": { - "description": "Get all the time intervals", - "tags": [ - "notifications" - ], - "operationId": "RouteNotificationsGetTimeIntervals", - "responses": { - "200": { - "$ref": "#/responses/GetAllIntervalsResponse" - }, - "403": { - "description": "ForbiddenError", - "schema": { - "$ref": "#/definitions/ForbiddenError" - } - } - } - } - }, - "/v1/notifications/time-intervals/{name}": { - "get": { - "tags": [ - "notifications" - ], - "summary": "Get a time interval by name.", - "operationId": "RouteNotificationsGetTimeInterval", - "parameters": [ - { - "type": "string", - "description": "Time interval name", - "name": "name", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "$ref": "#/responses/GetIntervalsByNameResponse" - }, - "403": { - "description": "ForbiddenError", - "schema": { - "$ref": "#/definitions/ForbiddenError" - } - }, - "404": { - "description": "NotFound", - "schema": { - "$ref": "#/definitions/NotFound" - } - } - } - } - }, "/v1/provisioning/alert-rules": { "get": { "tags": [ @@ -21704,7 +21649,6 @@ } }, "alertGroup": { - "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -21728,7 +21672,6 @@ } }, "alertGroups": { - "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" @@ -21916,13 +21859,13 @@ } }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "$ref": "#/definitions/gettableAlert" } }, "gettableSilence": { - "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -21971,6 +21914,7 @@ } }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" @@ -22121,7 +22065,6 @@ } }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -22388,6 +22331,21 @@ "$ref": "#/definitions/GettableTimeIntervals" } }, + "GetReceiverResponse": { + "description": "(empty)", + "schema": { + "$ref": "#/definitions/GettableApiReceiver" + } + }, + "GetReceiversResponse": { + "description": "(empty)", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/GettableApiReceiver" + } + } + }, "GettableHistoricUserConfigs": { "description": "(empty)", "schema": { diff --git a/public/openapi3.json b/public/openapi3.json index a5211471ca0..dbe2e8efc6c 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -24,6 +24,29 @@ }, "description": "(empty)" }, + "GetReceiverResponse": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GettableApiReceiver" + } + } + }, + "description": "(empty)" + }, + "GetReceiversResponse": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/GettableApiReceiver" + }, + "type": "array" + } + } + }, + "description": "(empty)" + }, "GettableHistoricUserConfigs": { "content": { "application/json": { @@ -12194,7 +12217,6 @@ "type": "object" }, "alertGroup": { - "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -12218,7 +12240,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/components/schemas/alertGroup" }, @@ -12406,13 +12427,13 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/components/schemas/gettableAlert" }, "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -12461,6 +12482,7 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/components/schemas/gettableSilence" }, @@ -12611,7 +12633,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -23973,75 +23994,6 @@ ] } }, - "/v1/notifications/time-intervals": { - "get": { - "description": "Get all the time intervals", - "operationId": "RouteNotificationsGetTimeIntervals", - "responses": { - "200": { - "$ref": "#/components/responses/GetAllIntervalsResponse" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForbiddenError" - } - } - }, - "description": "ForbiddenError" - } - }, - "tags": [ - "notifications" - ] - } - }, - "/v1/notifications/time-intervals/{name}": { - "get": { - "operationId": "RouteNotificationsGetTimeInterval", - "parameters": [ - { - "description": "Time interval name", - "in": "path", - "name": "name", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "$ref": "#/components/responses/GetIntervalsByNameResponse" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ForbiddenError" - } - } - }, - "description": "ForbiddenError" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/NotFound" - } - } - }, - "description": "NotFound" - } - }, - "summary": "Get a time interval by name.", - "tags": [ - "notifications" - ] - } - }, "/v1/provisioning/alert-rules": { "get": { "operationId": "RouteGetAlertRules",