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

[v14] Fix issue with ServiceNow incidents not including link to access request #33593

Merged
merged 14 commits into from Oct 17, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions integrations/access/servicenow/bot.go
Expand Up @@ -50,6 +50,7 @@ func (b *Bot) Broadcast(ctx context.Context, recipients []common.Recipient, reqI
Created: time.Now().UTC(),
RequestReason: reqData.RequestReason,
ReviewsCount: reqData.ReviewsCount,
Resources: reqData.Resources,
}
serviceNowData, err := b.client.CreateIncident(ctx, reqID, serviceNowReqData)
if err != nil {
Expand Down
81 changes: 60 additions & 21 deletions integrations/access/servicenow/client.go
Expand Up @@ -30,32 +30,16 @@ import (
"github.com/gravitational/trace"

"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 (
// DateTimeFormat is the time format used by servicenow
DateTimeFormat = "2006-01-02 15:04:05"
)

var (
incidentBodyTemplate = template.Must(template.New("incident 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}}
`,
))
reviewNoteTemplate = template.Must(template.New("review note").Parse(
`{{.Author}} reviewed the request at {{.Created.Format .TimeFormat}}.
Resolution: {{.ProposedState}}.
{{if .Reason}}Reason: {{.Reason}}.{{end}}`,
))
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
Expand All @@ -73,12 +57,19 @@ type ClientConfig struct {
// allowing links to the access requests to be built
WebProxyURL *url.URL

// ClusterName is the name of the Teleport cluster.
ClusterName string

// Username is the username used by the client for basic auth.
Username string
// APIToken is the token used for basic auth.
APIToken string
// CloseCode is the ServiceNow close code that incidents will be closed with.
CloseCode string

// StatusSink receives any status updates from the plugin for
// further processing. Status updates will be ignored if not set.
StatusSink common.StatusSink
}

// NewClient creates a new Servicenow client for managing incidents.
Expand Down Expand Up @@ -134,8 +125,9 @@ func (snc *Client) CreateIncident(ctx context.Context, reqID string, reqData Req
}

body := Incident{
ShortDescription: fmt.Sprintf("Access request from %s", reqData.User),
ShortDescription: fmt.Sprintf("Teleport access request from user %s", reqData.User),
Description: bodyDetails,
Caller: reqData.User,
}

var result incidentResult
Expand All @@ -152,7 +144,7 @@ func (snc *Client) CreateIncident(ctx context.Context, reqID string, reqData Req
return Incident{}, errWrapper(resp.StatusCode(), string(resp.Body()))
}

return result.Result, nil
return Incident{IncidentID: result.Result.IncidentID}, nil
}

// PostReviewNote posts a note once a new request review appears.
Expand Down Expand Up @@ -250,6 +242,23 @@ 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()))
}
Expand Down Expand Up @@ -283,6 +292,30 @@ func (snc *Client) GetUserEmail(ctx context.Context, userID string) (string, err
return result.Result[0].Email, nil
}

var (
incidentWithRolesBodyTemplate = template.Must(template.New("incident body").Parse(
`Teleport user {{.User}} submitted access request for roles {{range $index, $element := .Roles}}{{if $index}}, {{end}}{{ . }}{{end}} on Teleport cluster {{.ClusterName}}.
{{if .RequestReason}}Reason: {{.RequestReason}}{{end}}
{{if .RequestLink}}Click this link to review the request in Teleport: {{.RequestLink}}{{end}}
`,
))
incidentBodyTemplate = template.Must(template.New("incident body").Parse(
`Teleport user {{.User}} submitted access request on Teleport cluster {{.ClusterName}}.
{{if .RequestReason}}Reason: {{.RequestReason}}{{end}}
{{if .RequestLink}}Click this link to review the request in Teleport: {{.RequestLink}}{{end}}
`,
))
reviewNoteTemplate = template.Must(template.New("review note").Parse(
`{{.Author}} reviewed the request at {{.Created.Format .TimeFormat}}.
Resolution: {{.ProposedState}}.
{{if .Reason}}Reason: {{.Reason}}.{{end}}`,
))
resolutionNoteTemplate = template.Must(template.New("resolution note").Parse(
`Access request has been {{.Resolution}}
{{if .ResolveReason}}Reason: {{.ResolveReason}}{{end}}`,
))
)

func (snc *Client) buildIncidentBody(webProxyURL *url.URL, reqID string, reqData RequestData) (string, error) {
var requestLink string
if webProxyURL != nil {
Expand All @@ -292,15 +325,21 @@ func (snc *Client) buildIncidentBody(webProxyURL *url.URL, reqID string, reqData
}

var builder strings.Builder
err := incidentBodyTemplate.Execute(&builder, struct {
template := incidentBodyTemplate
if reqData.Resources == nil {
template = incidentWithRolesBodyTemplate
}
err := template.Execute(&builder, struct {
ID string
TimeFormat string
RequestLink string
ClusterName string
RequestData
}{
ID: reqID,
TimeFormat: time.RFC822,
RequestLink: requestLink,
ClusterName: snc.ClusterName,
RequestData: reqData,
})
if err != nil {
Expand Down
5 changes: 3 additions & 2 deletions integrations/access/servicenow/client_test.go
Expand Up @@ -57,8 +57,9 @@ func TestCreateIncident(t *testing.T) {
assert.NoError(t, err)

expected := Incident{
ShortDescription: "Access request from someUser",
Description: "someUser requested permissions for roles role1, role2 on Teleport at 01 Jan 01 00:00 UTC.\nReason: someReason\n\n",
ShortDescription: "Teleport access request from user someUser",
Description: "Teleport user someUser submitted access request for roles role1, role2 on Teleport cluster .\nReason: someReason\n\n",
Caller: "someUser",
}
var got Incident
err = json.Unmarshal([]byte(recievedReq), &got)
Expand Down
6 changes: 5 additions & 1 deletion integrations/access/servicenow/config.go
Expand Up @@ -65,11 +65,15 @@ func (c *Config) NewBot(clusterName, webProxyAddr string) (common.MessagingBot,
err error
)
if webProxyAddr != "" {
if webProxyURL, err = lib.AddrToURL(webProxyAddr); err != nil {
if c.WebProxyURL, err = lib.AddrToURL(webProxyAddr); err != nil {
return nil, trace.Wrap(err)
}
}

if clusterName != "" {
c.ClusterName = clusterName
}

client, err := NewClient(c.ClientConfig)
if err != nil {
return nil, trace.Wrap(err)
Expand Down
37 changes: 37 additions & 0 deletions integrations/access/servicenow/config_test.go
@@ -0,0 +1,37 @@
/*
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 servicenow

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestNewBot(t *testing.T) {
conf := Config{
ClientConfig: ClientConfig{
APIEndpoint: "serviceNowSpec.ApiEndpoint",
Username: "username",
APIToken: "password",
CloseCode: "serviceNowSpec.CloseCode",
},
}
_, err := conf.NewBot("someClusterName", "someWebProxyAddr")
require.NoError(t, err)
require.Equal(t, conf.WebProxyURL.Host, "someWebProxyAddr")
}
18 changes: 17 additions & 1 deletion integrations/access/servicenow/fake_servicenow_test.go
Expand Up @@ -90,7 +90,23 @@ func NewFakeServiceNow(concurrency int) *FakeServiceNow {
serviceNow.StoreIncident(incident)
serviceNow.newIncidents <- incident

err = json.NewEncoder(rw).Encode(incidentResult{Result: incident})
err = json.NewEncoder(rw).Encode(incidentResult{Result: struct {
IncidentID string `json:"sys_id,omitempty"`
ShortDescription string `json:"short_description,omitempty"`
Description string `json:"description,omitempty"`
CloseCode string `json:"close_code,omitempty"`
CloseNotes string `json:"close_notes,omitempty"`
IncidentState string `json:"incident_state,omitempty"`
WorkNotes string `json:"work_notes,omitempty"`
}{
IncidentID: incident.IncidentID,
ShortDescription: incident.ShortDescription,
Description: incident.Description,
CloseCode: incident.CloseCode,
CloseNotes: incident.CloseNotes,
IncidentState: incident.IncidentState,
WorkNotes: incident.WorkNotes,
}})
panicIf(err)
})
router.PATCH("/api/now/v1/table/incident/:incidentID/", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
Expand Down
8 changes: 4 additions & 4 deletions integrations/access/servicenow/servicenow_test.go
Expand Up @@ -372,7 +372,7 @@ func (s *ServiceNowSuite) TestApproval() {

incident, err := s.fakeServiceNow.CheckIncidentUpdate(s.Context())
require.NoError(t, err)
require.Contains(t, incident.Description, "requested permissions")
require.Contains(t, incident.Description, "submitted access request")
assert.Contains(t, incident.CloseNotes, "Access request has been resolved")
assert.Contains(t, incident.CloseNotes, "Reason: okay")
assert.Equal(t, incident.CloseCode, "resolved")
Expand All @@ -387,7 +387,7 @@ func (s *ServiceNowSuite) TestDenial() {

incident, err := s.fakeServiceNow.CheckNewIncident(s.Context())
require.NoError(t, err, "no new incidents stored")
require.Contains(t, incident.Description, "requested permissions")
require.Contains(t, incident.Description, "submitted access request")

err = s.ruler().DenyAccessRequest(s.Context(), req.GetName(), "not okay")
require.NoError(t, err)
Expand Down Expand Up @@ -457,7 +457,7 @@ func (s *ServiceNowSuite) TestApprovalByReview() {

incident, err := s.fakeServiceNow.CheckNewIncident(s.Context())
require.NoError(t, err, "no new incidents stored")
require.Contains(t, incident.Description, "requested permissions")
require.Contains(t, incident.Description, "submitted access request")

err = s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{
Author: s.userNames.reviewer1,
Expand Down Expand Up @@ -508,7 +508,7 @@ func (s *ServiceNowSuite) TestDenialByReview() {

incident, err := s.fakeServiceNow.CheckNewIncident(s.Context())
require.NoError(t, err, "no new incidents stored")
require.Contains(t, incident.Description, "requested permissions")
require.Contains(t, incident.Description, "submitted access request")

err = s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{
Author: s.userNames.reviewer1,
Expand Down
21 changes: 20 additions & 1 deletion integrations/access/servicenow/types.go
Expand Up @@ -50,6 +50,8 @@ type Incident struct {
IncidentState string `json:"incident_state,omitempty"`
// WorkNotes contains comments on the progress of the incident.
WorkNotes string `json:"work_notes,omitempty"`
// Caller is the user on whose behalf the incident is being created. (Must be an existing servicenow user)
Caller string `json:"caller_id,omitempty"`
}

const (
Expand Down Expand Up @@ -86,6 +88,8 @@ type RequestData struct {
Resolution Resolution
// SystemAnnotations contains key value annotations for the request.
SystemAnnotations types.Labels
// Resources are the resources being requested.
Resources []string
}

type onCallResult struct {
Expand All @@ -103,5 +107,20 @@ type userResult struct {
}

type incidentResult struct {
Result Incident `json:"result"`
Result struct {
// IncidentID is the sys_id of the incident
IncidentID string `json:"sys_id,omitempty"`
// ShortDescription contains a brief summary of the incident.
ShortDescription string `json:"short_description,omitempty"`
// Description contains the description of the incident.
Description string `json:"description,omitempty"`
// CloseCode contains the close code of the incident once it is resolved.
CloseCode string `json:"close_code,omitempty"`
// CloseNotes contains the closing comments on the incident once it is resolved.
CloseNotes string `json:"close_notes,omitempty"`
// IncidentState contains the current state the incident is in.
IncidentState string `json:"incident_state,omitempty"`
// WorkNotes contains comments on the progress of the incident.
WorkNotes string `json:"work_notes,omitempty"`
} `json:"result"`
}