Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use structured error responses from servicenow and opsgenie (#52434) #52629

Open
wants to merge 1 commit into
base: branch/v17
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions integrations/access/common/response.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Teleport
* Copyright (C) 2025 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

package common

import (
"net/http"

"github.com/go-resty/resty/v2"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/integrations/lib/logger"
)

// ErrWrapperFunc are functions used to wrap http errors by plugin clients that return structured error responses.
type ErrWrapperFunc func(statusCode int, body []byte) error

// OnAfterResponse is a generic resty ResponseMiddleware that wraps errors and updates the status sink for plugins.
func OnAfterResponse(pluginName string, errWrapper ErrWrapperFunc, sink StatusSink) resty.ResponseMiddleware {
return func(_ *resty.Client, resp *resty.Response) error {
if sink != nil {
var code types.PluginStatusCode
switch {
case resp.StatusCode() == http.StatusUnauthorized:
code = types.PluginStatusCode_UNAUTHORIZED
case resp.StatusCode() >= 200 && resp.StatusCode() < 400:
code = types.PluginStatusCode_RUNNING
default:
code = types.PluginStatusCode_OTHER_ERROR
}
if err := sink.Emit(resp.Request.Context(), &types.PluginStatusV1{Code: code}); err != nil {
logger.Get(resp.Request.Context()).ErrorContext(resp.Request.Context(),
"Error while emittingplugin status",
"plugin", pluginName,
"error", err,
"code", resp.StatusCode(),
)
}
}
if resp.IsError() {
return errWrapper(resp.StatusCode(), resp.Body())
}
return nil
}
}
51 changes: 12 additions & 39 deletions integrations/access/opsgenie/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package opsgenie

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
Expand Down Expand Up @@ -128,21 +129,28 @@ func NewClient(conf ClientConfig) (*Client, error) {
}}).
SetHeader("Authorization", "GenieKey "+conf.APIKey).
SetBaseURL(conf.APIEndpoint)

client.OnAfterResponse(common.OnAfterResponse(types.PluginTypeOpsgenie, errWrapper, conf.StatusSink))
return &Client{
client: client,
ClientConfig: conf,
}, nil
}

func errWrapper(statusCode int, body string) error {
func errWrapper(statusCode int, body []byte) error {
defaultMessage := string(body)
errResponse := errorResult{}
if err := json.Unmarshal(body, &errResponse); err == nil {
defaultMessage = errResponse.Message
}
switch statusCode {
case http.StatusForbidden:
return trace.AccessDenied("opsgenie API access denied: status code %v: %q", statusCode, body)
return trace.AccessDenied("opsgenie API access denied: status code %v: %q", statusCode, defaultMessage)
case http.StatusRequestTimeout:
return trace.ConnectionProblem(trace.Errorf("status code %v: %q", statusCode, body),
return trace.ConnectionProblem(trace.Errorf("status code %v: %q", statusCode, defaultMessage),
"connecting to opsgenie API")
}
return trace.Errorf("connecting to opsgenie API status code %v: %q", statusCode, body)
return trace.Errorf("connecting to opsgenie API status code %v: %q", statusCode, defaultMessage)
}

// CreateAlert creates an opsgenie alert.
Expand Down Expand Up @@ -171,9 +179,6 @@ func (og Client) CreateAlert(ctx context.Context, reqID string, reqData RequestD
return OpsgenieData{}, trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return OpsgenieData{}, errWrapper(resp.StatusCode(), string(resp.Body()))
}

// If this fails, Teleport request approval and auto-approval will still work,
// but incident in Opsgenie won't be auto-closed or updated as the alertID won't be available.
Expand Down Expand Up @@ -213,9 +218,6 @@ func (og Client) getAlertRequestResult(ctx context.Context, reqID string) (GetAl
return GetAlertRequestResult{}, trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return GetAlertRequestResult{}, errWrapper(resp.StatusCode(), string(resp.Body()))
}
return result, nil
}

Expand Down Expand Up @@ -272,9 +274,6 @@ func (og Client) PostReviewNote(ctx context.Context, alertID string, review type
return trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return errWrapper(resp.StatusCode(), string(resp.Body()))
}
return nil
}

