diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index c17b50795a3..be3a4623a7a 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -77,6 +77,7 @@ type API struct { SecretsService secrets.Service AccessControl accesscontrol.AccessControl Policies *provisioning.NotificationPolicyService + ContactPointService *provisioning.ContactPointService } // RegisterAPIEndpoints registers API handlers @@ -132,8 +133,9 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { if api.Cfg.IsFeatureToggleEnabled(featuremgmt.FlagAlertProvisioning) { api.RegisterProvisioningApiEndpoints(NewForkedProvisioningApi(&ProvisioningSrv{ - log: logger, - policies: api.Policies, + log: logger, + policies: api.Policies, + contactPointService: api.ContactPointService, }), m) } } diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 679e432107c..2b6e632c58e 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -9,20 +9,29 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/models" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" - domain "github.com/grafana/grafana/pkg/services/ngalert/models" + alerting_models "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" ) type ProvisioningSrv struct { - log log.Logger - policies NotificationPolicyService + log log.Logger + policies NotificationPolicyService + contactPointService ContactPointService +} + +type ContactPointService interface { + GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error) + CreateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) (apimodels.EmbeddedContactPoint, error) + UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, p alerting_models.Provenance) error + DeleteContactPoint(ctx context.Context, orgID int64, uid string) error } type NotificationPolicyService interface { GetPolicyTree(ctx context.Context, orgID int64) (provisioning.EmbeddedRoutingTree, error) - UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p domain.Provenance) error + UpdatePolicyTree(ctx context.Context, orgID int64, tree apimodels.Route, p alerting_models.Provenance) error } func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Response { @@ -39,7 +48,7 @@ func (srv *ProvisioningSrv) RouteGetPolicyTree(c *models.ReqContext) response.Re func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimodels.Route) response.Response { // TODO: lift validation out of definitions.Rotue.UnmarshalJSON and friends into a dedicated validator. - err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, domain.ProvenanceApi) + err := srv.policies.UpdatePolicyTree(c.Req.Context(), c.OrgId, tree, alerting_models.ProvenanceAPI) if errors.Is(err, store.ErrNoAlertmanagerConfiguration) { return ErrResp(http.StatusNotFound, err, "") } @@ -49,3 +58,37 @@ func (srv *ProvisioningSrv) RoutePostPolicyTree(c *models.ReqContext, tree apimo return response.JSON(http.StatusAccepted, util.DynMap{"message": "policies updated"}) } + +func (srv *ProvisioningSrv) RouteGetContactPoints(c *models.ReqContext) response.Response { + cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), c.OrgId) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusOK, cps) +} + +func (srv *ProvisioningSrv) RoutePostContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + // TODO: provenance is hardcoded for now, change it later to make it more flexible + contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, contactPoint) +} + +func (srv *ProvisioningSrv) RoutePutContactPoint(c *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + err := srv.contactPointService.UpdateContactPoint(c.Req.Context(), c.OrgId, cp, alerting_models.ProvenanceAPI) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint updated"}) +} + +func (srv *ProvisioningSrv) RouteDeleteContactPoint(c *models.ReqContext) response.Response { + cpID := web.Params(c.Req)[":ID"] + err := srv.contactPointService.DeleteContactPoint(c.Req.Context(), c.OrgId, cpID) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "") + } + return response.JSON(http.StatusAccepted, util.DynMap{"message": "contactpoint deleted"}) +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 317c7b21037..6394ec50ac3 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -181,10 +181,14 @@ func (api *API) authorize(method, path string) web.Handler { return middleware.ReqOrgAdmin // Grafana-only Provisioning Read Paths - case http.MethodGet + "/api/provisioning/policies": + case http.MethodGet + "/api/provisioning/policies", + http.MethodGet + "/api/provisioning/contact-points": return middleware.ReqSignedIn - case http.MethodPost + "/api/provisioning/policies": + case http.MethodPost + "/api/provisioning/policies", + http.MethodPost + "/api/provisioning/contact-points", + http.MethodPut + "/api/provisioning/contact-points", + http.MethodDelete + "/api/provisioning/contact-points/{ID}": return middleware.ReqEditorRole } diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index b39597a9fb8..0f6579760e6 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -47,7 +47,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 30) + require.Len(t, paths, 32) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/forked_provisioning.go b/pkg/services/ngalert/api/forked_provisioning.go index 74213e3c783..a808d9eca3e 100644 --- a/pkg/services/ngalert/api/forked_provisioning.go +++ b/pkg/services/ngalert/api/forked_provisioning.go @@ -26,3 +26,19 @@ func (f *ForkedProvisioningApi) forkRouteGetPolicyTree(ctx *models.ReqContext) r func (f *ForkedProvisioningApi) forkRoutePostPolicyTree(ctx *models.ReqContext, route apimodels.Route) response.Response { return f.svc.RoutePostPolicyTree(ctx, route) } + +func (f *ForkedProvisioningApi) forkRouteGetContactpoints(ctx *models.ReqContext) response.Response { + return f.svc.RouteGetContactPoints(ctx) +} + +func (f *ForkedProvisioningApi) forkRoutePostContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + return f.svc.RoutePostContactPoint(ctx, cp) +} + +func (f *ForkedProvisioningApi) forkRoutePutContactpoints(ctx *models.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { + return f.svc.RoutePutContactPoint(ctx, cp) +} + +func (f *ForkedProvisioningApi) forkRouteDeleteContactpoints(ctx *models.ReqContext) response.Response { + return f.svc.RouteDeleteContactPoint(ctx) +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index 2d5e72ae571..f310cdaddd5 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -20,14 +20,34 @@ import ( ) type ProvisioningApiForkingService interface { + RouteDeleteContactpoints(*models.ReqContext) response.Response + RouteGetContactpoints(*models.ReqContext) response.Response RouteGetPolicyTree(*models.ReqContext) response.Response + RoutePostContactpoints(*models.ReqContext) response.Response RoutePostPolicyTree(*models.ReqContext) response.Response + RoutePutContactpoints(*models.ReqContext) response.Response +} + +func (f *ForkedProvisioningApi) RouteDeleteContactpoints(ctx *models.ReqContext) response.Response { + return f.forkRouteDeleteContactpoints(ctx) +} + +func (f *ForkedProvisioningApi) RouteGetContactpoints(ctx *models.ReqContext) response.Response { + return f.forkRouteGetContactpoints(ctx) } func (f *ForkedProvisioningApi) RouteGetPolicyTree(ctx *models.ReqContext) response.Response { return f.forkRouteGetPolicyTree(ctx) } +func (f *ForkedProvisioningApi) RoutePostContactpoints(ctx *models.ReqContext) response.Response { + conf := apimodels.EmbeddedContactPoint{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePostContactpoints(ctx, conf) +} + func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) response.Response { conf := apimodels.Route{} if err := web.Bind(ctx.Req, &conf); err != nil { @@ -36,8 +56,36 @@ func (f *ForkedProvisioningApi) RoutePostPolicyTree(ctx *models.ReqContext) resp return f.forkRoutePostPolicyTree(ctx, conf) } +func (f *ForkedProvisioningApi) RoutePutContactpoints(ctx *models.ReqContext) response.Response { + conf := apimodels.EmbeddedContactPoint{} + if err := web.Bind(ctx.Req, &conf); err != nil { + return response.Error(http.StatusBadRequest, "bad request data", err) + } + return f.forkRoutePutContactpoints(ctx, conf) +} + func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingService, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { + group.Delete( + toMacaronPath("/api/provisioning/contact-points/{ID}"), + api.authorize(http.MethodDelete, "/api/provisioning/contact-points/{ID}"), + metrics.Instrument( + http.MethodDelete, + "/api/provisioning/contact-points/{ID}", + srv.RouteDeleteContactpoints, + m, + ), + ) + group.Get( + toMacaronPath("/api/provisioning/contact-points"), + api.authorize(http.MethodGet, "/api/provisioning/contact-points"), + metrics.Instrument( + http.MethodGet, + "/api/provisioning/contact-points", + srv.RouteGetContactpoints, + m, + ), + ) group.Get( toMacaronPath("/api/provisioning/policies"), api.authorize(http.MethodGet, "/api/provisioning/policies"), @@ -48,6 +96,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Post( + toMacaronPath("/api/provisioning/contact-points"), + api.authorize(http.MethodPost, "/api/provisioning/contact-points"), + metrics.Instrument( + http.MethodPost, + "/api/provisioning/contact-points", + srv.RoutePostContactpoints, + m, + ), + ) group.Post( toMacaronPath("/api/provisioning/policies"), api.authorize(http.MethodPost, "/api/provisioning/policies"), @@ -58,5 +116,15 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApiForkingServi m, ), ) + group.Put( + toMacaronPath("/api/provisioning/contact-points"), + api.authorize(http.MethodPut, "/api/provisioning/contact-points"), + metrics.Instrument( + http.MethodPut, + "/api/provisioning/contact-points", + srv.RoutePutContactpoints, + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go new file mode 100644 index 00000000000..3d86af76928 --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go @@ -0,0 +1,157 @@ +package definitions + +import ( + "fmt" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels" +) + +// swagger:route GET /api/provisioning/contact-points provisioning RouteGetContactpoints +// +// Get all the contact points. +// +// Responses: +// 200: Route +// 400: ValidationError + +// swagger:route POST /api/provisioning/contact-points provisioning RoutePostContactpoints +// +// Create a contact point. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:route PUT /api/provisioning/contact-points provisioning RoutePutContactpoints +// +// Update an existing contact point. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:route DELETE /api/provisioning/contact-points/{ID} provisioning RouteDeleteContactpoints +// +// Delete a contact point. +// +// Consumes: +// - application/json +// +// Responses: +// 202: Accepted +// 400: ValidationError + +// swagger:parameters RoutePostContactpoints RoutePutContactpoints +type ContactPointPayload struct { + // in:body + Body EmbeddedContactPoint +} + +// EmbeddedContactPoint is the contact point type that is used +// by grafanas embedded alertmanager implementation. +type EmbeddedContactPoint struct { + // UID is the unique identifier of the contact point. This will be + // automatically set be the Grafana. + UID string `json:"uid"` + // Name is used as grouping key in the UI. Contact points with the + // same name will be grouped in the UI. + Name string `json:"name" binding:"required"` + Type string `json:"type" binding:"required"` + Settings *simplejson.Json `json:"settings" binding:"required"` + DisableResolveMessage bool `json:"disableResolveMessage"` + Provenance string `json:"provanance"` +} + +const RedactedValue = "[REDACTED]" + +func (e *EmbeddedContactPoint) Valid(decryptFunc channels.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") + } + factory, exists := channels.Factory(e.Type) + if !exists { + return fmt.Errorf("unknown type '%s'", e.Type) + } + cfg, _ := channels.NewFactoryConfig(&channels.NotificationChannelConfig{ + Settings: e.Settings, + Type: e.Type, + }, nil, decryptFunc, nil) + if _, err := factory(cfg); err != nil { + return err + } + return nil +} + +func (e *EmbeddedContactPoint) SecretKeys() ([]string, error) { + switch e.Type { + case "alertmanager": + return []string{"basicAuthPassword"}, nil + case "dingding": + return []string{}, nil + case "discord": + return []string{}, nil + case "email": + return []string{}, nil + case "googlechat": + return []string{}, nil + case "kafka": + return []string{}, nil + case "line": + return []string{"token"}, nil + case "opsgenie": + return []string{"apiKey"}, nil + case "pagerduty": + return []string{"integrationKey"}, nil + case "pushover": + return []string{"userKey", "apiToken"}, nil + case "sensugo": + return []string{"apiKey"}, nil + case "slack": + return []string{"url", "token"}, nil + case "teams": + return []string{}, nil + case "telegram": + return []string{"bottoken"}, nil + case "threema": + return []string{"api_secret"}, nil + case "victorops": + return []string{}, nil + case "webhook": + return []string{}, nil + case "wecom": + return []string{"url"}, nil + } + return nil, fmt.Errorf("no secrets configured for type '%s'", e.Type) +} + +func (e *EmbeddedContactPoint) ExtractSecrets() (map[string]string, error) { + secrets := map[string]string{} + secretKeys, err := e.SecretKeys() + if err != nil { + return nil, err + } + for _, secretKey := range secretKeys { + secretValue := e.Settings.Get(secretKey).MustString() + e.Settings.Del(secretKey) + secrets[secretKey] = secretValue + } + return secrets, nil +} + +func (e *EmbeddedContactPoint) ResourceID() string { + return e.UID +} + +func (e *EmbeddedContactPoint) ResourceType() string { + return "contactPoint" +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go similarity index 100% rename from pkg/services/ngalert/api/tooling/definitions/provisioning.go rename to pkg/services/ngalert/api/tooling/definitions/provisioning_policies.go diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index dd87745df0d..f9373171bf5 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -558,6 +558,36 @@ "type": "object", "x-go-package": "github.com/prometheus/alertmanager/config" }, + "EmbeddedContactPoint": { + "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", + "properties": { + "disableResolveMessage": { + "type": "boolean", + "x-go-name": "DisableResolveMessage" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "provanance": { + "type": "string", + "x-go-name": "Provenance" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "type": "object", + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "ErrorType": { "title": "ErrorType models the different API error types.", "type": "string", @@ -3034,6 +3064,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3055,17 +3086,14 @@ "labels", "receiver" ], - "type": "object", - "x-go-name": "AlertGroup", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "alertGroups": { + "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, - "type": "array", - "x-go-name": "AlertGroups", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "array" }, "alertStatus": { "description": "AlertStatus alert status", @@ -3255,6 +3283,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -3306,17 +3335,14 @@ "status", "updatedAt" ], - "type": "object", - "x-go-name": "GettableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence" }, - "type": "array", - "x-go-name": "GettableSilences", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "array" }, "labelSet": { "additionalProperties": { @@ -3445,7 +3471,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -3485,7 +3510,9 @@ "matchers", "startsAt" ], - "type": "object" + "type": "object", + "x-go-name": "PostableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "receiver": { "properties": { @@ -4733,6 +4760,112 @@ ] } }, + "/api/provisioning/contact-points": { + "get": { + "operationId": "RouteGetContactpoints", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Get all the contact points.", + "tags": [ + "provisioning" + ] + }, + "post": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePostContactpoints", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Create a contact point.", + "tags": [ + "provisioning" + ] + }, + "put": { + "consumes": [ + "application/json" + ], + "operationId": "RoutePutContactpoints", + "parameters": [ + { + "in": "body", + "name": "Body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Update an existing contact point.", + "tags": [ + "provisioning" + ] + } + }, + "/api/provisioning/contact-points/{ID}": { + "delete": { + "consumes": [ + "application/json" + ], + "operationId": "RouteDeleteContactpoints", + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + }, + "summary": "Delete a contact point.", + "tags": [ + "provisioning" + ] + } + }, "/api/provisioning/policies": { "get": { "operationId": "RouteGetPolicyTree", diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index fff24ed4f08..188a5c4b885 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1136,6 +1136,112 @@ } } }, + "/api/provisioning/contact-points": { + "get": { + "tags": [ + "provisioning" + ], + "summary": "Get all the contact points.", + "operationId": "RouteGetContactpoints", + "responses": { + "200": { + "description": "Route", + "schema": { + "$ref": "#/definitions/Route" + } + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "put": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Update an existing contact point.", + "operationId": "RoutePutContactpoints", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + }, + "post": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Create a contact point.", + "operationId": "RoutePostContactpoints", + "parameters": [ + { + "name": "Body", + "in": "body", + "schema": { + "$ref": "#/definitions/EmbeddedContactPoint" + } + } + ], + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, + "/api/provisioning/contact-points/{ID}": { + "delete": { + "consumes": [ + "application/json" + ], + "tags": [ + "provisioning" + ], + "summary": "Delete a contact point.", + "operationId": "RouteDeleteContactpoints", + "responses": { + "202": { + "$ref": "#/responses/Accepted" + }, + "400": { + "description": "ValidationError", + "schema": { + "$ref": "#/definitions/ValidationError" + } + } + } + } + }, "/api/provisioning/policies": { "get": { "tags": [ @@ -2369,6 +2475,36 @@ }, "x-go-package": "github.com/prometheus/alertmanager/config" }, + "EmbeddedContactPoint": { + "description": "EmbeddedContactPoint is the contact point type that is used\nby grafanas embedded alertmanager implementation.", + "type": "object", + "properties": { + "disableResolveMessage": { + "type": "boolean", + "x-go-name": "DisableResolveMessage" + }, + "name": { + "type": "string", + "x-go-name": "Name" + }, + "provanance": { + "type": "string", + "x-go-name": "Provenance" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string", + "x-go-name": "Type" + }, + "uid": { + "type": "string", + "x-go-name": "UID" + } + }, + "x-go-package": "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + }, "ErrorType": { "type": "string", "title": "ErrorType models the different API error types.", @@ -4848,6 +4984,7 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "alertGroup": { + "description": "AlertGroup alert group", "type": "object", "required": [ "alerts", @@ -4870,17 +5007,14 @@ "$ref": "#/definitions/receiver" } }, - "x-go-name": "AlertGroup", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroup" }, "alertGroups": { + "description": "AlertGroups alert groups", "type": "array", "items": { "$ref": "#/definitions/alertGroup" }, - "x-go-name": "AlertGroups", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/alertGroups" }, "alertStatus": { @@ -5073,6 +5207,7 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -5125,17 +5260,14 @@ "x-go-name": "UpdatedAt" } }, - "x-go-name": "GettableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "$ref": "#/definitions/gettableSilence" }, - "x-go-name": "GettableSilences", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilences" }, "labelSet": { @@ -5265,7 +5397,6 @@ "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" }, "postableSilence": { - "description": "PostableSilence postable silence", "type": "object", "required": [ "comment", @@ -5306,6 +5437,8 @@ "x-go-name": "StartsAt" } }, + "x-go-name": "PostableSilence", + "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/postableSilence" }, "receiver": { diff --git a/pkg/services/ngalert/models/provisioning.go b/pkg/services/ngalert/models/provisioning.go index b3ad9612ef5..c3655edfc5b 100644 --- a/pkg/services/ngalert/models/provisioning.go +++ b/pkg/services/ngalert/models/provisioning.go @@ -3,8 +3,10 @@ package models type Provenance string const ( + // ProvenanceNone reflects the provenance when no provenance is stored + // for the requested object in the database. ProvenanceNone Provenance = "" - ProvenanceApi Provenance = "api" + ProvenanceAPI Provenance = "api" ProvenanceFile Provenance = "file" ) diff --git a/pkg/services/ngalert/ngalert.go b/pkg/services/ngalert/ngalert.go index fe070adf47a..9d03ecfe9cd 100644 --- a/pkg/services/ngalert/ngalert.go +++ b/pkg/services/ngalert/ngalert.go @@ -139,6 +139,7 @@ func (ng *AlertNG) init() error { // Provisioning policyService := provisioning.NewNotificationPolicyService(store, store, store, ng.Log) + contactPointService := provisioning.NewContactPointService(store, ng.SecretsService, store, store, ng.Log) api := api.API{ Cfg: ng.Cfg, @@ -158,6 +159,7 @@ func (ng *AlertNG) init() error { StateManager: ng.stateManager, AccessControl: ng.accesscontrol, Policies: policyService, + ContactPointService: contactPointService, } api.RegisterAPIEndpoints(ng.Metrics.GetAPIMetrics()) diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go new file mode 100644 index 00000000000..00b1b60aba7 --- /dev/null +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -0,0 +1,402 @@ +package provisioning + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + + "github.com/grafana/grafana/pkg/infra/log" + 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/store" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/util" + "github.com/prometheus/alertmanager/config" +) + +type ContactPointService struct { + amStore AMConfigStore + encryptionService secrets.Service + provenanceStore ProvisioningStore + xact TransactionManager + log log.Logger +} + +func NewContactPointService(store store.AlertingStore, encryptionService secrets.Service, + provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger) *ContactPointService { + return &ContactPointService{ + amStore: store, + encryptionService: encryptionService, + provenanceStore: provenanceStore, + xact: xact, + log: log, + } +} + +func (ecp *ContactPointService) GetContactPoints(ctx context.Context, orgID int64) ([]apimodels.EmbeddedContactPoint, error) { + cfg, _, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return nil, err + } + provenances, err := ecp.provenanceStore.GetProvenances(ctx, orgID, "contactPoint") + if err != nil { + return nil, err + } + contactPoints := []apimodels.EmbeddedContactPoint{} + for _, contactPoint := range cfg.GetGrafanaReceiverMap() { + embeddedContactPoint := apimodels.EmbeddedContactPoint{ + UID: contactPoint.UID, + Type: contactPoint.Type, + Name: contactPoint.Name, + DisableResolveMessage: contactPoint.DisableResolveMessage, + Settings: contactPoint.Settings, + } + if val, exists := provenances[embeddedContactPoint.UID]; exists && val != "" { + embeddedContactPoint.Provenance = string(val) + } + for k, v := range contactPoint.SecureSettings { + decryptedValue, err := ecp.decryptValue(v) + if err != nil { + ecp.log.Warn("decrypting value failed", "err", err.Error()) + continue + } + if decryptedValue == "" { + continue + } + embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue) + } + contactPoints = append(contactPoints, embeddedContactPoint) + } + sort.SliceStable(contactPoints, func(i, j int) bool { + return contactPoints[i].Name < contactPoints[j].Name + }) + return contactPoints, nil +} + +// internal only +func (ecp *ContactPointService) getContactPointDecrypted(ctx context.Context, orgID int64, uid string) (apimodels.EmbeddedContactPoint, error) { + cfg, _, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + for _, receiver := range cfg.GetGrafanaReceiverMap() { + if receiver.UID != uid { + continue + } + embeddedContactPoint := apimodels.EmbeddedContactPoint{ + UID: receiver.UID, + Type: receiver.Type, + Name: receiver.Name, + DisableResolveMessage: receiver.DisableResolveMessage, + Settings: receiver.Settings, + } + for k, v := range receiver.SecureSettings { + decryptedValue, err := ecp.decryptValue(v) + if err != nil { + ecp.log.Warn("decrypting value failed", "err", err.Error()) + continue + } + if decryptedValue == "" { + continue + } + embeddedContactPoint.Settings.Set(k, decryptedValue) + } + return embeddedContactPoint, nil + } + return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point with uid '%s' not found", uid) +} + +func (ecp *ContactPointService) CreateContactPoint(ctx context.Context, orgID int64, + contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) (apimodels.EmbeddedContactPoint, error) { + if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil { + return apimodels.EmbeddedContactPoint{}, fmt.Errorf("contact point is not valid: %w", err) + } + + cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + + extractedSecrets, err := contactPoint.ExtractSecrets() + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + + for k, v := range extractedSecrets { + encryptedValue, err := ecp.encryptValue(v) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + extractedSecrets[k] = encryptedValue + } + + contactPoint.UID = util.GenerateShortUID() + grafanaReceiver := &apimodels.PostableGrafanaReceiver{ + UID: contactPoint.UID, + Name: contactPoint.Name, + Type: contactPoint.Type, + DisableResolveMessage: contactPoint.DisableResolveMessage, + Settings: contactPoint.Settings, + SecureSettings: extractedSecrets, + } + + receiverFound := false + for _, receiver := range cfg.AlertmanagerConfig.Receivers { + if receiver.Name == contactPoint.Name { + receiver.PostableGrafanaReceivers.GrafanaManagedReceivers = append(receiver.PostableGrafanaReceivers.GrafanaManagedReceivers, grafanaReceiver) + receiverFound = true + } + } + + if !receiverFound { + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers, &apimodels.PostableApiReceiver{ + Receiver: config.Receiver{ + Name: grafanaReceiver.Name, + }, + PostableGrafanaReceivers: apimodels.PostableGrafanaReceivers{ + GrafanaManagedReceivers: []*apimodels.PostableGrafanaReceiver{grafanaReceiver}, + }, + }) + } + + data, err := json.Marshal(cfg) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + + err = ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(data), + FetchedConfigurationHash: fetchedHash, + ConfigurationVersion: "v1", + Default: false, + OrgID: orgID, + }) + if err != nil { + return err + } + adapter := provenanceOrgAdapter{ + inner: &contactPoint, + orgID: orgID, + } + err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance) + if err != nil { + return err + } + contactPoint.Provenance = string(provenance) + return nil + }) + if err != nil { + return apimodels.EmbeddedContactPoint{}, err + } + for k := range extractedSecrets { + contactPoint.Settings.Set(k, apimodels.RedactedValue) + } + return contactPoint, nil +} + +func (ecp *ContactPointService) UpdateContactPoint(ctx context.Context, orgID int64, contactPoint apimodels.EmbeddedContactPoint, provenance models.Provenance) error { + // set all redacted values with the latest known value from the store + rawContactPoint, err := ecp.getContactPointDecrypted(ctx, orgID, contactPoint.UID) + if err != nil { + return err + } + secretKeys, err := contactPoint.SecretKeys() + if err != nil { + return err + } + for _, secretKey := range secretKeys { + secretValue := contactPoint.Settings.Get(secretKey).MustString() + if secretValue == apimodels.RedactedValue { + contactPoint.Settings.Set(secretKey, rawContactPoint.Settings.Get(secretKey).MustString()) + } + } + // validate merged values + if err := contactPoint.Valid(ecp.encryptionService.GetDecryptedValue); err != nil { + return err + } + // check that provenance is not changed in a invalid way + storedProvenance, err := ecp.provenanceStore.GetProvenance(ctx, provenanceOrgAdapter{ + inner: &contactPoint, + orgID: orgID, + }) + if err != nil { + return err + } + if storedProvenance != provenance && storedProvenance != models.ProvenanceNone { + return fmt.Errorf("cannot changed provenance from '%s' to '%s'", storedProvenance, provenance) + } + // transform to internal model + extractedSecrets, err := contactPoint.ExtractSecrets() + if err != nil { + return err + } + for k, v := range extractedSecrets { + encryptedValue, err := ecp.encryptValue(v) + if err != nil { + return err + } + extractedSecrets[k] = encryptedValue + } + mergedReceiver := &apimodels.PostableGrafanaReceiver{ + UID: contactPoint.UID, + Name: contactPoint.Name, + Type: contactPoint.Type, + DisableResolveMessage: contactPoint.DisableResolveMessage, + Settings: contactPoint.Settings, + SecureSettings: extractedSecrets, + } + // save to store + cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return err + } + for _, receiver := range cfg.AlertmanagerConfig.Receivers { + if receiver.Name == contactPoint.Name { + receiverNotFound := true + for i, grafanaReceiver := range receiver.GrafanaManagedReceivers { + if grafanaReceiver.UID == mergedReceiver.UID { + receiverNotFound = false + receiver.GrafanaManagedReceivers[i] = mergedReceiver + break + } + } + if receiverNotFound { + return fmt.Errorf("contact point with uid '%s' not found", mergedReceiver.UID) + } + } + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + err = ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(data), + FetchedConfigurationHash: fetchedHash, + ConfigurationVersion: "v1", + Default: false, + OrgID: orgID, + }) + if err != nil { + return err + } + adapter := provenanceOrgAdapter{ + inner: &contactPoint, + orgID: orgID, + } + err = ecp.provenanceStore.SetProvenance(ctx, adapter, provenance) + if err != nil { + return err + } + contactPoint.Provenance = string(provenance) + return nil + }) +} + +func (ecp *ContactPointService) DeleteContactPoint(ctx context.Context, orgID int64, uid string) error { + cfg, fetchedHash, err := ecp.getCurrentConfig(ctx, orgID) + if err != nil { + return err + } + // Indicates if the full contact point is removed or just one of the + // configurations, as a contactpoint can consist of any number of + // configurations. + fullRemoval := false + // Name of the contact point that will be removed, might be used if a + // full removal is done to check if it's referenced in any route. + name := "" + for i, receiver := range cfg.AlertmanagerConfig.Receivers { + for j, grafanaReceiver := range receiver.GrafanaManagedReceivers { + if grafanaReceiver.UID == uid { + name = grafanaReceiver.Name + receiver.GrafanaManagedReceivers = append(receiver.GrafanaManagedReceivers[:j], receiver.GrafanaManagedReceivers[j+1:]...) + // if this was the last receiver we removed, we remove the whole receiver + if len(receiver.GrafanaManagedReceivers) == 0 { + fullRemoval = true + cfg.AlertmanagerConfig.Receivers = append(cfg.AlertmanagerConfig.Receivers[:i], cfg.AlertmanagerConfig.Receivers[i+1:]...) + } + break + } + } + } + if fullRemoval && isContactPointInUse(name, []*apimodels.Route{cfg.AlertmanagerConfig.Route}) { + return fmt.Errorf("contact point '%s' is currently used by a notification policy", name) + } + data, err := json.Marshal(cfg) + if err != nil { + return err + } + return ecp.xact.InTransaction(ctx, func(ctx context.Context) error { + err := ecp.provenanceStore.DeleteProvenance(ctx, provenanceOrgAdapter{ + inner: &apimodels.EmbeddedContactPoint{ + UID: uid, + }, + orgID: orgID, + }) + if err != nil { + return err + } + return ecp.amStore.UpdateAlertmanagerConfiguration(ctx, &models.SaveAlertmanagerConfigurationCmd{ + AlertmanagerConfiguration: string(data), + FetchedConfigurationHash: fetchedHash, + ConfigurationVersion: "v1", + Default: false, + OrgID: orgID, + }) + }) +} + +func (ecp *ContactPointService) getCurrentConfig(ctx context.Context, orgID int64) (*apimodels.PostableUserConfig, string, error) { + query := &models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: orgID, + } + err := ecp.amStore.GetLatestAlertmanagerConfiguration(ctx, query) + if err != nil { + return nil, "", err + } + cfg, err := DeserializeAlertmanagerConfig([]byte(query.Result.AlertmanagerConfiguration)) + if err != nil { + return nil, "", err + } + return cfg, query.Result.ConfigurationHash, nil +} + +func isContactPointInUse(name string, routes []*apimodels.Route) bool { + if len(routes) == 0 { + return false + } + for _, route := range routes { + if route.Receiver == name { + return true + } + if isContactPointInUse(name, route.Routes) { + return true + } + } + return false +} + +func (ecp *ContactPointService) decryptValue(value string) (string, error) { + decodeValue, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + + decryptedValue, err := ecp.encryptionService.Decrypt(context.Background(), decodeValue) + if err != nil { + return "", err + } + + return string(decryptedValue), nil +} + +func (ecp *ContactPointService) encryptValue(value string) (string, error) { + encryptedData, err := ecp.encryptionService.Encrypt(context.Background(), []byte(value), secrets.WithoutScope()) + if err != nil { + return "", fmt.Errorf("failed to encrypt secure settings: %w", err) + } + return base64.StdEncoding.EncodeToString(encryptedData), nil +} diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go new file mode 100644 index 00000000000..cb70fe05baa --- /dev/null +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -0,0 +1,195 @@ +package provisioning + +import ( + "context" + "testing" + + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/secrets/database" + "github.com/grafana/grafana/pkg/services/secrets/manager" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/stretchr/testify/require" +) + +func TestContactPointService(t *testing.T) { + sqlStore := sqlstore.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + t.Run("service gets contact points from AM config", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + + require.Len(t, cps, 1) + require.Equal(t, "email receiver", cps[0].Name) + }) + + t.Run("service stitches contact point into org's AM config", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Len(t, cps, 2) + require.Equal(t, "test-contact-point", cps[1].Name) + require.Equal(t, "slack", cps[1].Type) + }) + + t.Run("default provenance of contact points is none", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + + require.Equal(t, models.ProvenanceNone, models.Provenance(cps[0].Provenance)) + }) + + t.Run("it's possible to update provenance from none to API", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + cps, err = sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance)) + }) + + t.Run("it's possible to update provenance from none to File", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceNone) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceNone, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile) + require.NoError(t, err) + + cps, err = sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance)) + }) + + t.Run("it's not possible to update provenance from File to API", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceFile, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.Error(t, err) + }) + + t.Run("it's not possible to update provenance from API to File", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + + newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + cps, err := sut.GetContactPoints(context.Background(), 1) + require.NoError(t, err) + require.Equal(t, newCp.UID, cps[1].UID) + require.Equal(t, models.ProvenanceAPI, models.Provenance(cps[1].Provenance)) + + err = sut.UpdateContactPoint(context.Background(), 1, newCp, models.ProvenanceFile) + require.Error(t, err) + }) + + t.Run("service respects concurrency token when updating", func(t *testing.T) { + sut := createContactPointServiceSut(secretsService) + newCp := createTestContactPoint() + q := models.GetLatestAlertmanagerConfigurationQuery{ + OrgID: 1, + } + err := sut.amStore.GetLatestAlertmanagerConfiguration(context.Background(), &q) + require.NoError(t, err) + expectedConcurrencyToken := q.Result.ConfigurationHash + + _, err = sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) + require.NoError(t, err) + + fake := sut.amStore.(*fakeAMConfigStore) + intercepted := fake.lastSaveCommand + require.Equal(t, expectedConcurrencyToken, intercepted.FetchedConfigurationHash) + }) +} + +func TestContactPointInUse(t *testing.T) { + result := isContactPointInUse("test", []*definitions.Route{ + { + Receiver: "not-test", + Routes: []*definitions.Route{ + { + Receiver: "not-test", + }, + { + Receiver: "test", + }, + }, + }, + }) + require.True(t, result) + result = isContactPointInUse("test", []*definitions.Route{ + { + Receiver: "not-test", + Routes: []*definitions.Route{ + { + Receiver: "not-test", + }, + { + Receiver: "not-test", + }, + }, + }, + }) + require.False(t, result) +} + +func createContactPointServiceSut(secretService secrets.Service) *ContactPointService { + return &ContactPointService{ + amStore: newFakeAMConfigStore(), + provenanceStore: newFakeProvisioningStore(), + xact: newNopTransactionManager(), + encryptionService: secretService, + log: log.NewNopLogger(), + } +} + +func createTestContactPoint() definitions.EmbeddedContactPoint { + settings, _ := simplejson.NewJson([]byte(`{"recipient":"value_recipient","token":"value_token"}`)) + return definitions.EmbeddedContactPoint{ + Name: "test-contact-point", + Type: "slack", + Settings: settings, + } +} diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go index 3df52d4bc81..2a27b6b3554 100644 --- a/pkg/services/ngalert/provisioning/notification_policies_test.go +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -45,12 +45,12 @@ func TestNotificationPolicyService(t *testing.T) { sut := createNotificationPolicyServiceSut() newRoute := createTestRoutingTree() - err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + err := sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI) require.NoError(t, err) updated, err := sut.GetPolicyTree(context.Background(), 1) require.NoError(t, err) - require.Equal(t, models.ProvenanceApi, updated.Provenance) + require.Equal(t, models.ProvenanceAPI, updated.Provenance) }) t.Run("service respects concurrency token when updating", func(t *testing.T) { @@ -63,7 +63,7 @@ func TestNotificationPolicyService(t *testing.T) { require.NoError(t, err) expectedConcurrencyToken := q.Result.ConfigurationHash - err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceApi) + err = sut.UpdatePolicyTree(context.Background(), 1, newRoute, models.ProvenanceAPI) require.NoError(t, err) fake := sut.GetAMConfigStore().(*fakeAMConfigStore) diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 2a550db327f..eb428055a86 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -15,7 +15,9 @@ type AMConfigStore interface { // ProvisioningStore is a store of provisioning data for arbitrary objects. type ProvisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) + GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error + DeleteProvenance(ctx context.Context, o models.Provisionable) error } // TransactionManager represents the ability to issue and close transactions through contexts. diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index 12c54cc02c7..aba31dfd513 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -4,6 +4,7 @@ import ( "context" "crypto/md5" "fmt" + "strings" "github.com/grafana/grafana/pkg/services/ngalert/models" ) @@ -99,19 +100,39 @@ func newFakeProvisioningStore() *fakeProvisioningStore { func (f *fakeProvisioningStore) GetProvenance(ctx context.Context, o models.Provisionable) (models.Provenance, error) { if val, ok := f.records[o.ResourceOrgID()]; ok { - if prov, ok := val[o.ResourceID()]; ok { + if prov, ok := val[o.ResourceID()+o.ResourceType()]; ok { return prov, nil } } return models.ProvenanceNone, nil } +func (f *fakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) { + results := make(map[string]models.Provenance) + if val, ok := f.records[orgID]; ok { + for k, v := range val { + if strings.HasSuffix(k, resourceType) { + results[strings.TrimSuffix(k, resourceType)] = v + } + } + } + return results, nil +} + func (f *fakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error { orgID := o.ResourceOrgID() if _, ok := f.records[orgID]; !ok { f.records[orgID] = map[string]models.Provenance{} } - f.records[orgID][o.ResourceID()] = p + _ = f.DeleteProvenance(ctx, o) // delete old entries first + f.records[orgID][o.ResourceID()+o.ResourceType()] = p + return nil +} + +func (f *fakeProvisioningStore) DeleteProvenance(ctx context.Context, o models.Provisionable) error { + if val, ok := f.records[o.ResourceOrgID()]; ok { + delete(val, o.ResourceID()+o.ResourceType()) + } return nil } diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 56c5d157606..6710b702a84 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -45,6 +45,23 @@ func (st DBstore) GetProvenance(ctx context.Context, o models.Provisionable) (mo return provenance, nil } +// GetProvenance gets the provenance status for a provisionable object. +func (st DBstore) GetProvenances(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) { + resultMap := make(map[string]models.Provenance) + err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + filter := "record_type = ? AND org_id = ?" + rawData, err := sess.Table(provenanceRecord{}).Where(filter, resourceType, orgID).Desc("id").Cols("record_key", "provenance").QueryString() + if err != nil { + return fmt.Errorf("failed to query for existing provenance status: %w", err) + } + for _, data := range rawData { + resultMap[data["record_key"]] = models.Provenance(data["provenance"]) + } + return nil + }) + return resultMap, err +} + // SetProvenance changes the provenance status for a provisionable object. func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p models.Provenance) error { recordType := o.ResourceType() @@ -76,3 +93,15 @@ func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, p m return nil }) } + +// DeleteProvenance deletes the provenance record from the table +func (st DBstore) DeleteProvenance(ctx context.Context, o models.Provisionable) error { + return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error { + _, err := sess.Delete(provenanceRecord{ + RecordKey: o.ResourceID(), + RecordType: o.ResourceType(), + OrgID: o.ResourceOrgID(), + }) + return err + }) +} diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index 815b799f080..886ca7f6884 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -73,16 +73,58 @@ func TestProvisioningStore(t *testing.T) { err = store.SetProvenance(context.Background(), &ruleOrg3, models.ProvenanceFile) require.NoError(t, err) - err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceApi) + err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI) require.NoError(t, err) p, err := store.GetProvenance(context.Background(), &ruleOrg2) require.NoError(t, err) - require.Equal(t, models.ProvenanceApi, p) + require.Equal(t, models.ProvenanceAPI, p) p, err = store.GetProvenance(context.Background(), &ruleOrg3) require.NoError(t, err) require.Equal(t, models.ProvenanceFile, p) }) + + t.Run("Store should return all provenances by type", func(t *testing.T) { + const orgID = 123 + ruleOrg1 := models.AlertRule{ + UID: "789", + OrgID: orgID, + } + ruleOrg2 := models.AlertRule{ + UID: "790", + OrgID: orgID, + } + err := store.SetProvenance(context.Background(), &ruleOrg1, models.ProvenanceFile) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &ruleOrg2, models.ProvenanceAPI) + require.NoError(t, err) + + p, err := store.GetProvenances(context.Background(), orgID, ruleOrg1.ResourceType()) + require.NoError(t, err) + require.Len(t, p, 2) + require.Equal(t, models.ProvenanceFile, p[ruleOrg1.UID]) + require.Equal(t, models.ProvenanceAPI, p[ruleOrg2.UID]) + }) + + t.Run("Store should delete provenance correctly", func(t *testing.T) { + const orgID = 1234 + ruleOrg := models.AlertRule{ + UID: "7834539", + OrgID: orgID, + } + err := store.SetProvenance(context.Background(), &ruleOrg, models.ProvenanceFile) + require.NoError(t, err) + p, err := store.GetProvenance(context.Background(), &ruleOrg) + require.NoError(t, err) + require.Equal(t, models.ProvenanceFile, p) + + err = store.DeleteProvenance(context.Background(), &ruleOrg) + require.NoError(t, err) + + p, err = store.GetProvenance(context.Background(), &ruleOrg) + require.NoError(t, err) + require.Equal(t, models.ProvenanceNone, p) + }) } func createProvisioningStoreSut(_ *ngalert.AlertNG, db *store.DBstore) provisioning.ProvisioningStore { diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index ad4e70e32b2..38428d00ca5 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -126,6 +126,98 @@ func TestProvisioning(t *testing.T) { require.Equal(t, 202, resp.StatusCode) }) + t.Run("admin POST should succeed", func(t *testing.T) { + req := createTestRequest("POST", url, "admin", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + }) + t.Run("when provisioning contactpoints", func(t *testing.T) { + url := fmt.Sprintf("http://%s/api/provisioning/contact-points", grafanaListedAddr) + body := ` + { + "name": "my-contact-point", + "type": "slack", + "settings": { + "recipient": "value_recipient", + "token": "value_token" + } + }` + + t.Run("un-authenticated GET should 401", func(t *testing.T) { + req := createTestRequest("GET", url, "", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 401, resp.StatusCode) + }) + + t.Run("viewer GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "viewer", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("editor GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "editor", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("admin GET should succeed", func(t *testing.T) { + req := createTestRequest("GET", url, "admin", "") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 200, resp.StatusCode) + }) + + t.Run("un-authenticated POST should 401", func(t *testing.T) { + req := createTestRequest("POST", url, "", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 401, resp.StatusCode) + }) + + t.Run("viewer POST should 403", func(t *testing.T) { + req := createTestRequest("POST", url, "viewer", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 403, resp.StatusCode) + }) + + t.Run("editor POST should succeed", func(t *testing.T) { + req := createTestRequest("POST", url, "editor", body) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + require.Equal(t, 202, resp.StatusCode) + }) + t.Run("admin POST should succeed", func(t *testing.T) { req := createTestRequest("POST", url, "admin", body)