diff --git a/api/types/plugin.go b/api/types/plugin.go
index a939bdb7ecf94..5b62cfdda784a 100644
--- a/api/types/plugin.go
+++ b/api/types/plugin.go
@@ -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"
@@ -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.
diff --git a/integrations/access/common/constants.go b/integrations/access/common/constants.go
new file mode 100644
index 0000000000000..41cd4c676cc7d
--- /dev/null
+++ b/integrations/access/common/constants.go
@@ -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 .
+ */
+
+package common
+
+import "time"
+
+const (
+ // PluginShutdownTimeout defines the timeout for plugins to gracefully shutdown.
+ PluginShutdownTimeout = 15 * time.Second
+)
diff --git a/integrations/access/common/recipient.go b/integrations/access/common/recipient.go
index 0e2a7a2d21409..333a197206b48 100644
--- a/integrations/access/common/recipient.go
+++ b/integrations/access/common/recipient.go
@@ -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).
diff --git a/integrations/access/datadog/Makefile b/integrations/access/datadog/Makefile
new file mode 100644
index 0000000000000..0075c13c549c7
--- /dev/null
+++ b/integrations/access/datadog/Makefile
@@ -0,0 +1,3 @@
+ACCESS_PLUGIN = datadog
+
+include ../common.mk
diff --git a/integrations/access/datadog/README.md b/integrations/access/datadog/README.md
new file mode 100644
index 0000000000000..568e21df2e65d
--- /dev/null
+++ b/integrations/access/datadog/README.md
@@ -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`.
diff --git a/integrations/access/datadog/app.go b/integrations/access/datadog/app.go
new file mode 100644
index 0000000000000..d39abf41f3820
--- /dev/null
+++ b/integrations/access/datadog/app.go
@@ -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 .
+ */
+
+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)
+}
diff --git a/integrations/access/datadog/bot.go b/integrations/access/datadog/bot.go
new file mode 100644
index 0000000000000..c7587480318b3
--- /dev/null
+++ b/integrations/access/datadog/bot.go
@@ -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 .
+ */
+
+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)
+}
diff --git a/integrations/access/datadog/client.go b/integrations/access/datadog/client.go
new file mode 100644
index 0000000000000..244334523feb9
--- /dev/null
+++ b/integrations/access/datadog/client.go
@@ -0,0 +1,247 @@
+/*
+ * 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 .
+ */
+
+package datadog
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-resty/resty/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport/integrations/access/common"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+ pd "github.com/gravitational/teleport/integrations/lib/plugindata"
+)
+
+const (
+ datadogMaxConns = 100
+ datadogHTTPTimeout = 10 * time.Second
+ statusEmitTimeout = 10 * time.Second
+)
+
+const (
+ // IncidentWritePermissions is a Datadog permission that allows the role to
+ // create, view, and manage incidents in Datadog.
+ //
+ // See documentation for more details:
+ // https://docs.datadoghq.com/account_management/rbac/permissions/#case-and-incident-management
+ IncidentWritePermissions = "incident_write"
+)
+
+// Datadog is a wrapper around resty.Client.
+type Datadog struct {
+ // DatadogConfig specifies datadog client configuration.
+ DatadogConfig
+
+ // TODO: Datadog API client implemented using resty because implementation is
+ // simpler to integrate with the existing framework. Consider using the official
+ // datadog api client package: https://github.com/DataDog/datadog-api-client-go.
+ client *resty.Client
+}
+
+// NewDatadogClient creates a new Datadog client for managing incidents.
+func NewDatadogClient(conf DatadogConfig, webProxyAddr string, statusSink common.StatusSink) (*Datadog, error) {
+ client := resty.NewWithClient(&http.Client{
+ Timeout: datadogHTTPTimeout,
+ Transport: &http.Transport{
+ MaxConnsPerHost: datadogMaxConns,
+ MaxIdleConnsPerHost: datadogMaxConns,
+ }}).
+ SetBaseURL(conf.APIEndpoint).
+ SetHeader("Accept", "application/json").
+ SetHeader("Content-Type", "application/json").
+ SetHeader("DD-API-KEY", conf.APIKey).
+ SetHeader("DD-APPLICATION-KEY", conf.ApplicationKey).
+ OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error {
+ req.SetError(&ErrorResult{})
+ return nil
+ }).
+ OnAfterResponse(onAfterDatadogResponse(statusSink))
+
+ return &Datadog{
+ DatadogConfig: conf,
+ client: client,
+ }, nil
+}
+
+func onAfterDatadogResponse(sink common.StatusSink) resty.ResponseMiddleware {
+ return func(_ *resty.Client, resp *resty.Response) error {
+ log := logger.Get(resp.Request.Context())
+
+ if sink != nil {
+ status := common.StatusFromStatusCode(resp.StatusCode())
+ // No usable context in scope, use background with a reasonable timeout
+ ctx, cancel := context.WithTimeout(context.Background(), statusEmitTimeout)
+ defer cancel()
+
+ if err := sink.Emit(ctx, status); err != nil {
+ log.WithError(err).Errorf("Error while emitting Datadog Incident Management plugin status: %v", err)
+ }
+ }
+
+ if resp.IsError() {
+ var details string
+ switch result := resp.Error().(type) {
+ case *ErrorResult:
+ details = fmt.Sprintf("http error code=%v, errors=[%v]", resp.StatusCode(), strings.Join(result.Errors, ", "))
+ default:
+ details = fmt.Sprintf("unknown error result %#v", result)
+ }
+ return trace.Errorf(details)
+ }
+ return nil
+ }
+}
+
+// CheckHealth pings Datadog and ensures required permissions.
+func (d *Datadog) CheckHealth(ctx context.Context) error {
+ var result PermissionsBody
+ _, err := d.client.NewRequest().
+ SetContext(ctx).
+ SetResult(&result).
+ Get("permissions")
+ if err != nil {
+ return trace.Wrap(err)
+ }
+ for _, permission := range result.Data {
+ if permission.Attributes.Name == IncidentWritePermissions {
+ if permission.Attributes.Restricted {
+ return trace.AccessDenied("missing incident_write permissions")
+ }
+ return nil
+ }
+ }
+ return nil
+}
+
+// Create Incident creates a new Datadog incident.
+func (d *Datadog) CreateIncident(ctx context.Context, summary string, recipients []common.Recipient, reqData pd.AccessRequestData) (IncidentsData, error) {
+ teams := make([]string, 0, len(recipients))
+ emails := make([]NotificationHandle, 0, len(recipients))
+
+ for _, recipient := range recipients {
+ switch recipient.Kind {
+ case common.RecipientKindTeam:
+ teams = append(teams, recipient.Name)
+ case common.RecipientKindEmail:
+ emails = append(emails, NotificationHandle{Handle: recipient.Name})
+ }
+ }
+
+ body := IncidentsBody{
+ Data: IncidentsData{
+ Metadata: Metadata{
+ Type: "incidents",
+ },
+ Attributes: IncidentsAttributes{
+ Title: fmt.Sprintf("Access request from %s", reqData.User),
+ Fields: IncidentsFields{
+ Summary: &StringField{
+ Type: "textbox",
+ Value: summary,
+ },
+ State: &StringField{
+ Type: "dropdown",
+ Value: "active",
+ },
+ DetectionMethod: &StringField{
+ Type: "dropdown",
+ Value: "employee",
+ },
+ Severity: &StringField{
+ Type: "dropdown",
+ Value: d.Severity,
+ },
+ RootCause: &StringField{
+ Type: "textbox",
+ Value: reqData.RequestReason,
+ },
+ Teams: &StringSliceField{
+ Type: "multiselect",
+ Value: teams,
+ },
+ },
+ NotificationHandles: emails,
+ },
+ },
+ }
+ var result IncidentsBody
+ _, err := d.client.NewRequest().
+ SetContext(ctx).
+ SetBody(body).
+ SetResult(&result).
+ Post("incidents")
+ if err != nil {
+ return IncidentsData{}, trace.Wrap(err)
+ }
+ return result.Data, nil
+}
+
+// PostReviewNote posts a note once a new request review appears.
+func (d *Datadog) PostReviewNote(ctx context.Context, incidentID, note string) error {
+ body := TimelineBody{
+ Data: TimelineData{
+ Metadata: Metadata{
+ Type: "incident_timeline_cells",
+ },
+ Attributes: TimelineAttributes{
+ CellType: "markdown",
+ Content: TimelineContent{
+ Content: note,
+ },
+ },
+ },
+ }
+ _, err := d.client.NewRequest().
+ SetContext(ctx).
+ SetBody(body).
+ SetPathParam("incident_id", incidentID).
+ Post("incidents/{incident_id}/timeline")
+ return trace.Wrap(err)
+}
+
+// ResolveIncident resolves an incident and posts a note with resolution details.
+func (d *Datadog) ResolveIncident(ctx context.Context, incidentID, state string) error {
+ body := IncidentsBody{
+ Data: IncidentsData{
+ Metadata: Metadata{
+ ID: incidentID,
+ Type: "incidents",
+ },
+ Attributes: IncidentsAttributes{
+ Fields: IncidentsFields{
+ State: &StringField{
+ Type: "dropdown",
+ Value: state,
+ },
+ },
+ },
+ },
+ }
+ _, err := d.client.NewRequest().
+ SetContext(ctx).
+ SetBody(body).
+ SetPathParam("incident_id", incidentID).
+ Patch("incidents/{incident_id}")
+ return trace.Wrap(err)
+}
diff --git a/integrations/access/datadog/cmd/teleport-datadog/example_config.toml b/integrations/access/datadog/cmd/teleport-datadog/example_config.toml
new file mode 100644
index 0000000000000..6025ca171f5f8
--- /dev/null
+++ b/integrations/access/datadog/cmd/teleport-datadog/example_config.toml
@@ -0,0 +1,47 @@
+# example teleport-datadog configuration TOML file
+[teleport]
+# Teleport Auth/Proxy Server address.
+#
+# Should be port 3025 for Auth Server and 3080 or 443 for Proxy.
+# For Teleport Cloud, should be in the form "your-account.teleport.sh:443".
+addr = "example.com:3025"
+
+# Credentials.
+#
+# When using --format=file:
+# identity = "/var/lib/teleport/plugins/datadog/auth_id" # Identity file
+# refresh_identity = true # Refresh identity file on a periodic basis.
+#
+# When using --format=tls:
+# client_key = "/var/lib/teleport/plugins/datadog/auth.key" # Teleport TLS secret key
+# client_crt = "/var/lib/teleport/plugins/datadog/auth.crt" # Teleport TLS certificate
+# root_cas = "/var/lib/teleport/plugins/datadog/auth.cas" # Teleport CA certs
+
+[datadog]
+# Datadog API Endpoint specifies the Datadog API endpoint.
+# See documentation for supported Datadog Sites: https://docs.datadoghq.com/getting_started/site/#access-the-datadog-site.
+# Make sure to specify the "api.*" subdomain.
+api_endpoint = "https://api.datadoghq.com/api/v2"
+
+# Datadog API Key accepts a key value or a filepath if the value starts with a '/'.
+api_key = "api_key"
+
+# Datadog Application Key accepts a key value or a filepath if the value starts with a '/'.
+application_key = "application_key"
+
+# Datadog Severity specivies the severity level of incidents.
+severity = "SEV-3"
+
+[role_to_recipients]
+# Map roles to recipients.
+#
+# Provide datadog user_email/team recipients for access requests for specific roles.
+# role.suggested_reviewers will automatically be treated as additional email recipients.
+# "*" must be provided to match non-specified roles.
+#
+# "dev" = "dev-team"
+# "*" = ["cloud@email.com", "cloud-team"]
+
+[log]
+output = "stderr" # Logger output. Could be "stdout", "stderr" or "/var/lib/teleport/datadog.log"
+severity = "INFO" # Logger severity. Could be "INFO", "ERROR", "DEBUG" or "WARN".
diff --git a/integrations/access/datadog/cmd/teleport-datadog/install b/integrations/access/datadog/cmd/teleport-datadog/install
new file mode 100755
index 0000000000000..7cc13566a1240
--- /dev/null
+++ b/integrations/access/datadog/cmd/teleport-datadog/install
@@ -0,0 +1,19 @@
+#!/bin/sh
+
+#
+# the directory where Teleport binaries will be located
+#
+BINDIR=/usr/local/bin
+
+# the directory where Teleport plugins store their certificates
+# and other data files
+#
+DATADIR=/var/lib/teleport/plugins/datadog
+
+[ ! $(id -u) != "0" ] || { echo "ERROR: You must be root"; exit 1; }
+cd $(dirname $0)
+mkdir -p $BINDIR $DATADIR
+cp -f teleport-datadog $BINDIR/ || exit 1
+
+echo "Teleport Datadog Incident Management plugin binaries have been copied to $BINDIR"
+echo "You can run teleport-datadog configure > /etc/teleport-datadog.toml to bootstrap your config file."
diff --git a/integrations/access/datadog/cmd/teleport-datadog/main.go b/integrations/access/datadog/cmd/teleport-datadog/main.go
new file mode 100644
index 0000000000000..cb9cbd1959771
--- /dev/null
+++ b/integrations/access/datadog/cmd/teleport-datadog/main.go
@@ -0,0 +1,99 @@
+/*
+ * 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 .
+ */
+
+package main
+
+import (
+ "context"
+ _ "embed"
+ "fmt"
+ "os"
+
+ "github.com/alecthomas/kingpin/v2"
+ "github.com/gravitational/trace"
+
+ "github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
+ "github.com/gravitational/teleport/integrations/access/datadog"
+ "github.com/gravitational/teleport/integrations/lib"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+)
+
+//go:embed example_config.toml
+var exampleConfig string
+
+func main() {
+ logger.Init()
+ app := kingpin.New("teleport-datadog", "Teleport plugin for access requests approval via Datadog.")
+
+ app.Command("configure", "Prints an example .TOML configuration file.")
+ app.Command("version", "Prints teleport-datadog version and exits.")
+
+ startCmd := app.Command("start", "Starts a Teleport Datadog Incident Management plugin.")
+ path := startCmd.Flag("config", "TOML config file path").
+ Short('c').
+ Default("/etc/teleport-datadog.toml").
+ String()
+ debug := startCmd.Flag("debug", "Enable verbose logging to stderr").
+ Short('d').
+ Bool()
+
+ selectedCmd, err := app.Parse(os.Args[1:])
+ if err != nil {
+ lib.Bail(err)
+ }
+
+ switch selectedCmd {
+ case "configure":
+ fmt.Print(exampleConfig)
+ case "version":
+ lib.PrintVersion(app.Name, teleport.Version, teleport.Gitref)
+ case "start":
+ if err := run(*path, *debug); err != nil {
+ lib.Bail(err)
+ } else {
+ logger.Standard().Info("Successfully shut down")
+ }
+ }
+}
+
+func run(configPath string, debug bool) error {
+ conf, err := datadog.LoadConfig(configPath)
+ if err != nil {
+ return trace.Wrap(err)
+ }
+
+ logConfig := conf.Log
+ if debug {
+ logConfig.Severity = "debug"
+ }
+ if err = logger.Setup(logConfig); err != nil {
+ return err
+ }
+ if debug {
+ logger.Standard().Debugf("DEBUG logging enabled")
+ }
+
+ app := datadog.NewDatadogApp(conf)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
+
+ logger.Standard().Infof("Starting Teleport Access Datadog Incident Management Plugin %s:%s", teleport.Version, teleport.Gitref)
+ return trace.Wrap(
+ app.Run(context.Background()),
+ )
+}
diff --git a/integrations/access/datadog/config.go b/integrations/access/datadog/config.go
new file mode 100644
index 0000000000000..56a5c2085a342
--- /dev/null
+++ b/integrations/access/datadog/config.go
@@ -0,0 +1,156 @@
+/*
+ * 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 .
+ */
+
+package datadog
+
+import (
+ "context"
+ "strings"
+
+ "github.com/gravitational/trace"
+ "github.com/pelletier/go-toml"
+
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/access/common"
+ "github.com/gravitational/teleport/integrations/access/common/teleport"
+ "github.com/gravitational/teleport/integrations/lib"
+)
+
+const (
+ // APIEndpointDefaultURL specifies the default US1 region api endpoint.
+ APIEndpointDefaultURL = "https://api.datadoghq.com/api/v2"
+ // SeverityDefault specifies the default incident severity.
+ SeverityDefault = "SEV-3"
+)
+
+// Config stores the full configuration for the teleport-datadog plugin to run.
+type Config struct {
+ // BaseConfig specifies the base configuration for an access plugin.
+ common.BaseConfig
+
+ // Datadog specifies Datadog API client configuration
+ Datadog DatadogConfig
+
+ // StatusSink receives any status updates from the plugin for
+ // further processing. Status updates will be ignored if not set.
+ StatusSink common.StatusSink
+
+ // Teleport is a handle to the client to use when communicating with
+ // the Teleport auth server. The Datadog app will create a gRPC-based
+ // client on startup if this is not set.
+ Client teleport.Client
+}
+
+// DatadogConfig stores datadog specifc configuration.
+type DatadogConfig struct {
+ // APIEndpoint specifies the Datadog API endpoint.
+ APIEndpoint string `toml:"api_endpoint"`
+ // APIKey specifies a Datadog API key.
+ APIKey string `toml:"api_key"`
+ // ApplicationKey specifies a Datadog Application key.
+ ApplicationKey string `toml:"application_key"`
+ // Severity configures the incident severity. Default is 'SEV-3'.
+ Severity string `toml:"severity"`
+}
+
+// LoadConfig loads configuration from specified filepath.
+func LoadConfig(filepath string) (*Config, error) {
+ t, err := toml.LoadFile(filepath)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ conf := &Config{}
+ if err := t.Unmarshal(conf); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ if strings.HasPrefix(conf.Datadog.APIKey, "/") {
+ conf.Datadog.APIKey, err = lib.ReadPassword(conf.Datadog.APIKey)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ }
+ if strings.HasPrefix(conf.Datadog.ApplicationKey, "/") {
+ conf.Datadog.ApplicationKey, err = lib.ReadPassword(conf.Datadog.ApplicationKey)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ }
+ if err := conf.CheckAndSetDefaults(); err != nil {
+ return nil, trace.Wrap(err)
+ }
+ return conf, nil
+}
+
+// CheckAndSetDefaults checks the config and sets defaults.
+func (c *Config) CheckAndSetDefaults() error {
+ if err := c.Teleport.CheckAndSetDefaults(); err != nil {
+ return trace.Wrap(err)
+ }
+ if c.Datadog.APIEndpoint == "" {
+ c.Datadog.APIEndpoint = APIEndpointDefaultURL
+ }
+ if c.Datadog.APIKey == "" {
+ return trace.BadParameter("missing required value datadog.api_key")
+ }
+ if c.Datadog.ApplicationKey == "" {
+ return trace.BadParameter("missing required value datadog.application_key")
+ }
+ if c.Datadog.Severity == "" {
+ c.Datadog.Severity = SeverityDefault
+ }
+ if c.Log.Output == "" {
+ c.Log.Output = "stderr"
+ }
+ if c.Log.Severity == "" {
+ c.Log.Severity = "info"
+ }
+ if len(c.Recipients) == 0 {
+ return trace.BadParameter("missing required value role_to_recipients.")
+ } else if len(c.Recipients[types.Wildcard]) == 0 {
+ return trace.BadParameter("missing required value role_to_recipients[%v].", types.Wildcard)
+ }
+ c.PluginType = types.PluginTypeDatadog
+ return nil
+}
+
+// GetTeleportClient returns the configured Teleport client.
+func (c *Config) GetTeleportClient(ctx context.Context) (teleport.Client, error) {
+ if c.Client != nil {
+ return c.Client, nil
+ }
+ return c.BaseConfig.GetTeleportClient(ctx)
+}
+
+// NewBot initializes a new Datadog bot.
+func (c *Config) NewBot(clusterName, webProxyAddr string) (common.MessagingBot, error) {
+ datadog, err := NewDatadogClient(c.Datadog, webProxyAddr, c.StatusSink)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ webProxyURL, err := lib.AddrToURL(webProxyAddr)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ return Bot{
+ datadog: datadog,
+ clusterName: clusterName,
+ webProxyURL: webProxyURL,
+ }, nil
+}
diff --git a/integrations/access/datadog/testlib/fake_datadog.go b/integrations/access/datadog/testlib/fake_datadog.go
new file mode 100644
index 0000000000000..b6071ad16f470
--- /dev/null
+++ b/integrations/access/datadog/testlib/fake_datadog.go
@@ -0,0 +1,197 @@
+/*
+ * 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 .
+ */
+
+package testlib
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "runtime/debug"
+ "sync"
+ "sync/atomic"
+
+ "github.com/gravitational/trace"
+ "github.com/julienschmidt/httprouter"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/gravitational/teleport/integrations/access/datadog"
+)
+
+type FakeDatadog struct {
+ srv *httptest.Server
+
+ newIncidents chan datadog.IncidentsBody
+ incidentUpdates chan datadog.IncidentsBody
+ newIncidentNotes chan datadog.TimelineBody
+
+ objects sync.Map
+ incidentIDCounter uint64
+ incidentNoteIDCounter uint64
+}
+
+func NewFakeDatadog(concurrency int) *FakeDatadog {
+ router := httprouter.New()
+ mock := &FakeDatadog{
+ srv: httptest.NewServer(router),
+
+ newIncidents: make(chan datadog.IncidentsBody, concurrency),
+ incidentUpdates: make(chan datadog.IncidentsBody, concurrency),
+ newIncidentNotes: make(chan datadog.TimelineBody, concurrency*3),
+ }
+
+ router.GET("/permissions", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.Header().Add("Content-Type", "application/json")
+ err := json.NewEncoder(rw).Encode(datadog.PermissionsBody{
+ Data: []datadog.PermissionsData{
+ {
+ Attributes: datadog.PermissionsAttributes{
+ Name: datadog.IncidentWritePermissions,
+ Restricted: false,
+ },
+ },
+ },
+ })
+ panicIf(err)
+ })
+
+ router.POST("/incidents", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.Header().Add("Content-Type", "application/json")
+ rw.WriteHeader(http.StatusCreated)
+
+ var body datadog.IncidentsBody
+ err := json.NewDecoder(r.Body).Decode(&body)
+ panicIf(err)
+
+ incident := mock.StoreIncident(body)
+ mock.newIncidents <- incident
+
+ err = json.NewEncoder(rw).Encode(incident)
+ panicIf(err)
+ })
+
+ router.PATCH("/incidents/:incident_id", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.Header().Add("Content-Type", "application/json")
+
+ incident, found := mock.GetIncident(ps.ByName("incident_id"))
+ if !found {
+ rw.WriteHeader(http.StatusNotFound)
+ err := json.NewEncoder(rw).Encode(&datadog.ErrorResult{Errors: []string{"Incident not found"}})
+ panicIf(err)
+ return
+ }
+
+ var body datadog.IncidentsBody
+ err := json.NewDecoder(r.Body).Decode(&body)
+ panicIf(err)
+
+ incident.Data.Attributes.Fields.State = body.Data.Attributes.Fields.State
+ mock.StoreIncident(incident)
+ mock.incidentUpdates <- incident
+
+ err = json.NewEncoder(rw).Encode(incident)
+ panicIf(err)
+ })
+
+ router.POST("/incidents/:incident_id/timeline", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) {
+ rw.Header().Add("Content-Type", "application/json")
+ rw.WriteHeader(http.StatusCreated)
+
+ var body datadog.TimelineBody
+ err := json.NewDecoder(r.Body).Decode(&body)
+ panicIf(err)
+
+ note := mock.StoreIncidentNote(body)
+ mock.newIncidentNotes <- note
+
+ err = json.NewEncoder(rw).Encode(note)
+ panicIf(err)
+ })
+
+ return mock
+}
+
+func (d *FakeDatadog) URL() string {
+ return d.srv.URL
+}
+
+func (d *FakeDatadog) Close() {
+ d.srv.Close()
+ close(d.newIncidents)
+ close(d.incidentUpdates)
+ close(d.newIncidentNotes)
+}
+
+func (d *FakeDatadog) GetIncident(id string) (datadog.IncidentsBody, bool) {
+ if obj, ok := d.objects.Load(id); ok {
+ incident, ok := obj.(datadog.IncidentsBody)
+ return incident, ok
+ }
+ return datadog.IncidentsBody{}, false
+}
+
+func (d *FakeDatadog) StoreIncident(incident datadog.IncidentsBody) datadog.IncidentsBody {
+ if incident.Data.ID == "" {
+ incident.Data.ID = fmt.Sprintf("incident-%v", atomic.AddUint64(&d.incidentIDCounter, 1))
+ }
+ d.objects.Store(incident.Data.ID, incident)
+ return incident
+}
+
+func (d *FakeDatadog) StoreIncidentNote(note datadog.TimelineBody) datadog.TimelineBody {
+ if note.Data.ID == "" {
+ note.Data.ID = fmt.Sprintf("incident_note-%v", atomic.AddUint64(&d.incidentNoteIDCounter, 1))
+ }
+ d.objects.Store(note.Data.ID, note)
+ return note
+}
+
+func (d *FakeDatadog) CheckNewIncident(ctx context.Context) (datadog.IncidentsBody, error) {
+ select {
+ case incident := <-d.newIncidents:
+ return incident, nil
+ case <-ctx.Done():
+ return datadog.IncidentsBody{}, trace.Wrap(ctx.Err())
+ }
+}
+
+func (d *FakeDatadog) CheckIncidentUpdate(ctx context.Context) (datadog.IncidentsBody, error) {
+ select {
+ case incident := <-d.incidentUpdates:
+ return incident, nil
+ case <-ctx.Done():
+ return datadog.IncidentsBody{}, trace.Wrap(ctx.Err())
+ }
+}
+
+func (d *FakeDatadog) CheckNewIncidentNote(ctx context.Context) (datadog.TimelineBody, error) {
+ select {
+ case note := <-d.newIncidentNotes:
+ return note, nil
+ case <-ctx.Done():
+ return datadog.TimelineBody{}, trace.Wrap(ctx.Err())
+ }
+}
+
+func panicIf(err error) {
+ if err != nil {
+ log.Panicf("%v at %v", err, string(debug.Stack()))
+ }
+}
diff --git a/integrations/access/datadog/testlib/helpers.go b/integrations/access/datadog/testlib/helpers.go
new file mode 100644
index 0000000000000..acdd911a6f68d
--- /dev/null
+++ b/integrations/access/datadog/testlib/helpers.go
@@ -0,0 +1,42 @@
+/*
+ * 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 .
+ */
+
+package testlib
+
+import (
+ "context"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/gravitational/teleport/integrations/access/accessrequest"
+)
+
+func (s *DatadogBaseSuite) checkPluginData(ctx context.Context, reqID string, cond func(accessrequest.PluginData) bool) accessrequest.PluginData {
+ t := s.T()
+ t.Helper()
+
+ for {
+ rawData, err := s.Ruler().PollAccessRequestPluginData(ctx, "datadog", reqID)
+ require.NoError(t, err)
+ data, err := accessrequest.DecodePluginData(rawData)
+ require.NoError(t, err)
+ if cond(data) {
+ return data
+ }
+ }
+}
diff --git a/integrations/access/datadog/testlib/oss_integration_test.go b/integrations/access/datadog/testlib/oss_integration_test.go
new file mode 100644
index 0000000000000..cb0205131a650
--- /dev/null
+++ b/integrations/access/datadog/testlib/oss_integration_test.go
@@ -0,0 +1,38 @@
+/*
+ * 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 .
+ */
+
+package testlib
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/gravitational/teleport/integrations/lib/testing/integration"
+)
+
+func TestDatadogPluginOSS(t *testing.T) {
+ datadogSuite := &DatadogSuiteOSS{
+ DatadogBaseSuite: DatadogBaseSuite{
+ AccessRequestSuite: &integration.AccessRequestSuite{
+ AuthHelper: &integration.MinimalAuthHelper{},
+ },
+ },
+ }
+ suite.Run(t, datadogSuite)
+}
diff --git a/integrations/access/datadog/testlib/suite.go b/integrations/access/datadog/testlib/suite.go
new file mode 100644
index 0000000000000..fae3df0a8698f
--- /dev/null
+++ b/integrations/access/datadog/testlib/suite.go
@@ -0,0 +1,571 @@
+/*
+ * 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 .
+ */
+
+package testlib
+
+import (
+ "context"
+ "fmt"
+ "runtime"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ accessmonitoringrulesv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessmonitoringrules/v1"
+ headerv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/header/v1"
+ "github.com/gravitational/teleport/api/types"
+ "github.com/gravitational/teleport/integrations/access/accessrequest"
+ "github.com/gravitational/teleport/integrations/access/common"
+ "github.com/gravitational/teleport/integrations/access/datadog"
+ "github.com/gravitational/teleport/integrations/lib/logger"
+ "github.com/gravitational/teleport/integrations/lib/plugindata"
+ "github.com/gravitational/teleport/integrations/lib/testing/integration"
+)
+
+// DatadogBaseSuite is the Datadog Incident Management plugin test suite.
+// It implements the testify.TestingSuite interface.
+type DatadogBaseSuite struct {
+ *integration.AccessRequestSuite
+ appConfig *datadog.Config
+ fakeDatadog *FakeDatadog
+
+ raceNumber int
+}
+
+// SetupTest starts a fake Datadog service and geneates the plugin configuration.
+// It runs for each test.
+func (s *DatadogBaseSuite) SetupTest() {
+ t := s.T()
+
+ err := logger.Setup(logger.Config{Severity: "debug"})
+ require.NoError(t, err)
+
+ s.raceNumber = runtime.GOMAXPROCS(0)
+ s.fakeDatadog = NewFakeDatadog(s.raceNumber)
+ t.Cleanup(s.fakeDatadog.Close)
+
+ s.appConfig = &datadog.Config{
+ BaseConfig: common.BaseConfig{
+ Teleport: s.TeleportConfig(),
+ PluginType: types.PluginTypeDatadog,
+ },
+ Datadog: datadog.DatadogConfig{
+ APIEndpoint: s.fakeDatadog.URL() + "/",
+ APIKey: "api-key",
+ ApplicationKey: "application-key",
+ },
+ StatusSink: &integration.FakeStatusSink{},
+ }
+}
+
+// startApp starts the Datadog Incident Management plugin, waits for it to become ready and returns.
+func (s *DatadogBaseSuite) startApp() {
+ t := s.T()
+ t.Helper()
+
+ app := datadog.NewDatadogApp(s.appConfig)
+ integration.RunAndWaitReady(t, app)
+}
+
+// DatadogSuiteOSS contains all tests that support running against a Teleport
+// OSS Server.
+type DatadogSuiteOSS struct {
+ DatadogBaseSuite
+}
+
+// DatadogSuiteEnterprise contains all tests that require a Teleport Enterprise
+// to run.
+type DatadogSuiteEnterprise struct {
+ DatadogBaseSuite
+}
+
+// SetupTest overrides DatadogBaseSuite.SetupTest to check the Teleport features
+// before each test.
+func (s *DatadogSuiteEnterprise) SetupTest() {
+ t := s.T()
+ s.RequireAdvancedWorkflow(t)
+ s.DatadogBaseSuite.SetupTest()
+}
+
+// TestIncidentCreation validates that an active incident is created and the
+// suggested reviewers are notified.
+func (s *DatadogSuiteOSS) TestIncidentCreation() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its incident.
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, []string{
+ integration.Reviewer1UserName,
+ })
+
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return len(data.SentMessages) > 0
+ })
+ require.Len(t, pluginData.SentMessages, 1)
+
+ incident, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+ require.Len(t, incident.Data.Attributes.NotificationHandles, 1)
+
+ assert.Equal(t, incident.Data.ID, pluginData.SentMessages[0].MessageID)
+ assert.Equal(t, fmt.Sprintf("@%s", integration.Reviewer1UserName), incident.Data.Attributes.NotificationHandles[0].Handle)
+ assert.Equal(t, "active", incident.Data.Attributes.Fields.State.Value)
+}
+
+// TestApproval tests that when a request is approved, its corresponding incident
+// is updated to reflect the new request state.
+func (s *DatadogSuiteOSS) TestApproval() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its incident.
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, []string{
+ integration.Reviewer1UserName,
+ })
+
+ _, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ // Test execution: we approve the request
+ err = s.Ruler().ApproveAccessRequest(ctx, req.GetName(), "okay")
+ require.NoError(t, err)
+
+ // Validating the plugin added a note to the incident explaining it got approved.
+ note, err := s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content := note.Data.Attributes.Content.Content
+ assert.Contains(t, content, "Access request is ✅ APPROVED")
+ assert.Contains(t, content, "Reason: okay")
+
+ // Validating the plugin resolved the incident.
+ incidentUpdate, err := s.fakeDatadog.CheckIncidentUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", incidentUpdate.Data.Attributes.Fields.State.Value)
+}
+
+// TestDenial tests that when a request is denied, its corresponding incident
+// is updated to reflect the new request state.
+func (s *DatadogSuiteOSS) TestDenial() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its incident.
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, []string{
+ integration.Reviewer1UserName,
+ })
+
+ _, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ // Test execution: we approve the request
+ err = s.Ruler().DenyAccessRequest(ctx, req.GetName(), "not okay")
+ require.NoError(t, err)
+
+ // Validating the plugin added a note to the incident explaining it got denied.
+ note, err := s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content := note.Data.Attributes.Content.Content
+ assert.Contains(t, content, "Access request is ❌ DENIED")
+ assert.Contains(t, content, "Reason: not okay")
+
+ // Validating the plugin resolved the incident.
+ incidentUpdate, err := s.fakeDatadog.CheckIncidentUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", incidentUpdate.Data.Attributes.Fields.State.Value)
+}
+
+// TestExpiration tests that when a request expires, its corresponding incident
+// is updated to reflect the new request state.
+func (s *DatadogSuiteOSS) TestExpiration() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its incident.
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, []string{
+ integration.Reviewer1UserName,
+ })
+
+ incident, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+ assert.Equal(t, "active", incident.Data.Attributes.Fields.State.Value)
+ incidentID := incident.Data.ID
+
+ s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return len(data.SentMessages) > 0
+ })
+
+ // Test execution: we expire the request
+ err = s.Ruler().DeleteAccessRequest(ctx, req.GetName()) // simulate expiration
+ require.NoError(t, err)
+
+ // Validating the plugin resolved the incident and added a note explaining the reason.
+ incident, err = s.fakeDatadog.CheckIncidentUpdate(ctx)
+ require.NoError(t, err, "no new incidents updated")
+ assert.Equal(t, incidentID, incident.Data.ID)
+ assert.Equal(t, "resolved", incident.Data.Attributes.Fields.State.Value)
+}
+
+// TestRecipientsFromAccessMonitoringRule tests access monitoring rules are
+// applied to the recipient selection process.
+func (s *DatadogSuiteOSS) TestRecipientsFromAccessMonitoringRule() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ // Setup base config to ensure access monitoring rule recipient take precidence
+ s.appConfig.Recipients = common.RawRecipientsMap{
+ types.Wildcard: []string{
+ integration.Reviewer2UserName,
+ },
+ }
+
+ s.startApp()
+
+ _, err := s.ClientByName(integration.RulerUserName).
+ AccessMonitoringRulesClient().
+ CreateAccessMonitoringRule(ctx, &accessmonitoringrulesv1.AccessMonitoringRule{
+ Kind: types.KindAccessMonitoringRule,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "test-datadog-amr",
+ },
+ Spec: &accessmonitoringrulesv1.AccessMonitoringRuleSpec{
+ Subjects: []string{types.KindAccessRequest},
+ Condition: "!is_empty(access_request.spec.roles)",
+ Notification: &accessmonitoringrulesv1.Notification{
+ Name: "datadog",
+ Recipients: []string{
+ integration.Reviewer1UserName,
+ },
+ },
+ },
+ })
+ require.NoError(t, err)
+
+ // Test execution: create an access request
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil)
+
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return len(data.SentMessages) > 0
+ })
+ require.Len(t, pluginData.SentMessages, 1)
+
+ incident, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ assert.Equal(t, incident.Data.ID, pluginData.SentMessages[0].MessageID)
+ assert.Equal(t, fmt.Sprintf("@%s", integration.Reviewer1UserName), incident.Data.Attributes.NotificationHandles[0].Handle)
+ assert.Equal(t, "active", incident.Data.Attributes.Fields.State.Value)
+ assert.NoError(t, s.ClientByName(integration.RulerUserName).
+ AccessMonitoringRulesClient().DeleteAccessMonitoringRule(ctx, "test-datadog-amr"))
+}
+
+// TestRecipientsFromAccessMonitoringRuleAfterUpdate tests access monitoring
+// rules are respected after an the rule is updated.
+func (s *DatadogSuiteOSS) TestRecipientsFromAccessMonitoringRuleAfterUpdate() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ // Setup base config to ensure access monitoring rule recipient take precidence
+ s.appConfig.Recipients = common.RawRecipientsMap{
+ types.Wildcard: []string{
+ integration.Reviewer2UserName,
+ },
+ }
+
+ s.startApp()
+
+ _, err := s.ClientByName(integration.RulerUserName).
+ AccessMonitoringRulesClient().
+ CreateAccessMonitoringRule(ctx, &accessmonitoringrulesv1.AccessMonitoringRule{
+ Kind: types.KindAccessMonitoringRule,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "test-datadog-amr-2",
+ },
+ Spec: &accessmonitoringrulesv1.AccessMonitoringRuleSpec{
+ Subjects: []string{types.KindAccessRequest},
+ Condition: "!is_empty(access_request.spec.roles)",
+ Notification: &accessmonitoringrulesv1.Notification{
+ Name: "datadog",
+ Recipients: []string{
+ integration.Reviewer1UserName,
+ },
+ },
+ },
+ })
+ assert.NoError(t, err)
+
+ // Test execution: we create an access request
+ req := s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil)
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return len(data.SentMessages) > 0
+ })
+ require.Len(t, pluginData.SentMessages, 1)
+
+ incident, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ assert.Equal(t, incident.Data.ID, pluginData.SentMessages[0].MessageID)
+ assert.Equal(t, fmt.Sprintf("@%s", integration.Reviewer1UserName), incident.Data.Attributes.NotificationHandles[0].Handle)
+ assert.Equal(t, "active", incident.Data.Attributes.Fields.State.Value)
+
+ // Update the Access Monitoring Rule so it is no longer applied
+ _, err = s.ClientByName(integration.RulerUserName).
+ AccessMonitoringRulesClient().
+ UpdateAccessMonitoringRule(ctx, &accessmonitoringrulesv1.AccessMonitoringRule{
+ Kind: types.KindAccessMonitoringRule,
+ Version: types.V1,
+ Metadata: &headerv1.Metadata{
+ Name: "test-datadog-amr-2",
+ },
+ Spec: &accessmonitoringrulesv1.AccessMonitoringRuleSpec{
+ Subjects: []string{"someOtherKind"},
+ Condition: "!is_empty(access_request.spec.roles)",
+ Notification: &accessmonitoringrulesv1.Notification{
+ Name: "datadog",
+ Recipients: []string{
+ integration.Reviewer1UserName,
+ },
+ },
+ },
+ })
+ assert.NoError(t, err)
+
+ // Test execution: we create an access request
+ req = s.CreateAccessRequest(ctx, integration.RequesterOSSUserName, nil)
+ pluginData = s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return len(data.SentMessages) > 0
+ })
+ require.Len(t, pluginData.SentMessages, 1)
+
+ incident, err = s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ assert.Equal(t, incident.Data.ID, pluginData.SentMessages[0].MessageID)
+ assert.Equal(t, fmt.Sprintf("@%s", integration.Reviewer2UserName), incident.Data.Attributes.NotificationHandles[0].Handle)
+ assert.Equal(t, "active", incident.Data.Attributes.Fields.State.Value)
+
+ assert.NoError(t, s.ClientByName(integration.RulerUserName).
+ AccessMonitoringRulesClient().DeleteAccessMonitoringRule(ctx, "test-datadog-amr-2"))
+}
+
+// TestReviewNotes tests that a new note is added to the incident after the
+// access request is reviewed.
+func (s *DatadogSuiteEnterprise) TestReviewNotes() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request.
+ req := s.CreateAccessRequest(ctx, integration.Requester1UserName, []string{
+ integration.Reviewer1UserName,
+ integration.Reviewer2UserName,
+ })
+
+ // Test execution: we submit two reviews
+ err := s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer1UserName,
+ ProposedState: types.RequestState_APPROVED,
+ Created: time.Now(),
+ Reason: "okay",
+ })
+ require.NoError(t, err)
+
+ err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer2UserName,
+ ProposedState: types.RequestState_DENIED,
+ Created: time.Now(),
+ Reason: "not okay",
+ })
+ require.NoError(t, err)
+
+ // Validate incident notes were created with the correct content.
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return len(data.SentMessages) > 0 && data.ReviewsCount == 2
+ })
+ assert.Len(t, pluginData.SentMessages, 1)
+
+ note, err := s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content := note.Data.Attributes.Content.Content
+ assert.Contains(t, content, integration.Reviewer1UserName+" reviewed the request", "note must contain a review author")
+ assert.Contains(t, content, "Resolution: APPROVED", "note must contain an approval resolution")
+ assert.Contains(t, content, "Reason: okay", "note must contain an approval reason")
+
+ note, err = s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content = note.Data.Attributes.Content.Content
+ assert.Contains(t, content, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author")
+ assert.Contains(t, content, "Resolution: DENIED", "note must contain a denial resolution")
+ assert.Contains(t, content, "Reason: not okay", "note must contain a denial reason")
+}
+
+// TestApprovalByReview tests that the incident is updated after the access
+// request is reviewed and approved.
+func (s *DatadogSuiteEnterprise) TestApprovalByReview() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its incident.
+ req := s.CreateAccessRequest(ctx, integration.Requester1UserName, []string{
+ integration.Reviewer1UserName,
+ integration.Reviewer2UserName,
+ })
+
+ _, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ // Test execution: we submit a review and validate that a note was created.
+ err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer1UserName,
+ ProposedState: types.RequestState_APPROVED,
+ Created: time.Now(),
+ Reason: "okay",
+ })
+ require.NoError(t, err)
+
+ note, err := s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content := note.Data.Attributes.Content.Content
+ assert.Contains(t, content, integration.Reviewer1UserName+" reviewed the request", "note must contain a review author")
+
+ // Test execution: we submit a second review and validate that a note was created.
+ err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer2UserName,
+ ProposedState: types.RequestState_APPROVED,
+ Created: time.Now(),
+ Reason: "finally okay",
+ })
+ require.NoError(t, err)
+
+ note, err = s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content = note.Data.Attributes.Content.Content
+ assert.Contains(t, content, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author")
+
+ // Validate the alert got resolved, and a final note was added describing the resolution.
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return data.ReviewsCount == 2 && data.ResolutionTag != plugindata.Unresolved
+ })
+ assert.Equal(t, plugindata.ResolvedApproved, pluginData.ResolutionTag)
+ assert.Equal(t, "finally okay", pluginData.ResolutionReason)
+
+ note, err = s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content = note.Data.Attributes.Content.Content
+ require.Contains(t, content, "Access request is ✅ APPROVED")
+ require.Contains(t, content, "Reason: finally okay")
+
+ incidentUpdate, err := s.fakeDatadog.CheckIncidentUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", incidentUpdate.Data.Attributes.Fields.State.Value)
+}
+
+// TestDenialByReview tests that the incident is updated after the access request
+// is reviewed and denied.
+func (s *DatadogSuiteEnterprise) TestDenialByReview() {
+ t := s.T()
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ t.Cleanup(cancel)
+
+ s.startApp()
+
+ // Test setup: we create an access request and wait for its incident.
+ req := s.CreateAccessRequest(ctx, integration.Requester1UserName, []string{
+ integration.Reviewer1UserName,
+ integration.Reviewer2UserName,
+ })
+
+ _, err := s.fakeDatadog.CheckNewIncident(ctx)
+ require.NoError(t, err, "no new incidents stored")
+
+ // Test execution: we submit a review and validate that a note was created.
+ err = s.Reviewer1().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer1UserName,
+ ProposedState: types.RequestState_DENIED,
+ Created: time.Now(),
+ Reason: "not okay",
+ })
+ require.NoError(t, err)
+
+ note, err := s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content := note.Data.Attributes.Content.Content
+ assert.Contains(t, content, integration.Reviewer1UserName+" reviewed the request", "note must contain a review author")
+
+ // Test execution: we submit a review and validate that a note was created.
+ err = s.Reviewer2().SubmitAccessRequestReview(ctx, req.GetName(), types.AccessReview{
+ Author: integration.Reviewer2UserName,
+ ProposedState: types.RequestState_DENIED,
+ Created: time.Now(),
+ Reason: "finally not okay",
+ })
+ require.NoError(t, err)
+
+ note, err = s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content = note.Data.Attributes.Content.Content
+ assert.Contains(t, content, integration.Reviewer2UserName+" reviewed the request", "note must contain a review author")
+
+ // Validate the alert got resolved, and a final note was added describing the resolution.
+ pluginData := s.checkPluginData(ctx, req.GetName(), func(data accessrequest.PluginData) bool {
+ return data.ReviewsCount == 2 && data.ResolutionTag != plugindata.Unresolved
+ })
+ assert.Equal(t, plugindata.ResolvedDenied, pluginData.ResolutionTag)
+ assert.Equal(t, "finally not okay", pluginData.ResolutionReason)
+
+ note, err = s.fakeDatadog.CheckNewIncidentNote(ctx)
+ require.NoError(t, err)
+
+ content = note.Data.Attributes.Content.Content
+ assert.Contains(t, content, "Access request is ❌ DENIED")
+ assert.Contains(t, content, "Reason: finally not okay")
+
+ incidentUpdate, err := s.fakeDatadog.CheckIncidentUpdate(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "resolved", incidentUpdate.Data.Attributes.Fields.State.Value)
+}
diff --git a/integrations/access/datadog/types.go b/integrations/access/datadog/types.go
new file mode 100644
index 0000000000000..a224804311500
--- /dev/null
+++ b/integrations/access/datadog/types.go
@@ -0,0 +1,123 @@
+/*
+ * 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 .
+ */
+
+package datadog
+
+// Datadog API types.
+// See: https://docs.datadoghq.com/api/latest/
+
+// Metadata contains metadata for all Datadog resources.
+type Metadata struct {
+ ID string `json:"id,omitempty"`
+ Type string `json:"type,omitempty"`
+}
+
+// PermissionsBody contains the response body for a list permissions request
+//
+// See: https://docs.datadoghq.com/api/latest/roles/#list-permissions
+type PermissionsBody struct {
+ Data []PermissionsData `json:"data,omitempty"`
+}
+
+// PermissionsData contains the permissions data.
+type PermissionsData struct {
+ Metadata
+ Attributes PermissionsAttributes `json:"attributes,omitempty"`
+}
+
+// PermissionsAttributes contains the permissions attributes.
+type PermissionsAttributes struct {
+ Name string `json:"name,omitempty"`
+ Restricted bool `json:"restricted"`
+}
+
+// IncidentBody contains the request/response body for an incident request.
+//
+// See: https://docs.datadoghq.com/api/latest/incidents
+type IncidentsBody struct {
+ Data IncidentsData `json:"data,omitempty"`
+}
+
+// IncidentData contains the incident data.
+type IncidentsData struct {
+ Metadata
+ Attributes IncidentsAttributes `json:"attributes,omitempty"`
+}
+
+// IncidentsAttributes contains the incident attributes.
+type IncidentsAttributes struct {
+ Title string `json:"title,omitempty"`
+ Fields IncidentsFields `json:"fields,omitempty"`
+ NotificationHandles []NotificationHandle `json:"notification_handles,omitempty"`
+}
+
+// IncidentsFields contains the incident fields.
+type IncidentsFields struct {
+ Summary *StringField `json:"summary,omitempty"`
+ Severity *StringField `json:"severity,omitempty"`
+ State *StringField `json:"state,omitempty"`
+ DetectionMethod *StringField `json:"detection_method,omitempty"`
+ RootCause *StringField `json:"root_cause,omitempty"`
+ Teams *StringSliceField `json:"teams,omitempty"`
+ Services *StringSliceField `json:"services,omitempty"`
+}
+
+// StringField represents a single string field value.
+type StringField struct {
+ Type string `json:"type,omitempty"`
+ Value string `json:"value,omitempty"`
+}
+
+// StringSliceField represents a multi-value string field value.
+type StringSliceField struct {
+ Type string `json:"type,omitempty"`
+ Value []string `json:"value,omitempty"`
+}
+
+// NotificationHandle represents an incident notification handle.
+type NotificationHandle struct {
+ DisplayName string `json:"display_name,omitempty"`
+ Handle string `json:"handle,omitempty"`
+}
+
+// TimelineBody contains the request/response body for an incident timeline request.
+type TimelineBody struct {
+ Data TimelineData `json:"data,omitempty"`
+}
+
+// TimelineData contains the incident timeline data.
+type TimelineData struct {
+ Metadata
+ Attributes TimelineAttributes `json:"attributes,omitempty"`
+}
+
+// TimelineAttributes contains the incident timeline attributes.
+type TimelineAttributes struct {
+ CellType string `json:"cell_type,omitempty"`
+ Content TimelineContent `json:"content,omitempty"`
+}
+
+// TimelineContent contains the incident tineline content.
+type TimelineContent struct {
+ Content string `json:"content,omitempty"`
+}
+
+// ErrorResult contains the error response.
+type ErrorResult struct {
+ Errors []string `json:"errors"`
+}
diff --git a/integrations/access/discord/cmd/teleport-discord/main.go b/integrations/access/discord/cmd/teleport-discord/main.go
index 47fc91d9182fc..cd19ce64591b6 100644
--- a/integrations/access/discord/cmd/teleport-discord/main.go
+++ b/integrations/access/discord/cmd/teleport-discord/main.go
@@ -21,12 +21,12 @@ import (
_ "embed"
"fmt"
"os"
- "time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/access/discord"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
@@ -88,7 +88,7 @@ func run(configPath string, debug bool) error {
}
app := discord.NewApp(conf)
- go lib.ServeSignals(app, 15*time.Second)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
logger.Standard().Infof("Starting Teleport Access Discord Plugin %s:%s", teleport.Version, teleport.Gitref)
return trace.Wrap(
diff --git a/integrations/access/email/cmd/teleport-email/main.go b/integrations/access/email/cmd/teleport-email/main.go
index a823271f91e11..840c80da76177 100644
--- a/integrations/access/email/cmd/teleport-email/main.go
+++ b/integrations/access/email/cmd/teleport-email/main.go
@@ -21,12 +21,12 @@ import (
_ "embed"
"fmt"
"os"
- "time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/access/email"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
@@ -96,8 +96,9 @@ func run(configPath string, debug bool) error {
return trace.Wrap(err)
}
- go lib.ServeSignals(app, 15*time.Second)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
+ logger.Standard().Infof("Starting Teleport Access Email Plugin %s:%s", teleport.Version, teleport.Gitref)
return trace.Wrap(
app.Run(context.Background()),
)
diff --git a/integrations/access/jira/cmd/teleport-jira/main.go b/integrations/access/jira/cmd/teleport-jira/main.go
index f80833da205b9..b2c2bb0672d06 100644
--- a/integrations/access/jira/cmd/teleport-jira/main.go
+++ b/integrations/access/jira/cmd/teleport-jira/main.go
@@ -21,12 +21,12 @@ import (
_ "embed"
"fmt"
"os"
- "time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/access/jira"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
@@ -100,8 +100,9 @@ func run(configPath string, insecure bool, debug bool) error {
return trace.Wrap(err)
}
- go lib.ServeSignals(app, 15*time.Second)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
+ logger.Standard().Infof("Starting Teleport Access Jira Plugin %s:%s", teleport.Version, teleport.Gitref)
return trace.Wrap(
app.Run(context.Background()),
)
diff --git a/integrations/access/mattermost/cmd/teleport-mattermost/main.go b/integrations/access/mattermost/cmd/teleport-mattermost/main.go
index ece8b51217b5a..7c4777b26655b 100644
--- a/integrations/access/mattermost/cmd/teleport-mattermost/main.go
+++ b/integrations/access/mattermost/cmd/teleport-mattermost/main.go
@@ -21,12 +21,12 @@ import (
_ "embed"
"fmt"
"os"
- "time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/access/mattermost"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
@@ -88,8 +88,9 @@ func run(configPath string, debug bool) error {
}
app := mattermost.NewMattermostApp(conf)
- go lib.ServeSignals(app, 15*time.Second)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
+ logger.Standard().Infof("Starting Teleport Access Mattermost Plugin %s:%s", teleport.Version, teleport.Gitref)
return trace.Wrap(
app.Run(context.Background()),
)
diff --git a/integrations/access/pagerduty/cmd/teleport-pagerduty/main.go b/integrations/access/pagerduty/cmd/teleport-pagerduty/main.go
index add35473853bf..aa4a8ba96eb32 100644
--- a/integrations/access/pagerduty/cmd/teleport-pagerduty/main.go
+++ b/integrations/access/pagerduty/cmd/teleport-pagerduty/main.go
@@ -21,12 +21,12 @@ import (
_ "embed"
"fmt"
"os"
- "time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/access/pagerduty"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
@@ -92,8 +92,9 @@ func run(configPath string, debug bool) error {
return trace.Wrap(err)
}
- go lib.ServeSignals(app, 15*time.Second)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
+ logger.Standard().Infof("Starting Teleport Access PagerDuty Plugin %s:%s", teleport.Version, teleport.Gitref)
return trace.Wrap(
app.Run(context.Background()),
)
diff --git a/integrations/access/slack/cmd/teleport-slack/main.go b/integrations/access/slack/cmd/teleport-slack/main.go
index c48f5c9ddec56..1f77db5f21492 100644
--- a/integrations/access/slack/cmd/teleport-slack/main.go
+++ b/integrations/access/slack/cmd/teleport-slack/main.go
@@ -21,12 +21,12 @@ import (
_ "embed"
"fmt"
"os"
- "time"
"github.com/alecthomas/kingpin/v2"
"github.com/gravitational/trace"
"github.com/gravitational/teleport"
+ "github.com/gravitational/teleport/integrations/access/common"
"github.com/gravitational/teleport/integrations/access/slack"
"github.com/gravitational/teleport/integrations/lib"
"github.com/gravitational/teleport/integrations/lib/logger"
@@ -88,7 +88,7 @@ func run(configPath string, debug bool) error {
}
app := slack.NewSlackApp(conf)
- go lib.ServeSignals(app, 15*time.Second)
+ go lib.ServeSignals(app, common.PluginShutdownTimeout)
logger.Standard().Infof("Starting Teleport Access Slack Plugin %s:%s", teleport.Version, teleport.Gitref)
return trace.Wrap(