From 13121d3234d1cc4c7e48e1791bfe0c93850a9c5f Mon Sep 17 00:00:00 2001 From: Matthew Jacobson Date: Thu, 20 Jul 2023 14:35:56 -0400 Subject: [PATCH] Alerting: Add contact point provisioning file export (#71692) * Add contact point provisioning file export apis * Regenerate api * docs * frontend * add mock to tests * Fix missing row-level export button on viewer role w/ prov. read * Address review comments --------- Co-authored-by: Gilles De Mey --- .../file-provisioning/index.md | 6 +- .../http_api/alerting_provisioning.md | 143 ++++++-- pkg/services/ngalert/api/api_provisioning.go | 51 ++- .../ngalert/api/api_provisioning_test.go | 346 +++++++++++++++++- pkg/services/ngalert/api/authorization.go | 1 + .../ngalert/api/authorization_test.go | 2 +- pkg/services/ngalert/api/compat.go | 45 +++ .../api/generated_base_api_provisioning.go | 14 + pkg/services/ngalert/api/provisioning.go | 4 + pkg/services/ngalert/api/tooling/api.json | 143 ++++++-- .../api/tooling/definitions/provisioning.go | 33 ++ .../definitions/provisioning_alert_rules.go | 22 -- .../definitions/provisioning_contactpoints.go | 27 +- pkg/services/ngalert/api/tooling/post.json | 137 +++++-- pkg/services/ngalert/api/tooling/spec.json | 138 +++++-- .../ngalert/provisioning/contactpoints.go | 25 +- .../provisioning/contactpoints_test.go | 98 +++-- pkg/services/ngalert/provisioning/errors.go | 6 +- .../notification_policies_test.go | 2 +- pkg/services/ngalert/provisioning/testing.go | 15 +- .../alerting/contact_point_provisioner.go | 2 +- .../contact-points/ContactPoints.v1.test.tsx | 5 + .../components/receivers/ReceiversSection.tsx | 30 +- .../components/receivers/ReceiversTable.tsx | 51 ++- .../alerting/unified/utils/access-control.ts | 1 + 25 files changed, 1154 insertions(+), 193 deletions(-) create mode 100644 pkg/services/ngalert/api/tooling/definitions/provisioning.go diff --git a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md index 8ffe21843d9..e972a17358e 100644 --- a/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md +++ b/docs/sources/alerting/set-up/provision-alerting-resources/file-provisioning/index.md @@ -137,11 +137,13 @@ deleteRules: Create or delete contact points in your Grafana instance(s). -1. Create a YAML or JSON configuration file. +1. Create a contact point in Grafana. +1. Use the [Alerting provisioning API]({{< relref "../../../../developers/http_api/alerting_provisioning" >}}) export endpoints to download a provisioning file for your contact point. +1. Copy the contents into a YAML or JSON configuration file in the default provisioning directory or in your configured directory. Example configuration files can be found below. -1. Add the file(s) to your GitOps workflow, so that they deploy alongside your Grafana instance(s). +1. Ensure that your files are in the right directory on the node running the Grafana server, so that they deploy alongside your Grafana instance(s). Here is an example of a configuration file for creating contact points. diff --git a/docs/sources/developers/http_api/alerting_provisioning.md b/docs/sources/developers/http_api/alerting_provisioning.md index fa5f96bf4d3..a60a584e688 100644 --- a/docs/sources/developers/http_api/alerting_provisioning.md +++ b/docs/sources/developers/http_api/alerting_provisioning.md @@ -60,12 +60,13 @@ title: 'Alerting Provisioning HTTP API ' Contact point provisioning is for Grafana-managed alerts only. -| Method | URI | Name | Summary | -| ------ | ----------------------------------------- | --------------------------------------------------------- | --------------------------------- | -| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | -| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | -| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | -| PUT | /api/v1/provisioning/contact-points/{UID} | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | +| Method | URI | Name | Summary | +| ------ | ------------------------------------------ | ----------------------------------------------------------------- | ------------------------------------------------------ | +| DELETE | /api/v1/provisioning/contact-points/{UID} | [route delete contactpoints](#route-delete-contactpoints) | Delete a contact point. | +| GET | /api/v1/provisioning/contact-points | [route get contactpoints](#route-get-contactpoints) | Get all the contact points. | +| GET | /api/v1/provisioning/contact-points/export | [route get contactpoints export](#route-get-contactpoints-export) | Export all contact points in provisioning file format. | +| POST | /api/v1/provisioning/contact-points | [route post contactpoints](#route-post-contactpoints) | Create a contact point. | +| PUT | /api/v1/provisioning/contact-points/{UID} | [route put contactpoint](#route-put-contactpoint) | Update an existing contact point. | ### Notification policies @@ -258,11 +259,11 @@ GET /api/v1/provisioning/alert-rules/{UID}/export #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | -------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| UID | `path` | string | `string` | | ✓ | | Alert rule UID | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | `string` | string | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| UID | `path` | string | `string` | | ✓ | | Alert rule UID | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | #### All responses @@ -337,12 +338,12 @@ GET /api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| --------- | ------- | -------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| FolderUID | `path` | string | `string` | | ✓ | | | -| Group | `path` | string | `string` | | ✓ | | | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | `string` | string | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| --------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| FolderUID | `path` | string | `string` | | ✓ | | | +| Group | `path` | string | `string` | | ✓ | | | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | #### All responses @@ -397,10 +398,10 @@ GET /api/v1/provisioning/alert-rules/export #### Parameters -| Name | Source | Type | Go type | Separator | Required | Default | Description | -| -------- | ------- | -------- | ------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | -| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | -| format | `query` | `string` | string | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | --------------------------------------------------------------------------------------------------------------------------------- | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | #### All responses @@ -453,6 +454,46 @@ Status: OK [ContactPoints](#contact-points) +### Export all contact points in provisioning file format. (_RouteGetContactpointsExport_) + +``` +GET /api/v1/provisioning/contact-points/export +``` + +#### Parameters + +| Name | Source | Type | Go type | Separator | Required | Default | Description | +| -------- | ------- | ------- | -------- | --------- | :------: | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| decrypt | `query` | boolean | `bool` | | | | Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. | +| download | `query` | boolean | `bool` | | | | Whether to initiate a download of the file or not. | +| format | `query` | string | `string` | | | `"yaml"` | Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. | +| name | `query` | string | `string` | | | | Filter by name | + +#### All responses + +| Code | Status | Description | Has headers | Schema | +| ------------------------------------------ | --------- | ------------------ | :---------: | ---------------------------------------------------- | +| [200](#route-get-contactpoints-export-200) | OK | AlertingFileExport | | [schema](#route-get-contactpoints-export-200-schema) | +| [403](#route-get-contactpoints-export-403) | Forbidden | PermissionDenied | | [schema](#route-get-contactpoints-export-403-schema) | + +#### Responses + +##### 200 - AlertingFileExport + +Status: OK + +###### Schema + +[AlertingFileExport](#alerting-file-export) + +##### 403 - PermissionDenied + +Status: Forbidden + +###### Schema + +[PermissionDenied](#permission-denied) + ### Get a mute timing. (_RouteGetMuteTiming_) ``` @@ -1092,9 +1133,10 @@ Status: Accepted | annotations | map of string | `map[string]string` | | | | | | condition | string | `string` | | | | | | dasboardUid | string | `string` | | | | | -| data | [][alertqueryexport](#alert-query-export) | `[]*AlertQueryExport` | | | | | +| data | [][AlertQueryExport](#alert-query-export) | `[]*AlertQueryExport` | | | | | | execErrState | string | `string` | | | | | | for | [Duration](#duration) | `Duration` | | | | | +| isPaused | boolean | `bool` | | | | | | labels | map of string | `map[string]string` | | | | | | noDataState | string | `string` | | | | | | panelId | int64 (formatted integer) | `int64` | | | | | @@ -1113,7 +1155,7 @@ Status: Accepted | --------- | ------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | | folderUid | string | `string` | | | | | | interval | int64 (formatted integer) | `int64` | | | | | -| rules | [][provisionedalertrule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | +| rules | [][ProvisionedAlertRule](#provisioned-alert-rule) | `[]*ProvisionedAlertRule` | | | | | | title | string | `string` | | | | | {{% /responsive-table %}} @@ -1130,7 +1172,7 @@ Status: Accepted | interval | [Duration](#duration) | `Duration` | | | | | | name | string | `string` | | | | | | orgId | int64 (formatted integer) | `int64` | | | | | -| rules | [][alertruleexport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | +| rules | [][AlertRuleExport](#alert-rule-export) | `[]*AlertRuleExport` | | | | | {{% /responsive-table %}} @@ -1140,16 +1182,27 @@ Status: Accepted {{% responsive-table %}} -| Name | Type | Go type | Required | Default | Description | Example | -| ---------- | -------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | -| apiVersion | int64 (formatted integer) | `int64` | | | | | -| groups | [][alertrulegroupexport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | +| Name | Type | Go type | Required | Default | Description | Example | +| ------------- | -------------------------------------------------- | ------------------------- | :------: | ------- | ----------- | ------- | +| apiVersion | int64 (formatted integer) | `int64` | | | | | +| contactPoints | [][ContactPointExport](#contact-point-export) | `[]*ContactPointExport` | | | | | +| groups | [][AlertRuleGroupExport](#alert-rule-group-export) | `[]*AlertRuleGroupExport` | | | | | {{% /responsive-table %}} +### ContactPointExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| --------- | ------------------------------------ | ------------------- | :------: | ------- | ----------- | ------- | +| name | string | `string` | | | | | +| orgId | int64 (formatted integer) | `int64` | | | | | +| receivers | [][ReceiverExport](#receiver-export) | `[]*ReceiverExport` | | | | | + ### ContactPoints -[][embeddedcontactpoint](#embedded-contact-point) +[][EmbeddedContactPoint](#embedded-contact-point) ### Duration @@ -1213,7 +1266,7 @@ Status: Accepted > provides a Matches method to match a LabelSet against all Matchers in the > slice. Note that some users of Matchers might require it to be sorted. -[][matcher](#matcher) +[][Matcher](#matcher) ### MuteTimeInterval @@ -1224,13 +1277,13 @@ Status: Accepted | Name | Type | Go type | Required | Default | Description | Example | | -------------- | -------------------------------- | ----------------- | :------: | ------- | ----------- | ------- | | name | string | `string` | | | | | -| time_intervals | [][timeinterval](#time-interval) | `[]*TimeInterval` | | | | | +| time_intervals | [][TimeInterval](#time-interval) | `[]*TimeInterval` | | | | | {{% /responsive-table %}} ### MuteTimings -[][mutetimeinterval](#mute-time-interval) +[][MuteTimeInterval](#mute-time-interval) ### NotificationTemplate @@ -1260,7 +1313,7 @@ Status: Accepted ### NotificationTemplates -[][notificationtemplate](#notification-template) +[][NotificationTemplate](#notification-template) ### ObjectMatchers @@ -1268,6 +1321,10 @@ Status: Accepted #### Inlined models +### PermissionDenied + +[interface{}](#interface) + ### Provenance | Name | Type | Go type | Default | Description | Example | @@ -1284,11 +1341,12 @@ Status: Accepted | ------------ | ---------------------------- | ------------------- | :------: | ------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | annotations | map of string | `map[string]string` | | | | `{"runbook_url":"https://supercoolrunbook.com/page/13"}` | | condition | string | `string` | ✓ | | | `A` | -| data | [][alertquery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | +| data | [][AlertQuery](#alert-query) | `[]*AlertQuery` | ✓ | | | `[{"datasourceUid":"__expr__","model":{"conditions":[{"evaluator":{"params":[0,0],"type":"gt"},"operator":{"type":"and"},"query":{"params":[]},"reducer":{"params":[],"type":"avg"},"type":"query"}],"datasource":{"type":"__expr__","uid":"__expr__"},"expression":"1 == 1","hide":false,"intervalMs":1000,"maxDataPoints":43200,"refId":"A","type":"math"},"queryType":"","refId":"A","relativeTimeRange":{"from":0,"to":0}}]` | | execErrState | string | `string` | ✓ | | | | | folderUID | string | `string` | ✓ | | | `project_x` | | for | [Duration](#duration) | `Duration` | ✓ | | | | | id | int64 (formatted integer) | `int64` | | | | | +| isPaused | boolean | `bool` | | | | `false` | | labels | map of string | `map[string]string` | | | | `{"team":"sre-team-1"}` | | noDataState | string | `string` | ✓ | | | | | orgID | int64 (formatted integer) | `int64` | ✓ | | | | @@ -1302,7 +1360,18 @@ Status: Accepted ### ProvisionedAlertRules -[][provisionedalertrule](#provisioned-alert-rule) +[][ProvisionedAlertRule](#provisioned-alert-rule) + +### ReceiverExport + +**Properties** + +| Name | Type | Go type | Required | Default | Description | Example | +| --------------------- | ------------- | -------- | :------: | ------- | ----------- | ------- | +| disableResolveMessage | boolean | `bool` | | | | | +| settings | [JSON](#json) | `JSON` | | | | | +| type | string | `string` | | | | | +| uid | string | `string` | | | | | ### Regexp @@ -1350,7 +1419,7 @@ Status: Accepted | provenance | [Provenance](#provenance) | `Provenance` | | | | | | receiver | string | `string` | | | | | | repeat_interval | string | `string` | | | | | -| routes | [][route](#route) | `[]*Route` | | | | | +| routes | [][Route](#route) | `[]*Route` | | | | | {{% /responsive-table %}} @@ -1368,7 +1437,7 @@ Status: Accepted | days_of_month | []string | `[]string` | | | | | | location | string | `string` | | | | | | months | []string | `[]string` | | | | | -| times | [][timerange](#time-range) | `[]*TimeRange` | | | | | +| times | [][TimeRange](#time-range) | `[]*TimeRange` | | | | | | weekdays | []string | `[]string` | | | | | | years | []string | `[]string` | | | | | diff --git a/pkg/services/ngalert/api/api_provisioning.go b/pkg/services/ngalert/api/api_provisioning.go index 3785c421d20..6e4ed113838 100644 --- a/pkg/services/ngalert/api/api_provisioning.go +++ b/pkg/services/ngalert/api/api_provisioning.go @@ -14,6 +14,7 @@ import ( 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/services/user" "github.com/grafana/grafana/pkg/util" ) @@ -29,7 +30,7 @@ type ProvisioningSrv struct { } type ContactPointService interface { - GetContactPoints(ctx context.Context, q provisioning.ContactPointQuery) ([]definitions.EmbeddedContactPoint, error) + GetContactPoints(ctx context.Context, q provisioning.ContactPointQuery, user *user.SignedInUser) ([]definitions.EmbeddedContactPoint, error) CreateContactPoint(ctx context.Context, orgID int64, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) (definitions.EmbeddedContactPoint, error) UpdateContactPoint(ctx context.Context, orgID int64, contactPoint definitions.EmbeddedContactPoint, p alerting_models.Provenance) error DeleteContactPoint(ctx context.Context, orgID int64, uid string) error @@ -108,13 +109,38 @@ func (srv *ProvisioningSrv) RouteGetContactPoints(c *contextmodel.ReqContext) re Name: c.Query("name"), OrgID: c.OrgID, } - cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), q) + cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), q, nil) if err != nil { + if errors.Is(err, provisioning.ErrPermissionDenied) { + return ErrResp(http.StatusForbidden, err, "") + } return ErrResp(http.StatusInternalServerError, err, "") } return response.JSON(http.StatusOK, cps) } +func (srv *ProvisioningSrv) RouteGetContactPointsExport(c *contextmodel.ReqContext) response.Response { + q := provisioning.ContactPointQuery{ + Name: c.Query("name"), + OrgID: c.OrgID, + Decrypt: c.QueryBoolWithDefault("decrypt", false), + } + cps, err := srv.contactPointService.GetContactPoints(c.Req.Context(), q, c.SignedInUser) + if err != nil { + if errors.Is(err, provisioning.ErrPermissionDenied) { + return ErrResp(http.StatusForbidden, err, "") + } + return ErrResp(http.StatusInternalServerError, err, "") + } + + e, err := AlertingFileExportFromEmbeddedContactPoints(c.OrgID, cps) + if err != nil { + return ErrResp(http.StatusInternalServerError, err, "failed to create alerting file export") + } + + return exportResponse(c, e) +} + func (srv *ProvisioningSrv) RoutePostContactPoint(c *contextmodel.ReqContext, cp definitions.EmbeddedContactPoint) response.Response { provenance := determineProvenance(c) contactPoint, err := srv.contactPointService.CreateContactPoint(c.Req.Context(), c.OrgID, cp, alerting_models.Provenance(provenance)) @@ -433,7 +459,7 @@ func determineProvenance(ctx *contextmodel.ReqContext) definitions.Provenance { return definitions.Provenance(alerting_models.ProvenanceAPI) } -func exportResponse(c *contextmodel.ReqContext, body any) response.Response { +func extractExportRequest(c *contextmodel.ReqContext) definitions.ExportQueryParams { var format = "yaml" acceptHeader := c.Req.Header.Get("Accept") @@ -450,17 +476,26 @@ func exportResponse(c *contextmodel.ReqContext, body any) response.Response { format = queryFormat } - download := c.QueryBoolWithDefault("download", false) - if download { + params := definitions.ExportQueryParams{ + Format: format, + Download: c.QueryBoolWithDefault("download", false), + } + + return params +} + +func exportResponse(c *contextmodel.ReqContext, body definitions.AlertingFileExport) response.Response { + params := extractExportRequest(c) + if params.Download { r := response.JSONDownload - if format == "yaml" { + if params.Format == "yaml" { r = response.YAMLDownload } - return r(http.StatusOK, body, fmt.Sprintf("export.%s", format)) + return r(http.StatusOK, body, fmt.Sprintf("export.%s", params.Format)) } r := response.JSON - if format == "yaml" { + if params.Format == "yaml" { r = response.YAML } return r(http.StatusOK, body) diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index a59771afcd4..20e49742b66 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -23,8 +23,10 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "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/provisioning" "github.com/grafana/grafana/pkg/services/ngalert/store" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/secrets" secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes" "github.com/grafana/grafana/pkg/services/user" @@ -316,7 +318,7 @@ func TestProvisioningApi(t *testing.T) { }) t.Run("have reached the rule quota, POST returns 403", func(t *testing.T) { - env := createTestEnv(t) + env := createTestEnv(t, testConfig) quotas := provisioning.MockQuotaChecker{} quotas.EXPECT().LimitExceeded() env.quotas = "as @@ -813,6 +815,230 @@ func TestProvisioningApi(t *testing.T) { }) } +func TestProvisioningApiContactPointExport(t *testing.T) { + t.Run("contact point export", func(t *testing.T) { + t.Run("are present, GET returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + response := sut.RouteGetContactPointsExport(&rc) + + require.Equal(t, 200, response.Status()) + }) + + t.Run("accept header contains yaml, GET returns text yaml", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/yaml") + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "text/yaml", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/json") + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("accept header contains json and yaml, GET returns json", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/json, application/yaml") + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "application/json", rc.Context.Resp.Header().Get("Content-Type")) + }) + + t.Run("query param download=true, GET returns content disposition attachment", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.Context.Req.Form.Set("download", "true") + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Contains(t, rc.Context.Resp.Header().Get("Content-Disposition"), "attachment") + }) + + t.Run("query param download=false, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.Context.Req.Form.Set("download", "false") + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("query param download not set, GET returns empty content disposition", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition")) + }) + + t.Run("decrypt true without admin returns 403", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.Context.Req.Form.Set("decrypt", "true") + + response := sut.RouteGetContactPointsExport(&rc) + + require.Equal(t, 403, response.Status()) + }) + + t.Run("decrypt true with admin returns 200", func(t *testing.T) { + sut := createProvisioningSrvSut(t) + rc := createTestRequestCtx() + + rc.SignedInUser.OrgRole = org.RoleAdmin + rc.Context.Req.Form.Set("decrypt", "true") + + response := sut.RouteGetContactPointsExport(&rc) + response.WriteTo(&rc) + + require.Equal(t, 200, response.Status()) + }) + + t.Run("json body content is as expected", func(t *testing.T) { + expectedRedactedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"[REDACTED]","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"[REDACTED]"},"disableResolveMessage":true}]}]}` + t.Run("decrypt false", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("decrypt", "false") + + response := sut.RouteGetContactPointsExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedRedactedResponse, string(response.Body())) + }) + t.Run("decrypt missing", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/json") + + response := sut.RouteGetContactPointsExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedRedactedResponse, string(response.Body())) + }) + t.Run("decrypt true", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.SignedInUser.OrgRole = org.RoleAdmin + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("decrypt", "true") + + response := sut.RouteGetContactPointsExport(&rc) + + expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"grafana-default-email","receivers":[{"uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b","type":"email","settings":{"addresses":"\u003cexample@email.com\u003e"},"disableResolveMessage":false}]},{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"testpass","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]},{"orgId":1,"name":"pagerduty test","receivers":[{"uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d","type":"pagerduty","settings":{"client":"some client","integrationKey":"some key","severity":"criticalish"},"disableResolveMessage":false}]},{"orgId":1,"name":"slack test","receivers":[{"uid":"cbfd0976-8228-4126-b672-4419f30a9e50","type":"slack","settings":{"text":"title body test","title":"title test","url":"some secure slack webhook"},"disableResolveMessage":true}]}]}` + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + t.Run("name filters response", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/json") + rc.Context.Req.Form.Set("name", "multiple integrations") + + response := sut.RouteGetContactPointsExport(&rc) + + expectedResponse := `{"apiVersion":1,"contactPoints":[{"orgId":1,"name":"multiple integrations","receivers":[{"uid":"c2090fda-f824-4add-b545-5a4d5c2ef082","type":"prometheus-alertmanager","settings":{"basicAuthPassword":"[REDACTED]","basicAuthUser":"test","url":"http://localhost:9093"},"disableResolveMessage":true},{"uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1","type":"discord","settings":{"avatar_url":"some avatar","url":"some url","use_discord_username":true},"disableResolveMessage":false}]}]}` + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + }) + + t.Run("yaml body content is as expected", func(t *testing.T) { + expectedRedactedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: \n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: '[REDACTED]'\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: '[REDACTED]'\n disableResolveMessage: true\n" + t.Run("decrypt false", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/yaml") + rc.Context.Req.Form.Set("decrypt", "false") + + response := sut.RouteGetContactPointsExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedRedactedResponse, string(response.Body())) + }) + t.Run("decrypt missing", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/yaml") + + response := sut.RouteGetContactPointsExport(&rc) + + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedRedactedResponse, string(response.Body())) + }) + t.Run("decrypt true", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.SignedInUser.OrgRole = org.RoleAdmin + rc.Context.Req.Header.Add("Accept", "application/yaml") + rc.Context.Req.Form.Set("decrypt", "true") + + response := sut.RouteGetContactPointsExport(&rc) + + expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: grafana-default-email\n receivers:\n - uid: ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b\n type: email\n settings:\n addresses: \n disableResolveMessage: false\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: testpass\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n - orgId: 1\n name: pagerduty test\n receivers:\n - uid: b9bf06f8-bde2-4438-9d4a-bba0522dcd4d\n type: pagerduty\n settings:\n client: some client\n integrationKey: some key\n severity: criticalish\n disableResolveMessage: false\n - orgId: 1\n name: slack test\n receivers:\n - uid: cbfd0976-8228-4126-b672-4419f30a9e50\n type: slack\n settings:\n text: title body test\n title: title test\n url: some secure slack webhook\n disableResolveMessage: true\n" + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + t.Run("name filters response", func(t *testing.T) { + env := createTestEnv(t, testContactPointConfig) + sut := createProvisioningSrvSutFromEnv(t, &env) + rc := createTestRequestCtx() + + rc.Context.Req.Header.Add("Accept", "application/yaml") + rc.Context.Req.Form.Set("name", "multiple integrations") + + response := sut.RouteGetContactPointsExport(&rc) + + expectedResponse := "apiVersion: 1\ncontactPoints:\n - orgId: 1\n name: multiple integrations\n receivers:\n - uid: c2090fda-f824-4add-b545-5a4d5c2ef082\n type: prometheus-alertmanager\n settings:\n basicAuthPassword: '[REDACTED]'\n basicAuthUser: test\n url: http://localhost:9093\n disableResolveMessage: true\n - uid: c84539ec-f87e-4fc5-9a91-7a687d34bbd1\n type: discord\n settings:\n avatar_url: some avatar\n url: some url\n use_discord_username: true\n disableResolveMessage: false\n" + require.Equal(t, 200, response.Status()) + require.Equal(t, expectedResponse, string(response.Body())) + }) + }) + }) +} + // testEnvironment binds together common dependencies for testing alerting APIs. type testEnvironment struct { secrets secrets.Service @@ -825,15 +1051,27 @@ type testEnvironment struct { prov provisioning.ProvisioningStore } -func createTestEnv(t *testing.T) testEnvironment { +func createTestEnv(t *testing.T, testConfig string) testEnvironment { t.Helper() - secrets := secrets_fakes.NewFakeSecretsService() + secretsService := secrets_fakes.NewFakeSecretsService() + + // Encrypt secure settings. + c, err := notifier.Load([]byte(testConfig)) + require.NoError(t, err) + err = c.EncryptConfig(func(ctx context.Context, payload []byte) ([]byte, error) { + return secretsService.Encrypt(ctx, payload, secrets.WithoutScope()) + }) + require.NoError(t, err) + + raw, err := json.Marshal(c) + require.NoError(t, err) + log := log.NewNopLogger() configs := &provisioning.MockAMConfigStore{} configs.EXPECT(). GetsConfig(models.AlertConfiguration{ - AlertmanagerConfiguration: testConfig, + AlertmanagerConfiguration: string(raw), }) sqlStore := db.InitTestDB(t) store := store.DBstore{ @@ -864,7 +1102,7 @@ func createTestEnv(t *testing.T) testEnvironment { }}, nil).Maybe() return testEnvironment{ - secrets: secrets, + secrets: secretsService, log: log, configs: configs, store: store, @@ -878,7 +1116,7 @@ func createTestEnv(t *testing.T) testEnvironment { func createProvisioningSrvSut(t *testing.T) ProvisioningSrv { t.Helper() - env := createTestEnv(t) + env := createTestEnv(t, testConfig) return createProvisioningSrvSutFromEnv(t, &env) } @@ -1133,3 +1371,99 @@ var testConfig = ` } } ` + +var testContactPointConfig = ` +{ + "template_files": { + "a": "template" + }, + "alertmanager_config": { + "route": { + "receiver": "grafana-default-email" + }, + "receivers": [ + { + "name":"grafana-default-email", + "grafana_managed_receiver_configs":[ + { + "uid":"ad95bd8a-49ed-4adc-bf89-1b444fa1aa5b", + "name":"grafana-default-email", + "type":"email", + "disableResolveMessage":false, + "settings":{ + "addresses":"" + }, + "secureSettings":{} + } + ] + }, + { + "name":"multiple integrations", + "grafana_managed_receiver_configs":[ + { + "uid":"c2090fda-f824-4add-b545-5a4d5c2ef082", + "name":"multiple integrations", + "type":"prometheus-alertmanager", + "disableResolveMessage":true, + "settings":{ + "basicAuthUser":"test", + "url":"http://localhost:9093" + }, + "secureSettings":{ + "basicAuthPassword":"testpass" + } + }, + { + "uid":"c84539ec-f87e-4fc5-9a91-7a687d34bbd1", + "name":"multiple integrations", + "type":"discord", + "disableResolveMessage":false, + "settings":{ + "avatar_url":"some avatar", + "url":"some url", + "use_discord_username":true + }, + "secureSettings":{} + } + ] + }, + { + "name":"pagerduty test", + "grafana_managed_receiver_configs":[ + { + "uid":"b9bf06f8-bde2-4438-9d4a-bba0522dcd4d", + "name":"pagerduty test", + "type":"pagerduty", + "disableResolveMessage":false, + "settings":{ + "client":"some client", + "severity":"criticalish" + }, + "secureSettings":{ + "integrationKey":"some key" + } + } + ] + }, + { + "name":"slack test", + "grafana_managed_receiver_configs":[ + { + "uid":"cbfd0976-8228-4126-b672-4419f30a9e50", + "name":"slack test", + "type":"slack", + "disableResolveMessage":true, + "settings":{ + "text":"title body test", + "title":"title test" + }, + "secureSettings":{ + "url":"some secure slack webhook" + } + } + ] + } +] + } +} +` diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 513107e4f30..e42b7f493f6 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -186,6 +186,7 @@ func (api *API) authorize(method, path string) web.Handler { // Grafana-only Provisioning Read Paths case http.MethodGet + "/api/v1/provisioning/policies", http.MethodGet + "/api/v1/provisioning/contact-points", + http.MethodGet + "/api/v1/provisioning/contact-points/export", http.MethodGet + "/api/v1/provisioning/templates", http.MethodGet + "/api/v1/provisioning/templates/{name}", http.MethodGet + "/api/v1/provisioning/mute-timings", diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 05848122d13..03a064105ea 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -49,7 +49,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 48) + require.Len(t, paths, 49) ac := acmock.New() api := &API{AccessControl: ac} diff --git a/pkg/services/ngalert/api/compat.go b/pkg/services/ngalert/api/compat.go index 546695c0b37..723021306f0 100644 --- a/pkg/services/ngalert/api/compat.go +++ b/pkg/services/ngalert/api/compat.go @@ -215,3 +215,48 @@ func AlertQueryExportFromAlertQuery(query models.AlertQuery) (definitions.AlertQ Model: mdl, }, nil } + +// AlertingFileExportFromEmbeddedContactPoints creates a definitions.AlertingFileExport DTO from []definitions.EmbeddedContactPoint. +func AlertingFileExportFromEmbeddedContactPoints(orgID int64, ecps []definitions.EmbeddedContactPoint) (definitions.AlertingFileExport, error) { + f := definitions.AlertingFileExport{APIVersion: 1} + + cache := make(map[string]*definitions.ContactPointExport) + contactPoints := make([]*definitions.ContactPointExport, 0) + for _, ecp := range ecps { + c, ok := cache[ecp.Name] + if !ok { + c = &definitions.ContactPointExport{ + OrgID: orgID, + Name: ecp.Name, + Receivers: make([]definitions.ReceiverExport, 0), + } + cache[ecp.Name] = c + contactPoints = append(contactPoints, c) + } + + recv, err := ReceiverExportFromEmbeddedContactPoint(ecp) + if err != nil { + return definitions.AlertingFileExport{}, err + } + c.Receivers = append(c.Receivers, recv) + } + + for _, c := range contactPoints { + f.ContactPoints = append(f.ContactPoints, *c) + } + return f, nil +} + +// ReceiverExportFromEmbeddedContactPoint creates a definitions.ReceiverExport DTO from definitions.EmbeddedContactPoint. +func ReceiverExportFromEmbeddedContactPoint(contact definitions.EmbeddedContactPoint) (definitions.ReceiverExport, error) { + raw, err := contact.Settings.MarshalJSON() + if err != nil { + return definitions.ReceiverExport{}, err + } + return definitions.ReceiverExport{ + UID: contact.UID, + Type: contact.Type, + Settings: raw, + DisableResolveMessage: contact.DisableResolveMessage, + }, nil +} diff --git a/pkg/services/ngalert/api/generated_base_api_provisioning.go b/pkg/services/ngalert/api/generated_base_api_provisioning.go index 6921fbc2007..eb3486f7a1e 100644 --- a/pkg/services/ngalert/api/generated_base_api_provisioning.go +++ b/pkg/services/ngalert/api/generated_base_api_provisioning.go @@ -30,6 +30,7 @@ type ProvisioningApi interface { RouteGetAlertRules(*contextmodel.ReqContext) response.Response RouteGetAlertRulesExport(*contextmodel.ReqContext) response.Response RouteGetContactpoints(*contextmodel.ReqContext) response.Response + RouteGetContactpointsExport(*contextmodel.ReqContext) response.Response RouteGetMuteTiming(*contextmodel.ReqContext) response.Response RouteGetMuteTimings(*contextmodel.ReqContext) response.Response RouteGetPolicyTree(*contextmodel.ReqContext) response.Response @@ -98,6 +99,9 @@ func (f *ProvisioningApiHandler) RouteGetAlertRulesExport(ctx *contextmodel.ReqC func (f *ProvisioningApiHandler) RouteGetContactpoints(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteGetContactpoints(ctx) } +func (f *ProvisioningApiHandler) RouteGetContactpointsExport(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteGetContactpointsExport(ctx) +} func (f *ProvisioningApiHandler) RouteGetMuteTiming(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters nameParam := web.Params(ctx.Req)[":name"] @@ -316,6 +320,16 @@ func (api *API) RegisterProvisioningApiEndpoints(srv ProvisioningApi, m *metrics m, ), ) + group.Get( + toMacaronPath("/api/v1/provisioning/contact-points/export"), + api.authorize(http.MethodGet, "/api/v1/provisioning/contact-points/export"), + metrics.Instrument( + http.MethodGet, + "/api/v1/provisioning/contact-points/export", + api.Hooks.Wrap(srv.RouteGetContactpointsExport), + m, + ), + ) group.Get( toMacaronPath("/api/v1/provisioning/mute-timings/{name}"), api.authorize(http.MethodGet, "/api/v1/provisioning/mute-timings/{name}"), diff --git a/pkg/services/ngalert/api/provisioning.go b/pkg/services/ngalert/api/provisioning.go index d26b6bb5e75..5e01df4a005 100644 --- a/pkg/services/ngalert/api/provisioning.go +++ b/pkg/services/ngalert/api/provisioning.go @@ -28,6 +28,10 @@ func (f *ProvisioningApiHandler) handleRouteGetContactpoints(ctx *contextmodel.R return f.svc.RouteGetContactPoints(ctx) } +func (f *ProvisioningApiHandler) handleRouteGetContactpointsExport(ctx *contextmodel.ReqContext) response.Response { + return f.svc.RouteGetContactPointsExport(ctx) +} + func (f *ProvisioningApiHandler) handleRoutePostContactpoints(ctx *contextmodel.ReqContext, cp apimodels.EmbeddedContactPoint) response.Response { return f.svc.RoutePostContactPoint(ctx, cp) } diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 0e7557841cd..1cbc8306664 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -286,6 +286,12 @@ "format": "int64", "type": "integer" }, + "contactPoints": { + "items": { + "$ref": "#/definitions/ContactPointExport" + }, + "type": "array" + }, "groups": { "items": { "$ref": "#/definitions/AlertRuleGroupExport" @@ -537,6 +543,25 @@ "title": "Config is the top-level configuration for Alertmanager's config files.", "type": "object" }, + "ContactPointExport": { + "properties": { + "name": { + "type": "string" + }, + "orgId": { + "format": "int64", + "type": "integer" + }, + "receivers": { + "items": { + "$ref": "#/definitions/ReceiverExport" + }, + "type": "array" + } + }, + "title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.", + "type": "object" + }, "ContactPoints": { "items": { "$ref": "#/definitions/EmbeddedContactPoint" @@ -2851,6 +2876,24 @@ "title": "Receiver configuration provides configuration on how to contact a receiver.", "type": "object" }, + "ReceiverExport": { + "properties": { + "disableResolveMessage": { + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string" + }, + "uid": { + "type": "string" + } + }, + "title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.", + "type": "object" + }, "Regexp": { "description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.", "title": "Regexp is the representation of a compiled regular expression.", @@ -3681,6 +3724,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -3716,7 +3760,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "Userinfo": { @@ -3896,6 +3940,7 @@ "type": "object" }, "alertGroup": { + "description": "AlertGroup alert group", "properties": { "alerts": { "description": "alerts", @@ -3919,7 +3964,6 @@ "type": "object" }, "alertGroups": { - "description": "AlertGroups alert groups", "items": { "$ref": "#/definitions/alertGroup" }, @@ -4079,13 +4123,13 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert" }, "type": "array" }, "gettableSilence": { - "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -4141,6 +4185,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", @@ -4284,7 +4329,6 @@ "type": "array" }, "postableSilence": { - "description": "PostableSilence postable silence", "properties": { "comment": { "description": "comment", @@ -4322,6 +4366,7 @@ "type": "object" }, "receiver": { + "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -4631,13 +4676,6 @@ "get": { "operationId": "RouteGetAlertRuleExport", "parameters": [ - { - "description": "Alert rule UID", - "in": "path", - "name": "UID", - "required": true, - "type": "string" - }, { "default": false, "description": "Whether to initiate a download of the file or not.", @@ -4651,6 +4689,13 @@ "in": "query", "name": "format", "type": "string" + }, + { + "description": "Alert rule UID", + "in": "path", + "name": "UID", + "required": true, + "type": "string" } ], "produces": [ @@ -4733,6 +4778,58 @@ ] } }, + "/api/v1/provisioning/contact-points/export": { + "get": { + "operationId": "RouteGetContactpointsExport", + "parameters": [ + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + }, + { + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "in": "query", + "name": "format", + "type": "string" + }, + { + "default": false, + "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", + "in": "query", + "name": "decrypt", + "type": "boolean" + }, + { + "description": "Filter by name", + "in": "query", + "name": "name", + "type": "string" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + }, + "summary": "Export all contact points in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/contact-points/{UID}": { "delete": { "consumes": [ @@ -4882,18 +4979,6 @@ "get": { "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ - { - "in": "path", - "name": "FolderUID", - "required": true, - "type": "string" - }, - { - "in": "path", - "name": "Group", - "required": true, - "type": "string" - }, { "default": false, "description": "Whether to initiate a download of the file or not.", @@ -4907,6 +4992,18 @@ "in": "query", "name": "format", "type": "string" + }, + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" } ], "produces": [ diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning.go b/pkg/services/ngalert/api/tooling/definitions/provisioning.go new file mode 100644 index 00000000000..666ad6f924a --- /dev/null +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning.go @@ -0,0 +1,33 @@ +package definitions + +// AlertingFileExport is the full provisioned file export. +// swagger:model +type AlertingFileExport struct { + APIVersion int64 `json:"apiVersion" yaml:"apiVersion"` + Groups []AlertRuleGroupExport `json:"groups,omitempty" yaml:"groups,omitempty"` + ContactPoints []ContactPointExport `json:"contactPoints,omitempty" yaml:"contactPoints,omitempty"` +} + +// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetAlertRulesExport RouteGetContactpointsExport RouteGetContactpointExport +type ExportQueryParams struct { + // Whether to initiate a download of the file or not. + // in: query + // required: false + // default: false + Download bool `json:"download"` + + // Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. + // in: query + // required: false + // default: yaml + Format string `json:"format"` +} + +// swagger:parameters RouteGetContactpointsExport RouteGetContactpointExport +type DecryptQueryParams struct { + // Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings. + // in: query + // required: false + // default: false + Decrypt bool `json:"decrypt"` +} diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go index ce9a42f7a69..76c7798e9fe 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_alert_rules.go @@ -190,21 +190,6 @@ type AlertRuleGroupMetadata struct { Interval int64 `json:"interval"` } -// swagger:parameters RouteGetAlertRuleGroupExport RouteGetAlertRuleExport RouteGetAlertRulesExport -type ExportQueryParams struct { - // Whether to initiate a download of the file or not. - // in: query - // required: false - // default: false - Download bool `json:"download"` - - // Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence. - // in: query - // required: false - // default: yaml - Format string `json:"format"` -} - // swagger:model type AlertRuleGroup struct { Title string `json:"title"` @@ -213,13 +198,6 @@ type AlertRuleGroup struct { Rules []ProvisionedAlertRule `json:"rules"` } -// AlertingFileExport is the full provisioned file export. -// swagger:model -type AlertingFileExport struct { - APIVersion int64 `json:"apiVersion" yaml:"apiVersion"` - Groups []AlertRuleGroupExport `json:"groups" yaml:"groups"` -} - // AlertRuleGroupExport is the provisioned file export of AlertRuleGroupV1. type AlertRuleGroupExport struct { OrgID int64 `json:"orgId" yaml:"orgId"` diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go index 12e1ef21814..dac846ca7ef 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_contactpoints.go @@ -11,6 +11,14 @@ import ( // Responses: // 200: ContactPoints +// swagger:route GET /api/v1/provisioning/contact-points/export provisioning stable RouteGetContactpointsExport +// +// Export all contact points in provisioning file format. +// +// Responses: +// 200: AlertingFileExport +// 403: PermissionDenied + // swagger:route POST /api/v1/provisioning/contact-points provisioning stable RoutePostContactpoints // // Create a contact point. @@ -43,14 +51,14 @@ import ( // Responses: // 204: description: The contact point was deleted successfully. -// swagger:parameters RoutePutContactpoint RouteDeleteContactpoints +// swagger:parameters RoutePutContactpoint RouteDeleteContactpoints RouteGetContactpoint RouteGetContactpointExport type ContactPointUIDReference struct { // UID is the contact point unique identifier // in:path UID string } -// swagger:parameters RouteGetContactpoints +// swagger:parameters RouteGetContactpoints RouteGetContactpointsExport type ContactPointParams struct { // Filter by name // in: query @@ -91,6 +99,21 @@ type EmbeddedContactPoint struct { Provenance string `json:"provenance,omitempty"` } +// ContactPointExport is the provisioned file export of alerting.ContactPointV1. +type ContactPointExport struct { + OrgID int64 `json:"orgId" yaml:"orgId"` + Name string `json:"name" yaml:"name"` + Receivers []ReceiverExport `json:"receivers" yaml:"receivers"` +} + +// ReceiverExport is the provisioned file export of alerting.ReceiverV1. +type ReceiverExport struct { + UID string `json:"uid" yaml:"uid"` + Type string `json:"type" yaml:"type"` + Settings RawMessage `json:"settings" yaml:"settings"` + DisableResolveMessage bool `json:"disableResolveMessage" yaml:"disableResolveMessage"` +} + const RedactedValue = "[REDACTED]" func (e *EmbeddedContactPoint) ResourceID() string { diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 478d010a74c..47fa7bd1552 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -286,6 +286,12 @@ "format": "int64", "type": "integer" }, + "contactPoints": { + "items": { + "$ref": "#/definitions/ContactPointExport" + }, + "type": "array" + }, "groups": { "items": { "$ref": "#/definitions/AlertRuleGroupExport" @@ -537,6 +543,25 @@ "title": "Config is the top-level configuration for Alertmanager's config files.", "type": "object" }, + "ContactPointExport": { + "properties": { + "name": { + "type": "string" + }, + "orgId": { + "format": "int64", + "type": "integer" + }, + "receivers": { + "items": { + "$ref": "#/definitions/ReceiverExport" + }, + "type": "array" + } + }, + "title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.", + "type": "object" + }, "ContactPoints": { "items": { "$ref": "#/definitions/EmbeddedContactPoint" @@ -2851,6 +2876,24 @@ "title": "Receiver configuration provides configuration on how to contact a receiver.", "type": "object" }, + "ReceiverExport": { + "properties": { + "disableResolveMessage": { + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string" + }, + "uid": { + "type": "string" + } + }, + "title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.", + "type": "object" + }, "Regexp": { "description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.", "title": "Regexp is the representation of a compiled regular expression.", @@ -3681,7 +3724,7 @@ "type": "object" }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -4142,7 +4185,6 @@ "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", @@ -4324,7 +4366,6 @@ "type": "object" }, "receiver": { - "description": "Receiver receiver", "properties": { "active": { "description": "active", @@ -6415,13 +6456,6 @@ "get": { "operationId": "RouteGetAlertRuleExport", "parameters": [ - { - "description": "Alert rule UID", - "in": "path", - "name": "UID", - "required": true, - "type": "string" - }, { "default": false, "description": "Whether to initiate a download of the file or not.", @@ -6435,6 +6469,13 @@ "in": "query", "name": "format", "type": "string" + }, + { + "description": "Alert rule UID", + "in": "path", + "name": "UID", + "required": true, + "type": "string" } ], "produces": [ @@ -6517,6 +6558,58 @@ ] } }, + "/api/v1/provisioning/contact-points/export": { + "get": { + "operationId": "RouteGetContactpointsExport", + "parameters": [ + { + "default": false, + "description": "Whether to initiate a download of the file or not.", + "in": "query", + "name": "download", + "type": "boolean" + }, + { + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "in": "query", + "name": "format", + "type": "string" + }, + { + "default": false, + "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", + "in": "query", + "name": "decrypt", + "type": "boolean" + }, + { + "description": "Filter by name", + "in": "query", + "name": "name", + "type": "string" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + }, + "summary": "Export all contact points in provisioning file format.", + "tags": [ + "provisioning" + ] + } + }, "/api/v1/provisioning/contact-points/{UID}": { "delete": { "consumes": [ @@ -6666,18 +6759,6 @@ "get": { "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ - { - "in": "path", - "name": "FolderUID", - "required": true, - "type": "string" - }, - { - "in": "path", - "name": "Group", - "required": true, - "type": "string" - }, { "default": false, "description": "Whether to initiate a download of the file or not.", @@ -6691,6 +6772,18 @@ "in": "query", "name": "format", "type": "string" + }, + { + "in": "path", + "name": "FolderUID", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "Group", + "required": true, + "type": "string" } ], "produces": [ diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 400387ec389..2bc338806a3 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -2004,13 +2004,6 @@ "summary": "Export an alert rule in provisioning file format.", "operationId": "RouteGetAlertRuleExport", "parameters": [ - { - "type": "string", - "description": "Alert rule UID", - "name": "UID", - "in": "path", - "required": true - }, { "type": "boolean", "default": false, @@ -2024,6 +2017,13 @@ "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", "name": "format", "in": "query" + }, + { + "type": "string", + "description": "Alert rule UID", + "name": "UID", + "in": "path", + "required": true } ], "responses": { @@ -2099,6 +2099,59 @@ } } }, + "/api/v1/provisioning/contact-points/export": { + "get": { + "tags": [ + "provisioning", + "stable" + ], + "summary": "Export all contact points in provisioning file format.", + "operationId": "RouteGetContactpointsExport", + "parameters": [ + { + "type": "boolean", + "default": false, + "description": "Whether to initiate a download of the file or not.", + "name": "download", + "in": "query" + }, + { + "type": "string", + "default": "yaml", + "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", + "name": "format", + "in": "query" + }, + { + "type": "boolean", + "default": false, + "description": "Whether any contained secure settings should be decrypted or left redacted. Redacted settings will contain RedactedValue instead. Currently, only org admin can view decrypted secure settings.", + "name": "decrypt", + "in": "query" + }, + { + "type": "string", + "description": "Filter by name", + "name": "name", + "in": "query" + } + ], + "responses": { + "200": { + "description": "AlertingFileExport", + "schema": { + "$ref": "#/definitions/AlertingFileExport" + } + }, + "403": { + "description": "PermissionDenied", + "schema": { + "$ref": "#/definitions/PermissionDenied" + } + } + } + } + }, "/api/v1/provisioning/contact-points/{UID}": { "put": { "consumes": [ @@ -2262,18 +2315,6 @@ "summary": "Export an alert rule group in provisioning file format.", "operationId": "RouteGetAlertRuleGroupExport", "parameters": [ - { - "type": "string", - "name": "FolderUID", - "in": "path", - "required": true - }, - { - "type": "string", - "name": "Group", - "in": "path", - "required": true - }, { "type": "boolean", "default": false, @@ -2287,6 +2328,18 @@ "description": "Format of the downloaded file, either yaml or json. Accept header can also be used, but the query parameter will take precedence.", "name": "format", "in": "query" + }, + { + "type": "string", + "name": "FolderUID", + "in": "path", + "required": true + }, + { + "type": "string", + "name": "Group", + "in": "path", + "required": true } ], "responses": { @@ -3054,6 +3107,12 @@ "type": "integer", "format": "int64" }, + "contactPoints": { + "type": "array", + "items": { + "$ref": "#/definitions/ContactPointExport" + } + }, "groups": { "type": "array", "items": { @@ -3303,6 +3362,25 @@ } } }, + "ContactPointExport": { + "type": "object", + "title": "ContactPointExport is the provisioned file export of alerting.ContactPointV1.", + "properties": { + "name": { + "type": "string" + }, + "orgId": { + "type": "integer", + "format": "int64" + }, + "receivers": { + "type": "array", + "items": { + "$ref": "#/definitions/ReceiverExport" + } + } + } + }, "ContactPoints": { "type": "array", "items": { @@ -5621,6 +5699,24 @@ } } }, + "ReceiverExport": { + "type": "object", + "title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.", + "properties": { + "disableResolveMessage": { + "type": "boolean" + }, + "settings": { + "$ref": "#/definitions/Json" + }, + "type": { + "type": "string" + }, + "uid": { + "type": "string" + } + } + }, "Regexp": { "description": "A Regexp is safe for concurrent use by multiple goroutines,\nexcept for configuration methods, such as Longest.", "type": "object", @@ -6451,7 +6547,7 @@ } }, "URL": { - "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.", + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the EscapedPath method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "type": "object", "title": "A URL represents a parsed URL (technically, a URI reference).", "properties": { @@ -6918,7 +7014,6 @@ "$ref": "#/definitions/gettableSilences" }, "integration": { - "description": "Integration integration", "type": "object", "required": [ "name", @@ -7102,7 +7197,6 @@ "$ref": "#/definitions/postableSilence" }, "receiver": { - "description": "Receiver receiver", "type": "object", "required": [ "active", diff --git a/pkg/services/ngalert/provisioning/contactpoints.go b/pkg/services/ngalert/provisioning/contactpoints.go index 13183c298f8..d083a410ff5 100644 --- a/pkg/services/ngalert/provisioning/contactpoints.go +++ b/pkg/services/ngalert/provisioning/contactpoints.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" alertingNotify "github.com/grafana/alerting/notify" "github.com/prometheus/alertmanager/config" @@ -15,7 +16,9 @@ import ( apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config" + "github.com/grafana/grafana/pkg/services/org" "github.com/grafana/grafana/pkg/services/secrets" + "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/util" ) @@ -42,9 +45,15 @@ type ContactPointQuery struct { // Optionally filter by name. Name string OrgID int64 + // Optionally decrypt secure settings, requires OrgAdmin. + Decrypt bool } -func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery) ([]apimodels.EmbeddedContactPoint, error) { +// GetContactPoints returns contact points. If q.Decrypt is true and the user is an OrgAdmin, decrypted secure settings are included instead of redacted ones. +func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery, u *user.SignedInUser) ([]apimodels.EmbeddedContactPoint, error) { + if q.Decrypt && (u == nil || !u.HasRole(org.RoleAdmin)) { + return nil, fmt.Errorf("%w: decrypting secure settings requires Org Admin", ErrPermissionDenied) + } revision, err := getLastConfiguration(ctx, q.OrgID, ecp.amStore) if err != nil { return nil, err @@ -82,13 +91,23 @@ func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactP if decryptedValue == "" { continue } - embeddedContactPoint.Settings.Set(k, apimodels.RedactedValue) + if q.Decrypt { + embeddedContactPoint.Settings.Set(k, decryptedValue) + } else { + 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 + switch strings.Compare(contactPoints[i].Name, contactPoints[j].Name) { + case -1: + return true + case 1: + return false + } + return contactPoints[i].UID < contactPoints[j].UID }) return contactPoints, nil } diff --git a/pkg/services/ngalert/provisioning/contactpoints_test.go b/pkg/services/ngalert/provisioning/contactpoints_test.go index 5b775ca5473..5c7774910fe 100644 --- a/pkg/services/ngalert/provisioning/contactpoints_test.go +++ b/pkg/services/ngalert/provisioning/contactpoints_test.go @@ -2,6 +2,7 @@ package provisioning import ( "context" + "encoding/json" "fmt" "testing" @@ -13,49 +14,51 @@ import ( "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/org" "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/user" ) func TestContactPointService(t *testing.T) { sqlStore := db.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) + sut := createContactPointServiceSut(t, secretsService) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1)) + cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) require.Len(t, cps, 1) - require.Equal(t, "email receiver", cps[0].Name) + require.Equal(t, "slack receiver", cps[0].Name) }) t.Run("service filters contact points by name", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) q := ContactPointQuery{ OrgID: 1, - Name: "email receiver", + Name: "slack receiver", } - cps, err := sut.GetContactPoints(context.Background(), q) + cps, err := sut.GetContactPoints(context.Background(), q, nil) require.NoError(t, err) require.Len(t, cps, 1) - require.Equal(t, "email receiver", cps[0].Name) + require.Equal(t, "slack receiver", cps[0].Name) }) t.Run("service stitches contact point into org's AM config", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1)) + cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) require.Len(t, cps, 2) require.Equal(t, "test-contact-point", cps[1].Name) @@ -64,14 +67,14 @@ func TestContactPointService(t *testing.T) { t.Run("it's possible to use a custom uid", func(t *testing.T) { customUID := "1337" - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp.UID = customUID _, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1)) + cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) require.Len(t, cps, 2) require.Equal(t, customUID, cps[1].UID) @@ -79,7 +82,7 @@ func TestContactPointService(t *testing.T) { t.Run("it's not possible to use the same uid twice", func(t *testing.T) { customUID := "1337" - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp.UID = customUID @@ -91,7 +94,7 @@ func TestContactPointService(t *testing.T) { }) t.Run("create rejects contact points that fail validation", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp.Type = "" @@ -101,7 +104,7 @@ func TestContactPointService(t *testing.T) { }) t.Run("update rejects contact points with no settings", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) @@ -113,7 +116,7 @@ func TestContactPointService(t *testing.T) { }) t.Run("update rejects contact points with no type", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) @@ -125,7 +128,7 @@ func TestContactPointService(t *testing.T) { }) t.Run("update rejects contact points which fail validation after merging", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, models.ProvenanceAPI) require.NoError(t, err) @@ -137,9 +140,9 @@ func TestContactPointService(t *testing.T) { }) t.Run("default provenance of contact points is none", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1)) + cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) require.Equal(t, models.ProvenanceNone, models.Provenance(cps[0].Provenance)) @@ -191,13 +194,13 @@ func TestContactPointService(t *testing.T) { } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() newCp, err := sut.CreateContactPoint(context.Background(), 1, newCp, test.from) require.NoError(t, err) - cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1)) + cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) require.Equal(t, newCp.UID, cps[1].UID) require.Equal(t, test.from, models.Provenance(cps[1].Provenance)) @@ -206,7 +209,7 @@ func TestContactPointService(t *testing.T) { if test.errNil { require.NoError(t, err) - cps, err = sut.GetContactPoints(context.Background(), cpsQuery(1)) + cps, err = sut.GetContactPoints(context.Background(), cpsQuery(1), nil) require.NoError(t, err) require.Equal(t, newCp.UID, cps[1].UID) require.Equal(t, test.to, models.Provenance(cps[1].Provenance)) @@ -218,7 +221,7 @@ func TestContactPointService(t *testing.T) { }) t.Run("service respects concurrency token when updating", func(t *testing.T) { - sut := createContactPointServiceSut(secretsService) + sut := createContactPointServiceSut(t, secretsService) newCp := createTestContactPoint() q := models.GetLatestAlertmanagerConfigurationQuery{ OrgID: 1, @@ -236,6 +239,41 @@ func TestContactPointService(t *testing.T) { }) } +func TestContactPointServiceDecryptRedact(t *testing.T) { + sqlStore := db.InitTestDB(t) + secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore)) + t.Run("GetContactPoints gets redacted contact points by default", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + cps, err := sut.GetContactPoints(context.Background(), cpsQuery(1), nil) + require.NoError(t, err) + + require.Len(t, cps, 1) + require.Equal(t, "slack receiver", cps[0].Name) + require.Equal(t, definitions.RedactedValue, cps[0].Settings.Get("url").MustString()) + }) + t.Run("GetContactPoints errors when Decrypt = true and user not Org Admin", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + q := cpsQuery(1) + q.Decrypt = true + _, err := sut.GetContactPoints(context.Background(), q, nil) + require.ErrorIs(t, err, ErrPermissionDenied) + }) + t.Run("GetContactPoints gets decrypted contact points when Decrypt = true and user is Org Admin", func(t *testing.T) { + sut := createContactPointServiceSut(t, secretsService) + + q := cpsQuery(1) + q.Decrypt = true + cps, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{OrgID: 1, OrgRole: org.RoleAdmin}) + require.NoError(t, err) + + require.Len(t, cps, 1) + require.Equal(t, "slack receiver", cps[0].Name) + require.Equal(t, "secure url", cps[0].Settings.Get("url").MustString()) + }) +} + func TestContactPointInUse(t *testing.T) { result := isContactPointInUse("test", []*definitions.Route{ { @@ -267,9 +305,21 @@ func TestContactPointInUse(t *testing.T) { require.False(t, result) } -func createContactPointServiceSut(secretService secrets.Service) *ContactPointService { +func createContactPointServiceSut(t *testing.T, secretService secrets.Service) *ContactPointService { + // Encrypt secure settings. + c := &definitions.PostableUserConfig{} + err := json.Unmarshal([]byte(defaultAlertmanagerConfigJSON), c) + require.NoError(t, err) + err = c.EncryptConfig(func(ctx context.Context, payload []byte) ([]byte, error) { + return secretService.Encrypt(ctx, payload, secrets.WithoutScope()) + }) + require.NoError(t, err) + + raw, err := json.Marshal(c) + require.NoError(t, err) + return &ContactPointService{ - amStore: newFakeAMConfigStore(), + amStore: newFakeAMConfigStore(string(raw)), provenanceStore: NewFakeProvisioningStore(), xact: newNopTransactionManager(), encryptionService: secretService, diff --git a/pkg/services/ngalert/provisioning/errors.go b/pkg/services/ngalert/provisioning/errors.go index ed1ed372671..1ca9f708dd4 100644 --- a/pkg/services/ngalert/provisioning/errors.go +++ b/pkg/services/ngalert/provisioning/errors.go @@ -1,6 +1,10 @@ package provisioning -import "fmt" +import ( + "errors" + "fmt" +) var ErrValidation = fmt.Errorf("invalid object specification") var ErrNotFound = fmt.Errorf("object not found") +var ErrPermissionDenied = errors.New("permission denied") diff --git a/pkg/services/ngalert/provisioning/notification_policies_test.go b/pkg/services/ngalert/provisioning/notification_policies_test.go index c84a61a27dd..7fbb6ea1699 100644 --- a/pkg/services/ngalert/provisioning/notification_policies_test.go +++ b/pkg/services/ngalert/provisioning/notification_policies_test.go @@ -219,7 +219,7 @@ func TestNotificationPolicyService(t *testing.T) { func createNotificationPolicyServiceSut() *NotificationPolicyService { return &NotificationPolicyService{ - amStore: newFakeAMConfigStore(), + amStore: newFakeAMConfigStore(defaultAlertmanagerConfigJSON), provenanceStore: NewFakeProvisioningStore(), xact: newNopTransactionManager(), log: log.NewNopLogger(), diff --git a/pkg/services/ngalert/provisioning/testing.go b/pkg/services/ngalert/provisioning/testing.go index 8e388425bd7..62eef7a3196 100644 --- a/pkg/services/ngalert/provisioning/testing.go +++ b/pkg/services/ngalert/provisioning/testing.go @@ -42,13 +42,11 @@ const defaultAlertmanagerConfigJSON = ` "name": "a new receiver", "grafana_managed_receiver_configs": [{ "uid": "", - "name": "email receiver", - "type": "email", + "name": "slack receiver", + "type": "slack", "disableResolveMessage": false, - "settings": { - "addresses": "\u003canother@email.com\u003e" - }, - "secureFields": {} + "settings": {}, + "secureSettings": {"url":"secure url"} }] }] } @@ -60,10 +58,10 @@ type fakeAMConfigStore struct { lastSaveCommand *models.SaveAlertmanagerConfigurationCmd } -func newFakeAMConfigStore() *fakeAMConfigStore { +func newFakeAMConfigStore(config string) *fakeAMConfigStore { return &fakeAMConfigStore{ config: models.AlertConfiguration{ - AlertmanagerConfiguration: defaultAlertmanagerConfigJSON, + AlertmanagerConfiguration: config, ConfigurationVersion: "v1", Default: true, OrgID: 1, @@ -168,6 +166,7 @@ func (m *MockAMConfigStore_Expecter) SaveSucceedsIntercept(intercepted *models.S func (m *MockProvisioningStore_Expecter) GetReturns(p models.Provenance) *MockProvisioningStore_Expecter { m.GetProvenance(mock.Anything, mock.Anything, mock.Anything).Return(p, nil) + m.GetProvenances(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil) return m } diff --git a/pkg/services/provisioning/alerting/contact_point_provisioner.go b/pkg/services/provisioning/alerting/contact_point_provisioner.go index 00d7bca57cb..51faafd20e6 100644 --- a/pkg/services/provisioning/alerting/contact_point_provisioner.go +++ b/pkg/services/provisioning/alerting/contact_point_provisioner.go @@ -37,7 +37,7 @@ func (c *defaultContactPointProvisioner) Provision(ctx context.Context, if _, exists := cpsCache[contactPointsConfig.OrgID]; !exists { cps, err := c.contactPointService.GetContactPoints(ctx, provisioning.ContactPointQuery{ OrgID: contactPointsConfig.OrgID, - }) + }, nil) if err != nil { return err } diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx index 5d2e6fca2b3..c0ee2251e07 100644 --- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx +++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.v1.test.tsx @@ -185,6 +185,11 @@ describe('Receivers', () => { return permissions.includes(action as AccessControlAction); }); + // respond with "true" when asked if we are an administrator + mocks.contextSrv.hasRole.mockImplementation((role: string) => { + return role === 'Admin'; + }); + mocks.contextSrv.hasAccess.mockImplementation(() => true); }); diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx index c31ad0c7891..660dfff4c15 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversSection.tsx @@ -4,7 +4,7 @@ import { Link } from 'react-router-dom'; import { GrafanaTheme2 } from '@grafana/data'; import { Stack } from '@grafana/experimental'; -import { Button, useStyles2 } from '@grafana/ui'; +import { Button, Dropdown, Icon, Menu, MenuItem, useStyles2 } from '@grafana/ui'; interface Props { title: string; @@ -13,6 +13,7 @@ interface Props { addButtonTo: string; className?: string; showButton?: boolean; + exportLink?: string; } export const ReceiversSection = ({ @@ -23,8 +24,11 @@ export const ReceiversSection = ({ addButtonTo, children, showButton = true, + exportLink, }: React.PropsWithChildren) => { const styles = useStyles2(getStyles); + const showMore = Boolean(exportLink); + const newMenu = {exportLink && }; return (
@@ -32,13 +36,23 @@ export const ReceiversSection = ({

{title}

{description}
- {showButton && ( - - - - )} + + {showButton && ( + + + + )} + {showMore && ( + + + + )} + {children}
diff --git a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx index e4e1ffdf2e4..7e111fd8dfb 100644 --- a/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx +++ b/public/app/features/alerting/unified/components/receivers/ReceiversTable.tsx @@ -8,6 +8,7 @@ import { contextSrv } from 'app/core/services/context_srv'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction, ContactPointsState, NotifiersState, ReceiversState, useDispatch } from 'app/types'; +import { isOrgAdmin } from '../../../../plugins/admin/permissions'; import { useGetContactPointsState } from '../../api/receiversApi'; import { Authorize } from '../../components/Authorize'; import { useUnifiedAlertingSelector } from '../../hooks/useUnifiedAlertingSelector'; @@ -16,9 +17,10 @@ import { getAlertTableStyles } from '../../styles/table'; import { SupportedPlugin } from '../../types/pluginBridges'; import { getNotificationsPermissions } from '../../utils/access-control'; import { isReceiverUsed } from '../../utils/alertmanager'; -import { isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; +import { GRAFANA_RULES_SOURCE_NAME, isVanillaPrometheusAlertManagerDataSource } from '../../utils/datasource'; import { makeAMLink } from '../../utils/misc'; import { extractNotifierTypeCounts } from '../../utils/receivers'; +import { createUrl } from '../../utils/url'; import { DynamicTable, DynamicTableColumnProps, DynamicTableItemProps } from '../DynamicTable'; import { ProvisioningBadge } from '../Provisioning'; import { ActionIcon } from '../rules/ActionIcon'; @@ -65,6 +67,9 @@ interface ActionProps { create: AccessControlAction; update: AccessControlAction; delete: AccessControlAction; + provisioning: { + read: AccessControlAction; + }; }; alertManagerName: string; receiverName: string; @@ -83,6 +88,25 @@ function ViewAction({ permissions, alertManagerName, receiverName }: ActionProps ); } +function ExportAction({ permissions, receiverName }: ActionProps) { + return ( + + + + ); +} + interface ReceiverErrorProps { errorCount: number; errorDetail?: string; @@ -276,6 +300,9 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => { const [receiverToDelete, setReceiverToDelete] = useState(); const [showCannotDeleteReceiverModal, setShowCannotDeleteReceiverModal] = useState(false); + const isGrafanaAM = alertManagerName === GRAFANA_RULES_SOURCE_NAME; + const showExport = isGrafanaAM && contextSrv.hasAccess(permissions.provisioning.read, isOrgAdmin()); + const onClickDeleteReceiver = (receiverName: string): void => { if (isReceiverUsed(receiverName, config)) { setShowCannotDeleteReceiverModal(true); @@ -330,6 +357,15 @@ export const ReceiversTable = ({ config, alertManagerName }: Props) => { showButton={!isVanillaAM && contextSrv.hasPermission(permissions.create)} addButtonLabel={'Add contact point'} addButtonTo={makeAMLink('/alerting/notifications/receivers/new', alertManagerName)} + exportLink={ + showExport + ? createUrl('/api/v1/provisioning/contact-points/export', { + download: 'true', + format: 'yaml', + decrypt: isOrgAdmin().toString(), + }) + : undefined + } > cp.matchingRoutes === 0); + const isGrafanaAlertManager = alertManagerName === GRAFANA_RULES_SOURCE_NAME; + const baseColumns: RowTableColumnProps[] = [ { id: 'name', @@ -456,7 +497,10 @@ function useGetColumns( id: 'actions', label: 'Actions', renderCell: ({ data: { provisioned, name } }) => ( - +
{!isVanillaAM && !provisioned && ( )} + {isGrafanaAlertManager && ( + + )}
), diff --git a/public/app/features/alerting/unified/utils/access-control.ts b/public/app/features/alerting/unified/utils/access-control.ts index d12f6a19654..56f4612e22d 100644 --- a/public/app/features/alerting/unified/utils/access-control.ts +++ b/public/app/features/alerting/unified/utils/access-control.ts @@ -91,6 +91,7 @@ export function getNotificationsPermissions(rulesSourceName: string) { create: notificationsPermissions.create[sourceType], update: notificationsPermissions.update[sourceType], delete: notificationsPermissions.delete[sourceType], + provisioning: provisioningPermissions, }; }