Expand All @@ -297,9 +296,6 @@ func (og Client) ResolveAlert(ctx context.Context, alertID string, resolution Re
return trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return errWrapper(resp.StatusCode(), string(resp.Body()))
}
return nil
}

Expand All @@ -321,9 +317,6 @@ func (og Client) GetOnCall(ctx context.Context, scheduleName string) (Responders
return RespondersResult{}, trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return RespondersResult{}, errWrapper(resp.StatusCode(), string(resp.Body()))
}
return result, nil
}

Expand All @@ -339,26 +332,6 @@ func (og Client) CheckHealth(ctx context.Context) error {
return trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()

if og.StatusSink != nil {
var code types.PluginStatusCode
switch {
case resp.StatusCode() == http.StatusUnauthorized:
code = types.PluginStatusCode_UNAUTHORIZED
case resp.StatusCode() >= 200 && resp.StatusCode() < 400:
code = types.PluginStatusCode_RUNNING
default:
code = types.PluginStatusCode_OTHER_ERROR
}
if err := og.StatusSink.Emit(ctx, &types.PluginStatusV1{Code: code}); err != nil {
logger.Get(resp.Request.Context()).WithError(err).
WithField("code", resp.StatusCode()).Errorf("Error while emitting servicenow plugin status: %v", err)
}
}

if resp.IsError() {
return errWrapper(resp.StatusCode(), string(resp.Body()))
}
return nil
}

Expand Down
5 changes: 5 additions & 0 deletions integrations/access/opsgenie/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,8 @@ type GetAlertRequestResult struct {
AlertID string `json:"alertId"`
} `json:"data"`
}

// errorResult represents the error response returned from Opsgenie.
type errorResult struct {
Message string `json:"message"`
}
54 changes: 13 additions & 41 deletions integrations/access/servicenow/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package servicenow

import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
Expand All @@ -33,7 +34,6 @@ import (
"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
)

