Skip to content

Commit

Permalink
Add Datadog Incident Management plugin support (#46271)
Browse files Browse the repository at this point in the history
* Implement datadog plugin

* Add unit tests

* Add fallback recipient config

* Rename to Datadog Incident Management

* Update tests

* Datadog Incident Management

* Update tctl resource plugin command

* Typos

* Lint

* Exclude api changes for now

* Set channel size

* Address feedback

- Add PluginShutdownTimeout const
- Support api endpoint configuration
- Add additional godocs/comments

* Comment about datadog client package

* Document Datadog API types

* Only post resolution message when the AR is resolved

* Fix lint

* Unused function

---------

Co-authored-by: hugoShaka <hugo.hervieux@goteleport.com>
  • Loading branch information
bernardjkim and hugoShaka authored Sep 18, 2024
1 parent c86f46d commit 9a0f8d6
Show file tree
Hide file tree
Showing 23 changed files with 1,857 additions and 13 deletions.
4 changes: 3 additions & 1 deletion api/types/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const (
PluginTypeOpsgenie = "opsgenie"
// PluginTypePagerDuty is the PagerDuty access plugin
PluginTypePagerDuty = "pagerduty"
// PluginTypeMattermost is the PagerDuty access plugin
// PluginTypeMattermost is the Mattermost access plugin
PluginTypeMattermost = "mattermost"
// PluginTypeDiscord indicates the Discord access plugin
PluginTypeDiscord = "discord"
Expand All @@ -72,6 +72,8 @@ const (
PluginTypeEntraID = "entra-id"
// PluginTypeSCIM indicates a generic SCIM integration
PluginTypeSCIM = "scim"
// PluginTypeDatadog indicates the Datadog Incident Management plugin
PluginTypeDatadog = "datadog"
)

// PluginSubkind represents the type of the plugin, e.g., access request, MDM etc.
Expand Down
26 changes: 26 additions & 0 deletions integrations/access/common/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/*
* Teleport
* Copyright (C) 2024 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 "time"

const (
// PluginShutdownTimeout defines the timeout for plugins to gracefully shutdown.
PluginShutdownTimeout = 15 * time.Second
)
2 changes: 2 additions & 0 deletions integrations/access/common/recipient.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ const (
RecipientKindSchedule = "schedule"
// RecipientKindTeam shows a recipient is a team.
RecipientKindTeam = "team"
// RecipientKindEmail shows a recipient is an email.
RecipientKindEmail = "email"
)

// RawRecipientsMap is a mapping of roles to recipient(s).
Expand Down
3 changes: 3 additions & 0 deletions integrations/access/datadog/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ACCESS_PLUGIN = datadog

include ../common.mk
6 changes: 6 additions & 0 deletions integrations/access/datadog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Teleport Datadog Incident Management plugin

The Teleport Access API provides a simple Datadog Incident Management plugin that
creates incidents in Datadog when an access request is created. You can find the
Teleport Access API in the main Teleport repository and the Datadog Incident
Management plugin in `https://github.com/gravitational/teleport/tree/master/integrations/access/datadog`.
33 changes: 33 additions & 0 deletions integrations/access/datadog/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Teleport
* Copyright (C) 2024 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 datadog

import (
"github.com/gravitational/teleport/integrations/access/common"
)

const (
// datadogPluginName is used to tag Datadog GenericPluginData and as a Delegator in Audit log.
datadogPluginName = "datadog"
)

// NewDatadogApp initializes a new teleport-datadog app and returns it.
func NewDatadogApp(conf *Config) *common.BaseApp {
return common.NewApp(conf, datadogPluginName)
}
229 changes: 229 additions & 0 deletions integrations/access/datadog/bot.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/*
* Teleport
* Copyright (C) 2024 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 datadog

import (
"context"
"fmt"
"net/url"
"strings"
"text/template"

"github.com/gravitational/trace"

"github.com/gravitational/teleport/api/types"
"github.com/gravitational/teleport/api/types/accesslist"
"github.com/gravitational/teleport/integrations/access/accessrequest"
"github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/lib"
pd "github.com/gravitational/teleport/integrations/lib/plugindata"
)

// Bot is a Datadog client that works with AccessRequest.
// It is responsible for creating/updating Datadog incidents when access request
// events occur.
type Bot struct {
datadog *Datadog
clusterName string
webProxyURL *url.URL
}

var incidentSummaryTemplate = template.Must(template.New("incident summary").Parse(
`You have a new Access Request:
ID: {{.ID}}
Cluster: {{.ClusterName}}
User: {{.User}}
Role(s): {{range $index, $element := .Roles}}{{if $index}}, {{end}}{{ . }}{{end}}
{{if .RequestLink}}Link: {{.RequestLink}}{{end}} `,
))
var reviewNoteTemplate = template.Must(template.New("review note").Parse(
`{{.Author}} reviewed the request.
Resolution: {{.ProposedState}}.
{{if .Reason}}Reason: {{.Reason}}.{{end}}`,
))
var resolutionNoteTemplate = template.Must(template.New("resolution note").Parse(
`Access request is {{.Resolution}}
{{if .ResolveReason}}Reason: {{.ResolveReason}}{{end}}`,
))

// SupportedApps are the apps supported by this bot.
func (b Bot) SupportedApps() []common.App {
return []common.App{
accessrequest.NewApp(b),
}
}

// CheckHealth checks if Datadog connection is healthy.
func (b Bot) CheckHealth(ctx context.Context) error {
return trace.Wrap(b.datadog.CheckHealth(ctx))
}

// SendReviewReminders will send a review reminder that an access list needs to be reviewed.
func (b Bot) SendReviewReminders(ctx context.Context, recipient common.Recipient, accessLists []*accesslist.AccessList) error {
return trace.NotImplemented("access list review reminder is not implemented for plugin")
}

// BroadcastAccessRequestMessage creates an incident for the provided recipients.
func (b Bot) BroadcastAccessRequestMessage(ctx context.Context, recipients []common.Recipient, reqID string, reqData pd.AccessRequestData) (accessrequest.SentMessages, error) {
summary, err := buildIncidentSummary(b.clusterName, reqID, reqData, b.webProxyURL)
if err != nil {
return nil, trace.Wrap(err)
}
incidentData, err := b.datadog.CreateIncident(ctx, summary, recipients, reqData)
if err != nil {
return nil, trace.Wrap(err)
}
var data accessrequest.SentMessages
data = append(data, accessrequest.MessageData{ChannelID: incidentData.ID, MessageID: incidentData.ID})
return data, nil
}

// PostReviewReply posts an incident note.
func (b Bot) PostReviewReply(ctx context.Context, channelID, _ string, review types.AccessReview) error {
note, err := buildReviewNoteBody(review)
if err != nil {
return trace.Wrap(err)
}
return trace.Wrap(b.datadog.PostReviewNote(ctx, channelID, note))
}

// NotifyUser will send users a direct notice with the access request status.
func (b Bot) NotifyUser(ctx context.Context, reqID string, reqData pd.AccessRequestData) error {
return trace.NotImplemented("notify user is not implemented for plugin")
}

// UpdateMessages updates the indicent.
func (b Bot) UpdateMessages(ctx context.Context, reqID string, reqData pd.AccessRequestData, incidents accessrequest.SentMessages, reviews []types.AccessReview) error {
var errors []error

switch reqData.ResolutionTag {
case pd.ResolvedApproved, pd.ResolvedDenied, pd.ResolvedExpired:
default:
// If the incident is not resolved, we don't need to post any resolution message
// Nor to change its state. Un-resolving an access request should be impossible.
// We can return immediately, nothing to update in the incident.
return nil
}

note, err := buildResolutionNoteBody(reqData)
if err != nil {
return trace.Wrap(err)
}
for _, incident := range incidents {
if err := b.datadog.PostReviewNote(ctx, incident.ChannelID, note); err != nil {
errors = append(errors, trace.Wrap(err))
continue
}
err := b.datadog.ResolveIncident(ctx, incident.ChannelID, "resolved")
errors = append(errors, trace.Wrap(err))
}
return trace.NewAggregate(errors...)
}

// FetchRecipient fetches the recipient for the given name.
func (b Bot) FetchRecipient(ctx context.Context, name string) (*common.Recipient, error) {
var kind string
if lib.IsEmail(name) {
kind = common.RecipientKindEmail
name = fmt.Sprintf("@%s", name)
} else {
kind = common.RecipientKindTeam
}
return &common.Recipient{
Name: name,
ID: name,
Kind: kind,
}, nil
}

func buildIncidentSummary(clusterName, reqID string, reqData pd.AccessRequestData, webProxyURL *url.URL) (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 := incidentSummaryTemplate.Execute(&builder, struct {
ID string
ClusterName string
RequestLink string
pd.AccessRequestData
}{
reqID,
clusterName,
requestLink,
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 {
Author string
ProposedState string
Reason string
}{
review.Author,
review.ProposedState.String(),
review.Reason,
})
if err != nil {
return "", trace.Wrap(err)
}
return builder.String(), nil
}

func buildResolutionNoteBody(reqData pd.AccessRequestData) (string, error) {
var builder strings.Builder
err := resolutionNoteTemplate.Execute(&builder, struct {
Resolution string
ResolveReason string
}{
statusText(reqData.ResolutionTag),
reqData.ResolutionReason,
})
if err != nil {
return "", trace.Wrap(err)
}
return builder.String(), nil
}

func statusText(tag pd.ResolutionTag) string {
var statusEmoji string
status := string(tag)
switch tag {
case pd.Unresolved:
status = "PENDING"
statusEmoji = "⏳"
case pd.ResolvedApproved:
statusEmoji = "✅"
case pd.ResolvedDenied:
statusEmoji = "❌"
case pd.ResolvedExpired:
statusEmoji = "⌛"
}
return fmt.Sprintf("%s %s", statusEmoji, status)
}
Loading

0 comments on commit 9a0f8d6

Please sign in to comment.