diff --git a/api/types/plugin.go b/api/types/plugin.go index a939bdb7ecf9..5b62cfdda784 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 000000000000..41cd4c676cc7 --- /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 0e2a7a2d2140..333a197206b4 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 000000000000..0075c13c549c --- /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 000000000000..568e21df2e65 --- /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 000000000000..d39abf41f382 --- /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 000000000000..c7587480318b --- /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 000000000000..244334523feb --- /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 000000000000..6025ca171f5f --- /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 000000000000..7cc13566a124 --- /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 000000000000..cb9cbd195977 --- /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 000000000000..56a5c2085a34 --- /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 000000000000..b6071ad16f47 --- /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 000000000000..acdd911a6f68 --- /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 000000000000..cb0205131a65 --- /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 000000000000..fae3df0a8698 --- /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 000000000000..a22480431150 --- /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 47fc91d9182f..cd19ce64591b 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 a823271f91e1..840c80da7617 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 f80833da205b..b2c2bb0672d0 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 ece8b51217b5..7c4777b26655 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 add35473853b..aa4a8ba96eb3 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 c48f5c9ddec5..1f77db5f2149 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(