const (
Expand Down Expand Up @@ -95,7 +95,7 @@ type ClientConfig struct {
StatusSink common.StatusSink
}

// NewClient creates a new Servicenow client for managing incidents.
// NewClient creates a new ServiceNow client for managing incidents.
func NewClient(conf ClientConfig) (*Client, error) {
if err := conf.checkAndSetDefaults(); err != nil {
return nil, trace.Wrap(err)
Expand Down Expand Up @@ -129,6 +129,7 @@ func NewClient(conf ClientConfig) (*Client, error) {
SetHeader("Content-Type", "application/json").
SetHeader("Accept", "application/json").
SetBasicAuth(conf.Username, conf.APIToken)
client.OnAfterResponse(common.OnAfterResponse(types.PluginTypeServiceNow, errWrapper, conf.StatusSink))
return &Client{
client: client,
ClientConfig: conf,
Expand All @@ -142,14 +143,20 @@ func (conf ClientConfig) checkAndSetDefaults() error {
return nil
}

func errWrapper(statusCode int, body string) error {
func errWrapper(statusCode int, body []byte) error {
defaultMessage := string(body)
errResponse := errorResult{}
if err := json.Unmarshal(body, &errResponse); err == nil {
defaultMessage = errResponse.Error.Message
}

switch statusCode {
case http.StatusForbidden:
return trace.AccessDenied("servicenow API access denied: status code %v: %q", statusCode, body)
return trace.AccessDenied("servicenow API access denied: status code %v: %q", statusCode, defaultMessage)
case http.StatusRequestTimeout:
return trace.ConnectionProblem(nil, "request to servicenow API failed: status code %v: %q", statusCode, body)
return trace.ConnectionProblem(nil, "request to servicenow API failed: status code %v: %q", statusCode, defaultMessage)
}
return trace.Errorf("request to servicenow API failed: status code %d: %q", statusCode, body)
return trace.Errorf("request to servicenow API failed: status code %d: %q", statusCode, defaultMessage)
}

// CreateIncident creates an servicenow incident.
Expand Down Expand Up @@ -180,9 +187,6 @@ func (snc *Client) CreateIncident(ctx context.Context, reqID string, reqData Req
return Incident{}, trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return Incident{}, errWrapper(resp.StatusCode(), string(resp.Body()))
}

return Incident{IncidentID: result.Result.IncidentID}, nil
}
Expand All @@ -205,9 +209,6 @@ func (snc *Client) PostReviewNote(ctx context.Context, incidentID string, review
return trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return errWrapper(resp.StatusCode(), string(resp.Body()))
}
return nil
}

Expand All @@ -231,9 +232,6 @@ func (snc *Client) ResolveIncident(ctx context.Context, incidentID string, resol
return trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return errWrapper(resp.StatusCode(), string(resp.Body()))
}
return nil
}

Expand All @@ -253,9 +251,6 @@ func (snc *Client) GetOnCall(ctx context.Context, rotaID string) ([]string, erro
return nil, trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return nil, errWrapper(resp.StatusCode(), string(resp.Body()))
}
if len(result.Result) == 0 {
return nil, trace.NotFound("no user found for given rota: %q", rotaID)
}
Expand All @@ -282,26 +277,6 @@ func (snc *Client) CheckHealth(ctx context.Context) error {
return trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()

if snc.StatusSink != nil {
var code types.PluginStatusCode
switch {
case resp.StatusCode() == http.StatusUnauthorized:
code = types.PluginStatusCode_UNAUTHORIZED
case resp.StatusCode() >= 200 && resp.StatusCode() < 400:
code = types.PluginStatusCode_RUNNING
default:
code = types.PluginStatusCode_OTHER_ERROR
}
if err := snc.StatusSink.Emit(ctx, &types.PluginStatusV1{Code: code}); err != nil {
log := logger.Get(resp.Request.Context())
log.WithError(err).WithField("code", resp.StatusCode()).Errorf("Error while emitting servicenow plugin status: %v", err)
}
}

if resp.IsError() {
return errWrapper(resp.StatusCode(), string(resp.Body()))
}
return nil
}

Expand All @@ -320,9 +295,6 @@ func (snc *Client) GetUserName(ctx context.Context, userID string) (string, erro
return "", trace.Wrap(err)
}
defer resp.RawResponse.Body.Close()
if resp.IsError() {
return "", errWrapper(resp.StatusCode(), string(resp.Body()))
}
if result.Result.UserName == "" {
return "", trace.NotFound("no username found for given id: %v", userID)
}
Expand Down
10 changes: 10 additions & 0 deletions integrations/access/servicenow/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,15 @@ type RequestData struct {
SuggestedReviewers []string
}

// OnCallResult represents the response returned from a whoisoncall request to ServiceNow.
type OnCallResult struct {
Result []struct {
// UserID is the ID of the on-call user.
UserID string `json:"userId"`
} `json:"result"`
}

// UserResult represents the response returned when retieving a user from ServiceNow.
type UserResult struct {
Result struct {
// UserName is the username in servicenow of the requested user.
Expand All @@ -111,6 +113,7 @@ type UserResult struct {
} `json:"result"`
}

// IncidentResult represents the response returned when retieving an incident from ServiceNow.
type IncidentResult struct {
Result struct {
// IncidentID is the sys_id of the incident
Expand All @@ -129,3 +132,10 @@ type IncidentResult struct {
WorkNotes string `json:"work_notes,omitempty"`
} `json:"result"`
}

// errorResult represents the error response returned from ServiceNow.
type errorResult struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
2 changes: 2 additions & 0 deletions integrations/event-handler/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-piv/piv-go v1.11.0 h1:5vAaCdRTFSIW4PeqMbnsDlUZ7odMYWnHBDGdmtU/Zhg=
github.com/go-piv/piv-go v1.11.0/go.mod h1:NZ2zmjVkfFaL/CF8cVQ/pXdXtuj110zEKGdJM6fJZZM=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
Expand Down
2 changes: 2 additions & 0 deletions integrations/terraform/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,8 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-piv/piv-go v1.11.0 h1:5vAaCdRTFSIW4PeqMbnsDlUZ7odMYWnHBDGdmtU/Zhg=
github.com/go-piv/piv-go v1.11.0/go.mod h1:NZ2zmjVkfFaL/CF8cVQ/pXdXtuj110zEKGdJM6fJZZM=
github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM=
github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
Expand Down
Loading