diff --git a/integrations/lib/opsgenie/client.go b/integrations/lib/opsgenie/client.go new file mode 100644 index 0000000000000..32a98ebb427fa --- /dev/null +++ b/integrations/lib/opsgenie/client.go @@ -0,0 +1,284 @@ +/* +Copyright 2015-2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package opsgenie + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "text/template" + "time" + + "github.com/aws/aws-sdk-go/aws/defaults" + "github.com/go-resty/resty/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/integrations/lib" +) + +const ( + // alertKeyPrefix is the prefix for Alert's alias field used when creating an Alert. + alertKeyPrefix = "teleport-access-request" +) + +var alertBodyTemplate = template.Must(template.New("alert body").Parse( + `{{.User}} requested permissions for roles {{range $index, $element := .Roles}}{{if $index}}, {{end}}{{ . }}{{end}} on Teleport at {{.Created.Format .TimeFormat}}. +{{if .RequestReason}}Reason: {{.RequestReason}}{{end}} +{{if .RequestLink}}To approve or deny the request, proceed to {{.RequestLink}}{{end}} +`, +)) +var reviewNoteTemplate = template.Must(template.New("review note").Parse( + `{{.Author}} reviewed the request at {{.Created.Format .TimeFormat}}. +Resolution: {{.ProposedState}}. +{{if .Reason}}Reason: {{.Reason}}.{{end}}`, +)) +var resolutionNoteTemplate = template.Must(template.New("resolution note").Parse( + `Access request has been {{.Resolution}} +{{if .ResolveReason}}Reason: {{.ResolveReason}}{{end}}`, +)) + +// Client is a wrapper around resty.Client. +type Client struct { + ClientConfig + + client *resty.Client +} + +// ClientConfig is the config for the opsgenie client. +type ClientConfig struct { + // APIKey is the API key for Opsgenie + APIKey string + // APIEndpoint is the endpoint for the Opsgenie API + // api url of the form https://api.opsgenie.com/v2/ with optional trailing '/' + APIEndpoint string + // DefaultSchedules are the default on-call schedules to check for auto approval + DefaultSchedules []string + // Priority is the priority alerts are to be created with + Priority string + + // WebProxyURL is the Teleport address used when building the bodies of the alerts + // allowing links to the access requests to be built + WebProxyURL *url.URL + // ClusterName is the name of the Teleport cluster + ClusterName string +} + +// NewClient creates a new Opsgenie client for managing alerts. +func NewClient(conf ClientConfig) (*Client, error) { + client := resty.NewWithClient(defaults.Config().HTTPClient) + client.SetHeader("Authorization", "GenieKey "+conf.APIKey) + client.SetBaseURL(conf.APIEndpoint) + return &Client{ + client: client, + ClientConfig: conf, + }, nil +} + +func errWrapper(statusCode int) error { + switch statusCode { + case http.StatusForbidden: + return trace.AccessDenied("opsgenie API access denied: status code %v", statusCode) + case http.StatusRequestTimeout: + return trace.ConnectionProblem(trace.Errorf("status code %v", statusCode), + "connecting to opsgenie API") + } + return trace.Errorf("connecting to opsgenie API status code %v", statusCode) +} + +// CreateAlert creates an opsgenie alert. +func (og Client) CreateAlert(ctx context.Context, reqID string, reqData RequestData) (OpsgenieData, error) { + bodyDetails, err := buildAlertBody(og.WebProxyURL, reqID, reqData) + if err != nil { + return OpsgenieData{}, trace.Wrap(err) + } + + body := AlertBody{ + Message: fmt.Sprintf("Access request from %s", reqData.User), + Alias: fmt.Sprintf("%s/%s", alertKeyPrefix, reqID), + Description: bodyDetails, + Responders: og.getResponders(reqData), + Priority: og.Priority, + } + + var result AlertResult + resp, err := og.client.NewRequest(). + SetContext(ctx). + SetBody(body). + SetResult(&result). + Post("alerts") + + if err != nil { + return OpsgenieData{}, trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return OpsgenieData{}, errWrapper(resp.StatusCode()) + } + return OpsgenieData{ + AlertID: result.Alert.ID, + }, nil +} + +func (og Client) getResponders(reqData RequestData) []Responder { + schedules := og.DefaultSchedules + if reqSchedules, ok := reqData.RequestAnnotations[ReqAnnotationRespondersKey]; ok { + schedules = reqSchedules + } + responders := make([]Responder, 0, len(schedules)) + for _, s := range schedules { + responders = append(responders, Responder{ + Type: "schedule", + ID: s, + }) + } + return responders +} + +// PostReviewNote posts a note once a new request review appears. +func (og Client) PostReviewNote(ctx context.Context, alertID string, review types.AccessReview) error { + note, err := buildReviewNoteBody(review) + if err != nil { + return trace.Wrap(err) + } + body := AlertNote{ + Note: note, + } + resp, err := og.client.NewRequest(). + SetContext(ctx). + SetBody(body). + SetPathParams(map[string]string{"alertID": alertID}). + SetQueryParams(map[string]string{"identifierType": "id"}). + Post("alerts/{alertID}/notes") + + if err != nil { + return trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return errWrapper(resp.StatusCode()) + } + return nil +} + +// ResolveAlert resolves an alert and posts a note with resolution details. +func (og Client) ResolveAlert(ctx context.Context, alertID string, resolution Resolution) error { + note, err := buildResolutionNoteBody(resolution) + if err != nil { + return trace.Wrap(err) + } + body := AlertNote{ + Note: note, + } + resp, err := og.client.NewRequest(). + SetContext(ctx). + SetBody(body). + SetPathParams(map[string]string{"alertID": alertID}). + SetQueryParams(map[string]string{"identifierType": "id"}). + Post("alerts/{alertID}/close") + if err != nil { + return trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return errWrapper(resp.StatusCode()) + } + return nil +} + +// GetOnCall returns the list of responders on-call for a schedule. +func (og Client) GetOnCall(ctx context.Context, scheduleName string) (RespondersResult, error) { + var result RespondersResult + resp, err := og.client.NewRequest(). + SetContext(ctx). + SetPathParams(map[string]string{"scheduleName": scheduleName}). + SetQueryParams(map[string]string{ + "scheduleIdentifierType": "name", + // When flat is enabled it returns the email addresses of on-call participants. + "flat": "true", + }). + SetResult(&result). + Post("schedules/{scheduleName}/on-calls") + if err != nil { + return RespondersResult{}, trace.Wrap(err) + } + defer resp.RawResponse.Body.Close() + if resp.IsError() { + return RespondersResult{}, errWrapper(resp.StatusCode()) + } + return result, nil +} + +func buildAlertBody(webProxyURL *url.URL, reqID string, reqData RequestData) (string, error) { + var requestLink string + if webProxyURL != nil { + reqURL := *webProxyURL + reqURL.Path = lib.BuildURLPath("web", "requests", reqID) + requestLink = reqURL.String() + } + + var builder strings.Builder + err := alertBodyTemplate.Execute(&builder, struct { + ID string + TimeFormat string + RequestLink string + RequestData + }{ + ID: reqID, + TimeFormat: time.RFC822, + RequestLink: requestLink, + RequestData: reqData, + }) + if err != nil { + return "", trace.Wrap(err) + } + return builder.String(), nil +} + +func buildReviewNoteBody(review types.AccessReview) (string, error) { + var builder strings.Builder + err := reviewNoteTemplate.Execute(&builder, struct { + types.AccessReview + ProposedState string + TimeFormat string + }{ + review, + review.ProposedState.String(), + time.RFC822, + }) + if err != nil { + return "", trace.Wrap(err) + } + return builder.String(), nil +} + +func buildResolutionNoteBody(resolution Resolution) (string, error) { + var builder strings.Builder + err := resolutionNoteTemplate.Execute(&builder, struct { + Resolution string + ResolveReason string + }{ + Resolution: string(resolution.Tag), + ResolveReason: resolution.Reason, + }) + if err != nil { + return "", trace.Wrap(err) + } + return builder.String(), nil +} diff --git a/integrations/lib/opsgenie/client_test.go b/integrations/lib/opsgenie/client_test.go new file mode 100644 index 0000000000000..8ee62cf622ecc --- /dev/null +++ b/integrations/lib/opsgenie/client_test.go @@ -0,0 +1,159 @@ +/* +Copyright 2015-2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package opsgenie + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + + "github.com/gravitational/teleport/api/types" +) + +func TestCreateAlert(t *testing.T) { + recievedReq := "" + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + recievedReq = string(bodyBytes) + })) + defer func() { testServer.Close() }() + + c, err := NewClient(ClientConfig{ + APIEndpoint: testServer.URL, + Priority: "somePriority", + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + _, err = c.CreateAlert(context.Background(), "someRequestID", RequestData{ + User: "someUser", + Roles: []string{"role1", "role2"}, + RequestReason: "someReason", + RequestAnnotations: map[string][]string{ + ReqAnnotationRespondersKey: {"responder@teleport.com"}, + }, + }) + assert.NoError(t, err) + + expected := AlertBody{ + Message: "Access request from someUser", + Alias: "teleport-access-request/someRequestID", + Description: "someUser requested permissions for roles role1, role2 on Teleport at 01 Jan 01 00:00 UTC.\nReason: someReason\n\n", + Responders: []Responder{{Type: "schedule", ID: "responder@teleport.com"}}, + Priority: "somePriority", + } + var got AlertBody + err = json.Unmarshal([]byte(recievedReq), &got) + assert.NoError(t, err) + + assert.Equal(t, expected, got) +} + +func TestPostReviewNote(t *testing.T) { + recievedReq := "" + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + recievedReq = string(bodyBytes) + })) + defer func() { testServer.Close() }() + + c, err := NewClient(ClientConfig{ + APIEndpoint: testServer.URL, + Priority: "somePriority", + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + err = c.PostReviewNote(context.Background(), "someAlertID", types.AccessReview{ + ProposedState: types.RequestState_APPROVED, + Author: "someUser", + Reason: "someReason", + }) + assert.NoError(t, err) + + expected := AlertNote{ + Note: "someUser reviewed the request at 01 Jan 01 00:00 UTC.\nResolution: APPROVED.\nReason: someReason.", + } + var got AlertNote + err = json.Unmarshal([]byte(recievedReq), &got) + assert.NoError(t, err) + + assert.Equal(t, expected, got) +} + +func TestResolveAlert(t *testing.T) { + recievedReq := "" + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + bodyBytes, err := io.ReadAll(req.Body) + if err != nil { + log.Fatal(err) + } + recievedReq = string(bodyBytes) + })) + defer func() { testServer.Close() }() + + c, err := NewClient(ClientConfig{ + APIEndpoint: testServer.URL, + Priority: "somePriority", + ClusterName: "someClusterName", + }) + assert.NoError(t, err) + + err = c.ResolveAlert(context.Background(), "someAlertID", Resolution{ + Tag: ResolvedApproved, + Reason: "someReason", + }) + assert.NoError(t, err) + + expected := AlertNote{ + Note: "Access request has been approved\nReason: someReason", + } + var got AlertNote + err = json.Unmarshal([]byte(recievedReq), &got) + assert.NoError(t, err) + + assert.Equal(t, expected, got) + +} + +func TestCreateAlertError(t *testing.T) { + testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { + res.WriteHeader(http.StatusForbidden) + })) + defer func() { testServer.Close() }() + + c, err := NewClient(ClientConfig{ + APIEndpoint: testServer.URL, + }) + assert.NoError(t, err) + + _, err = c.CreateAlert(context.Background(), "someRequestID", RequestData{}) + assert.True(t, trace.IsAccessDenied(err)) +} diff --git a/integrations/lib/opsgenie/plugindata.go b/integrations/lib/opsgenie/plugindata.go new file mode 100644 index 0000000000000..4633d1eaa8028 --- /dev/null +++ b/integrations/lib/opsgenie/plugindata.go @@ -0,0 +1,119 @@ +/* +Copyright 2020-2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package opsgenie + +import ( + "fmt" + "strings" + "time" +) + +// PluginData is a data associated with access request that we store in Teleport using UpdatePluginData API. +type PluginData struct { + RequestData + OpsgenieData +} + +// Resolution stores the resolution (approved, denied or expired) and its reason. +type Resolution struct { + Tag ResolutionTag + Reason string +} + +// ResolutionTag represents if and in which state an access request was resolved. +type ResolutionTag string + +// Unresolved is added to alerts that are yet to be resolved. +const Unresolved = ResolutionTag("") + +// ReolvedApproved is added to alerts that are approved. +const ResolvedApproved = ResolutionTag("approved") + +// ResolvedDenied is added to alerts that are denied. +const ResolvedDenied = ResolutionTag("denied") + +// ResolvedExpired is added to alerts that are expired. +const ResolvedExpired = ResolutionTag("expired") + +// ReqAnnotationRespondersKey is the key in access requests where on-call shedule names are stored. +const ReqAnnotationRespondersKey = "teleport.dev/opsgenie-responder" + +// RequestData stores a slice of some request fields in a convenient format. +type RequestData struct { + User string + Roles []string + Created time.Time + RequestReason string + ReviewsCount int + Resolution Resolution + RequestAnnotations map[string][]string +} + +// OpsgenieData stores the notification alert info. +type OpsgenieData struct { + ServiceID string + AlertID string +} + +// DecodePluginData deserializes a string map to PluginData struct. +func DecodePluginData(dataMap map[string]string) (data PluginData) { + data.User = dataMap["user"] + if str := dataMap["roles"]; str != "" { + data.Roles = strings.Split(str, ",") + } + if str := dataMap["created"]; str != "" { + var created int64 + fmt.Sscanf(dataMap["created"], "%d", &created) + data.Created = time.Unix(created, 0) + } + data.RequestReason = dataMap["request_reason"] + if str := dataMap["reviews_count"]; str != "" { + fmt.Sscanf(str, "%d", &data.ReviewsCount) + } + data.Resolution.Tag = ResolutionTag(dataMap["resolution"]) + data.Resolution.Reason = dataMap["resolve_reason"] + data.AlertID = dataMap["alert_id"] + data.ServiceID = dataMap["service_id"] + return +} + +// EncodePluginData serializes a PluginData struct into a string map. +func EncodePluginData(data PluginData) map[string]string { + result := make(map[string]string) + result["alert_id"] = data.AlertID + result["service_id"] = data.ServiceID + result["user"] = data.User + result["roles"] = strings.Join(data.Roles, ",") + + var createdStr string + if !data.Created.IsZero() { + createdStr = fmt.Sprintf("%d", data.Created.Unix()) + } + result["created"] = createdStr + + result["request_reason"] = data.RequestReason + + var reviewsCountStr string + if data.ReviewsCount != 0 { + reviewsCountStr = fmt.Sprintf("%d", data.ReviewsCount) + } + result["reviews_count"] = reviewsCountStr + + result["resolution"] = string(data.Resolution.Tag) + result["resolve_reason"] = data.Resolution.Reason + return result +} diff --git a/integrations/lib/opsgenie/types.go b/integrations/lib/opsgenie/types.go new file mode 100644 index 0000000000000..51ea59d68e131 --- /dev/null +++ b/integrations/lib/opsgenie/types.go @@ -0,0 +1,81 @@ +/* +Copyright 2015-2023 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package opsgenie + +// Alert represents an Opsgenie alert +type Alert struct { + // ID is the id of the Opsgenie alert. + ID string `json:"id"` + // Title is the title of the Opsgenie alert. + Title string `json:"title"` + // Status is the curerent status of the Opsgenie alert. + Status string `json:"status"` + // AlertKey is the key of the Opsgenie alert. + AlertKey string `json:"alert_key"` + // Details are a map of key-value pairs to use as custom properties of the alert. + Details map[string]string `json:"details"` +} + +// AlertBody represents and Opsgenie alert body +type AlertBody struct { + // Message is the message the alert is created with. + Message string `json:"message,omitempty"` + // Alias is the client-defined identifier of the alert. + Alias string `json:"alias,omitempty"` + // Description field of the alert. + Description string `json:"description,omitempty"` + // Responders are the teams/users that the alert will be routed to send notifications. + Responders []Responder `json:"responders,omitempty"` + // Priority is the priority the alert is created with. + Priority string `json:"priority,omitempty"` +} + +// Responder represents an Opsgenie responder +type Responder struct { + // Name is the name of the responder. + Name string `json:"name,omitempty"` + // Username is the opsgenie username of the responder. + Username string `json:"username,omitempty"` + // Type is the type of responder team/user + Type string `json:"type,omitempty"` + // ID is the ID of the responder. + ID string `json:"id,omitempty"` +} + +// RespondersResult represents a group of Opsgenie responders +type RespondersResult struct { + // Data is a wrapper around the OnCallRecipients. + Data struct { + OnCallRecipients []string `json:"onCallRecipients,omitempty"` + } `json:"data,omitempty"` +} + +// AlertResult is a wrapper around Alert +type AlertResult struct { + // Alert contains the actual alert data. + Alert Alert `json:"alert"` +} + +// AlertNote represents an Opsgenie alert note +type AlertNote struct { + // User is the user that created the note. + User string `json:"user"` + // Source is the display name of the request source. + Source string `json:"source"` + // Note is the alert note. + Note string `json:"note"` +}