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

Add Datadog Incident Management plugin support #46271

Merged
merged 19 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from 17 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
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
Loading