diff --git a/access/email/app.go b/access/email/app.go deleted file mode 100644 index 6d15dd99c..000000000 --- a/access/email/app.go +++ /dev/null @@ -1,505 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "time" - - "github.com/gravitational/teleport/api/client/proto" - "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" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/teleport/integrations/lib/watcherjob" - "github.com/gravitational/trace" -) - -const ( - // minServerVersion is the minimal teleport version the plugin supports. - minServerVersion = "6.1.0-beta.1" - // pluginName is used to tag PluginData and as a Delegator in Audit log. - pluginName = "email" - // initTimeout is used to bound execution time of health check and teleport version check. - initTimeout = time.Second * 10 - // handlerTimeout is used to bound the execution time of watcher event handler. - handlerTimeout = time.Second * 5 - // maxModifyPluginDataTries is a maximum number of compare-and-swap tries when modifying plugin data. - maxModifyPluginDataTries = 5 -) - -// App contains global application state. -type App struct { - conf Config - - apiClient teleport.Client - client Client - mainJob lib.ServiceJob - - *lib.Process -} - -// NewApp initializes a new teleport-email app and returns it. -func NewApp(conf Config) (*App, error) { - app := &App{conf: conf} - app.mainJob = lib.NewServiceJob(app.run) - return app, nil -} - -// Run initializes and runs a watcher and a callback server -func (a *App) Run(ctx context.Context) error { - // Initialize the process. - a.Process = lib.NewProcess(ctx) - a.SpawnCriticalJob(a.mainJob) - <-a.Process.Done() - return a.Err() -} - -// Err returns the error app finished with. -func (a *App) Err() error { - return trace.Wrap(a.mainJob.Err()) -} - -// WaitReady waits for http and watcher service to start up. -func (a *App) WaitReady(ctx context.Context) (bool, error) { - return a.mainJob.WaitReady(ctx) -} - -// run starts plugin -func (a *App) run(ctx context.Context) error { - var err error - - log := logger.Get(ctx) - log.Infof("Starting Teleport Access Email Plugin %s:%s", Version, Gitref) - - if err = a.init(ctx); err != nil { - return trace.Wrap(err) - } - watcherJob, err := watcherjob.NewJob( - a.apiClient, - watcherjob.Config{ - Watch: types.Watch{Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}}}, - EventFuncTimeout: handlerTimeout, - }, - a.onWatcherEvent, - ) - if err != nil { - return trace.Wrap(err) - } - a.SpawnCriticalJob(watcherJob) - ok, err := watcherJob.WaitReady(ctx) - if err != nil { - return trace.Wrap(err) - } - - a.mainJob.SetReady(ok) - if ok { - log.Info("Plugin is ready") - } else { - log.Error("Plugin is not ready") - } - - <-watcherJob.Done() - - return trace.Wrap(watcherJob.Err()) -} - -// init inits plugin -func (a *App) init(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, initTimeout) - defer cancel() - - var err error - if a.apiClient, err = common.GetTeleportClient(ctx, a.conf.Teleport); err != nil { - return trace.Wrap(err) - } - - pong, err := a.checkTeleportVersion(ctx) - if err != nil { - return trace.Wrap(err) - } - - var webProxyAddr string - if pong.ServerFeatures.AdvancedAccessWorkflows { - webProxyAddr = pong.ProxyPublicAddr - } - - a.client, err = NewClient(ctx, a.conf, pong.ClusterName, webProxyAddr) - if err != nil { - return trace.Wrap(err) - } - - return nil -} - -// checkTeleportVersion checks that Teleport version is not lower than required -func (a *App) checkTeleportVersion(ctx context.Context) (proto.PingResponse, error) { - log := logger.Get(ctx) - log.Debug("Checking Teleport server version") - pong, err := a.apiClient.Ping(ctx) - if err != nil { - if trace.IsNotImplemented(err) { - return pong, trace.Wrap(err, "server version must be at least %s", minServerVersion) - } - log.Error("Unable to get Teleport server version") - return pong, trace.Wrap(err) - } - err = lib.AssertServerVersion(pong, minServerVersion) - return pong, trace.Wrap(err) -} - -// onWatcherEvent processes new incoming access request -func (a *App) onWatcherEvent(ctx context.Context, event types.Event) error { - if kind := event.Resource.GetKind(); kind != types.KindAccessRequest { - return trace.Errorf("unexpected kind %s", kind) - } - op := event.Type - reqID := event.Resource.GetName() - ctx, _ = logger.WithField(ctx, "request_id", reqID) - - switch op { - case types.OpPut: - ctx, _ = logger.WithField(ctx, "request_op", "put") - req, ok := event.Resource.(types.AccessRequest) - if !ok { - return trace.Errorf("unexpected resource type %T", event.Resource) - } - ctx, log := logger.WithField(ctx, "request_state", req.GetState().String()) - - var err error - switch { - case req.GetState().IsPending(): - err = a.onPendingRequest(ctx, req) - case req.GetState().IsApproved(): - err = a.onResolvedRequest(ctx, req) - case req.GetState().IsDenied(): - err = a.onResolvedRequest(ctx, req) - default: - log.WithField("event", event).Warn("Unknown request state") - return nil - } - - if err != nil { - log.WithError(err).Errorf("Failed to process request") - return trace.Wrap(err) - } - - return nil - case types.OpDelete: - ctx, log := logger.WithField(ctx, "request_op", "delete") - - if err := a.onDeletedRequest(ctx, reqID); err != nil { - log.WithError(err).Errorf("Failed to process deleted request") - return trace.Wrap(err) - } - return nil - default: - return trace.BadParameter("unexpected event operation %s", op) - } -} - -// onPendingRequest is called when an access request is created or reviewed (with thresholds > 1) -func (a *App) onPendingRequest(ctx context.Context, req types.AccessRequest) error { - log := logger.Get(ctx) - - reqID := req.GetName() - reqData := NewRequestData(req) - - isNew, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { - if existing != nil { - return PluginData{}, false - } - return PluginData{RequestData: reqData}, true - }) - if err != nil { - return trace.Wrap(err) - } - - if isNew { - if recipients := a.getEmailRecipients(ctx, req.GetRoles(), req.GetSuggestedReviewers()); len(recipients) > 0 { - if err := a.sendNewThreads(ctx, recipients, reqID, reqData); err != nil { - return trace.Wrap(err) - } - } else { - log.Warning("No recipients to send") - return nil - } - } - - if reqReviews := req.GetReviews(); len(reqReviews) > 0 { - if err := a.sendReviews(ctx, reqID, reqData, reqReviews); err != nil { - return trace.Wrap(err) - } - } - - return nil -} - -// onResolvedRequest is called when request has been resolved or denied -func (a *App) onResolvedRequest(ctx context.Context, req types.AccessRequest) error { - var replyErr error - - reqID := req.GetName() - reqData := NewRequestData(req) - - if err := a.sendReviews(ctx, reqID, reqData, req.GetReviews()); err != nil { - replyErr = trace.Wrap(err) - } - - resolution := Resolution{Reason: req.GetResolveReason()} - state := req.GetState() - switch state { - case types.RequestState_APPROVED: - resolution.Tag = ResolvedApproved - case types.RequestState_DENIED: - resolution.Tag = ResolvedDenied - default: - logger.Get(ctx).Warningf("Unknown state %v (%s)", state, state.String()) - return replyErr - } - err := trace.Wrap(a.sendResolution(ctx, req.GetName(), resolution)) - return trace.NewAggregate(replyErr, err) -} - -// onResolvedRequest is called when request has been deleted -func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { - return a.sendResolution(ctx, reqID, Resolution{Tag: ResolvedExpired}) -} - -// getEmailRecipients converts suggested reviewers to email recipients -func (a *App) getEmailRecipients(ctx context.Context, roles, suggestedReviewers []string) []string { - log := logger.Get(ctx) - validEmailRecipients := []string{} - - recipients := a.conf.RoleToRecipients.GetRawRecipientsFor(roles, suggestedReviewers) - - for _, recipient := range recipients { - if !lib.IsEmail(recipient) { - log.Warningf("Failed to notify a reviewer: %q does not look like a valid email", recipient) - continue - } - - validEmailRecipients = append(validEmailRecipients, recipient) - } - - return validEmailRecipients -} - -// broadcastNewThreads sends notifications on a new request -func (a *App) sendNewThreads(ctx context.Context, recipients []string, reqID string, reqData RequestData) error { - threadsSent, err := a.client.SendNewThreads(ctx, recipients, reqID, reqData) - - if len(threadsSent) == 0 && err != nil { - return trace.Wrap(err) - } - - logSentThreads(ctx, threadsSent, "new threads") - - if err != nil { - logger.Get(ctx).WithError(err).Error("Failed send one or more messages") - } - - _, err = a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { - var pluginData PluginData - if existing != nil { - pluginData = *existing - } else { - // It must be impossible but lets handle it just in case. - pluginData = PluginData{RequestData: reqData} - } - pluginData.EmailThreads = threadsSent - return pluginData, true - }) - return trace.Wrap(err) -} - -// sendReviews sends notifications on a request updates (new accept/decline review, review expired) -func (a *App) sendReviews(ctx context.Context, reqID string, reqData RequestData, reqReviews []types.AccessReview) error { - var oldCount int - var threads []EmailThread - - ok, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { - if existing == nil { - return PluginData{}, false - } - - if threads = existing.EmailThreads; len(threads) == 0 { - return PluginData{}, false - } - - count := len(reqReviews) - if oldCount = existing.ReviewsCount; oldCount >= count { - return PluginData{}, false - } - pluginData := *existing - pluginData.ReviewsCount = count - return pluginData, true - }) - if err != nil { - return trace.Wrap(err) - } - if !ok { - logger.Get(ctx).Debug("Failed to post reply: plugin data is missing") - return nil - } - reviews := reqReviews[oldCount:] - if len(reviews) == 0 { - return nil - } - - errors := make([]error, 0, len(reviews)) - for _, review := range reviews { - threadsSent, err := a.client.SendReview(ctx, threads, reqID, reqData, review) - if err != nil { - errors = append(errors, err) - } - logger.Get(ctx).Infof("New review for request %v by %v is %v", reqID, review.Author, review.ProposedState.String()) - logSentThreads(ctx, threadsSent, "new review") - } - - return trace.NewAggregate(errors...) -} - -// sendResolution updates the messages status and sends message when request has been resolved -func (a *App) sendResolution(ctx context.Context, reqID string, resolution Resolution) error { - log := logger.Get(ctx) - - var pluginData PluginData - ok, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { - // If plugin data is empty or missing email message timestamps, we cannot do anything. - if existing == nil { - return PluginData{}, false - } - if pluginData = *existing; len(pluginData.EmailThreads) == 0 { - return PluginData{}, false - } - - // If resolution field is not empty then we already resolved the incident before. In this case we just quit. - if pluginData.RequestData.Resolution.Tag != Unresolved { - return PluginData{}, false - } - - // Mark plugin data as resolved. - pluginData.Resolution = resolution - return pluginData, true - }) - if err != nil { - return trace.Wrap(err) - } - if !ok { - log.Debug("Failed to update messages: plugin data is missing") - return nil - } - - reqData, threads := pluginData.RequestData, pluginData.EmailThreads - - threadsSent, err := a.client.SendResolution(ctx, threads, reqID, reqData) - logSentThreads(ctx, threadsSent, "request resolved") - - log.Infof("Marked request as %s and sent emails!", resolution.Tag) - - if err != nil { - return trace.Wrap(err) - } - - return nil -} - -// modifyPluginData performs a compare-and-swap update of access request's plugin data. -// -// Callback function parameter is nil if plugin data hasn't been created yet. -// -// Otherwise, callback function parameter is a pointer to current plugin data contents. -// Callback function return value is an updated plugin data contents plus the boolean flag -// indicating whether it should be written or not. -// -// Note that callback function fn might be called more than once due to retry mechanism baked in -// so make sure that the function is "pure" i.e. it doesn't interact with the outside world: -// it doesn't perform any sort of I/O operations so even things like Go channels must be avoided. -// -// Indeed, this limitation is not that ultimate at least if you know what you're doing. -func (a *App) modifyPluginData(ctx context.Context, reqID string, fn func(data *PluginData) (PluginData, bool)) (bool, error) { - var lastErr error - for i := 0; i < maxModifyPluginDataTries; i++ { - oldData, err := a.getPluginData(ctx, reqID) - if err != nil && !trace.IsNotFound(err) { - return false, trace.Wrap(err) - } - newData, ok := fn(oldData) - if !ok { - return false, nil - } - var expectData PluginData - if oldData != nil { - expectData = *oldData - } - err = trace.Wrap(a.updatePluginData(ctx, reqID, newData, expectData)) - if err == nil { - return true, nil - } - if trace.IsCompareFailed(err) { - lastErr = err - continue - } - return false, err - } - return false, lastErr -} - -// getPluginData loads a plugin data for a given access request. It returns nil if it's not found. -func (a *App) getPluginData(ctx context.Context, reqID string) (*PluginData, error) { - dataMaps, err := a.apiClient.GetPluginData(ctx, types.PluginDataFilter{ - Kind: types.KindAccessRequest, - Resource: reqID, - Plugin: pluginName, - }) - if err != nil { - return nil, trace.Wrap(err) - } - if len(dataMaps) == 0 { - return nil, trace.NotFound("plugin data not found") - } - entry := dataMaps[0].Entries()[pluginName] - if entry == nil { - return nil, trace.NotFound("plugin data not found") - } - data := DecodePluginData(entry.Data) - return &data, nil -} - -// updatePluginData updates an existing plugin data or sets a new one if it didn't exist. -func (a *App) updatePluginData(ctx context.Context, reqID string, data PluginData, expectData PluginData) error { - return a.apiClient.UpdatePluginData(ctx, types.PluginDataUpdateParams{ - Kind: types.KindAccessRequest, - Resource: reqID, - Plugin: pluginName, - Set: EncodePluginData(data), - Expect: EncodePluginData(expectData), - }) -} - -// logSentThreads logs successfully sent emails -func logSentThreads(ctx context.Context, threads []EmailThread, kind string) { - for _, thread := range threads { - logger.Get(ctx).WithFields(logger.Fields{ - "email": thread.Email, - "timestamp": thread.Timestamp, - "message_id": thread.MessageID, - }).Infof("Successfully sent %v!", kind) - } -} diff --git a/access/email/client.go b/access/email/client.go deleted file mode 100644 index 91c1f58b0..000000000 --- a/access/email/client.go +++ /dev/null @@ -1,227 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "fmt" - "net/url" - "strings" - "text/template" - "time" - - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/trace" -) - -var reviewReplyTemplate = template.Must(template.New("review reply").Parse( - `{{.Author}} reviewed the request at {{.Created.Format .TimeFormat}}. -Resolution: {{.ProposedStateEmoji}} {{.ProposedState}}. -{{if .Reason}}Reason: {{.Reason}}.{{end}}`, -)) - -// Client is a email client that works with access.Request -type Client struct { - clusterName string - mailer Mailer - webProxyURL *url.URL -} - -// NewClient initializes the new Email message client -func NewClient(ctx context.Context, conf Config, clusterName, webProxyAddr string) (Client, error) { - var ( - webProxyURL *url.URL - err error - mailer Mailer - ) - - if webProxyAddr != "" { - if webProxyURL, err = lib.AddrToURL(webProxyAddr); err != nil { - return Client{}, trace.Wrap(err) - } - } - - if conf.Mailgun != nil { - mailer = NewMailgunMailer(*conf.Mailgun, conf.Delivery.Sender, clusterName) - logger.Get(ctx).WithField("domain", conf.Mailgun.Domain).Info("Using Mailgun as email transport") - } - - if conf.SMTP != nil { - mailer = NewSMTPMailer(*conf.SMTP, conf.Delivery.Sender, clusterName) - logger.Get(ctx).WithFields(logger.Fields{ - "host": conf.SMTP.Host, - "port": conf.SMTP.Port, - "username": conf.SMTP.Username, - }).Info("Using SMTP as email transport") - } - - return Client{ - mailer: mailer, - clusterName: clusterName, - webProxyURL: webProxyURL, - }, nil -} - -// SendNewThreads sends emails on new requests. Returns EmailData. -func (c *Client) SendNewThreads(ctx context.Context, recipients []string, reqID string, reqData RequestData) ([]EmailThread, error) { - var threads []EmailThread - var errors []error - - body := c.buildBody(reqID, reqData, "You have a new Role Request") - - for _, email := range recipients { - id, err := c.mailer.Send(ctx, reqID, email, body, "") - if err != nil { - errors = append(errors, err) - continue - } - - threads = append(threads, EmailThread{Email: email, Timestamp: time.Now().String(), MessageID: id}) - } - - return threads, trace.NewAggregate(errors...) -} - -// SendReview sends new AccessReview message to the given threads -func (c *Client) SendReview(ctx context.Context, threads []EmailThread, reqID string, reqData RequestData, review types.AccessReview) ([]EmailThread, error) { - var proposedStateEmoji string - var threadsSent = make([]EmailThread, 0) - - switch review.ProposedState { - case types.RequestState_APPROVED: - proposedStateEmoji = "✅" - case types.RequestState_DENIED: - proposedStateEmoji = "❌" - } - - var builder strings.Builder - err := reviewReplyTemplate.Execute(&builder, struct { - types.AccessReview - ProposedState string - ProposedStateEmoji string - TimeFormat string - }{ - review, - review.ProposedState.String(), - proposedStateEmoji, - time.RFC822, - }) - if err != nil { - return threadsSent, trace.Wrap(err) - } - body := builder.String() - - errors := make([]error, 0) - - for _, thread := range threads { - _, err = c.mailer.Send(ctx, reqID, thread.Email, body, thread.MessageID) - if err != nil { - errors = append(errors, trace.Wrap(err)) - continue - } - threadsSent = append(threadsSent, thread) - } - - return threadsSent, trace.NewAggregate(errors...) -} - -// SendResolution sends message on a request status update (review, decline) -func (c *Client) SendResolution(ctx context.Context, threads []EmailThread, reqID string, reqData RequestData) ([]EmailThread, error) { - var errors []error - var threadsSent = make([]EmailThread, 0) - - body := c.buildBody(reqID, reqData, "Role Request has been resolved") - - for _, thread := range threads { - _, err := c.mailer.Send(ctx, reqID, thread.Email, body, thread.MessageID) - if err != nil { - errors = append(errors, err) - continue - } - - threadsSent = append(threads, thread) - } - - return threadsSent, trace.NewAggregate(errors...) -} - -// buildBody builds a email message for create/resolve events -func (c *Client) buildBody(reqID string, reqData RequestData, subject string) string { - var builder strings.Builder - builder.Grow(128) - - builder.WriteString(fmt.Sprintf("%v:\n\n", subject)) - - resolution := reqData.Resolution - - msgFieldToBuilder(&builder, "ID", reqID) - msgFieldToBuilder(&builder, "Cluster", c.clusterName) - - if len(reqData.User) > 0 { - msgFieldToBuilder(&builder, "User", reqData.User) - } - if reqData.Roles != nil { - msgFieldToBuilder(&builder, "Role(s)", strings.Join(reqData.Roles, ",")) - } - if reqData.RequestReason != "" { - msgFieldToBuilder(&builder, "Reason", reqData.RequestReason) - } - if c.webProxyURL != nil { - reqURL := *c.webProxyURL - reqURL.Path = lib.BuildURLPath("web", "requests", reqID) - msgFieldToBuilder(&builder, "Link", reqURL.String()) - } else { - if resolution.Tag == Unresolved { - msgFieldToBuilder(&builder, "Approve", fmt.Sprintf("tsh request review --approve %s", reqID)) - msgFieldToBuilder(&builder, "Deny", fmt.Sprintf("tsh request review --deny %s", reqID)) - } - } - - var statusEmoji string - status := string(resolution.Tag) - switch resolution.Tag { - case Unresolved: - status = "PENDING" - statusEmoji = "⏳" - case ResolvedApproved: - statusEmoji = "✅" - case ResolvedDenied: - statusEmoji = "❌" - case ResolvedExpired: - statusEmoji = "⌛" - } - - statusText := fmt.Sprintf("Status: %s %s", statusEmoji, status) - if resolution.Reason != "" { - statusText += fmt.Sprintf(" (%s)", resolution.Reason) - } - - builder.WriteString("\n") - builder.WriteString(statusText) - - return builder.String() -} - -// msgFieldToBuilder utility string builder method -func msgFieldToBuilder(b *strings.Builder, field, value string) { - b.WriteString(field) - b.WriteString(": ") - b.WriteString(value) - b.WriteString("\n") -} diff --git a/access/email/config.go b/access/email/config.go deleted file mode 100644 index a7c3e3233..000000000 --- a/access/email/config.go +++ /dev/null @@ -1,262 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - _ "embed" - "fmt" - - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/access/common" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/trace" - "github.com/pelletier/go-toml" - "gopkg.in/mail.v2" -) - -// DeliveryConfig represents email recipients config -type DeliveryConfig struct { - Sender string - // DELETE IN 12.0.0 - Recipients []string -} - -// MailgunConfig holds Mailgun-specific configuration options. -type MailgunConfig struct { - Domain string - PrivateKey string `toml:"private_key"` - PrivateKeyFile string `toml:"private_key_file"` - APIBase string `toml:"-"` -} - -// SMTPConfig is SMTP-specific configuration options -type SMTPConfig struct { - Host string - Port int - Username string - Password string - PasswordFile string `toml:"password_file"` - StartTLSPolicy string `toml:"starttls_policy"` - MailStartTLSPolicy mail.StartTLSPolicy -} - -// Config stores the full configuration for the teleport-email plugin to run. -type Config struct { - Teleport lib.TeleportConfig `toml:"teleport"` - Mailgun *MailgunConfig `toml:"mailgun"` - SMTP *SMTPConfig `toml:"smtp"` - Delivery DeliveryConfig `toml:"delivery"` - RoleToRecipients common.RawRecipientsMap `toml:"role_to_recipients"` - Log logger.Config `toml:"log"` -} - -const exampleConfig = `# Example email plugin configuration TOML file - -[teleport] -addr = "0.0.0.0:3025" # Teleport Auth Server GRPC API address - -# When using --format=file: -# identity = "/var/lib/teleport/plugins/email/auth_id" # Identity file -# refresh_identity = true # Refresh identity file on a periodic basis. -# -# When using --format=tls: -# client_key = "/var/lib/teleport/plugins/email/auth.key" # Teleport TLS secret key -# client_crt = "/var/lib/teleport/plugins/email/auth.crt" # Teleport TLS certificate -# root_cas = "/var/lib/teleport/plugins/email/auth.cas" # Teleport CA certs - -[mailgun] -domain = "your-domain-name" -private_key = "xoxb-11xx" -# private_key_file = "/var/lib/teleport/plugins/email/mailgun_private_key" - -[smtp] -host = "smtp.gmail.com" -port = 587 -username = "username@gmail.com" -password = "" -# password_file = "/var/lib/teleport/plugins/email/smtp_password" -starttls_policy = "mandatory" # mandatory|opportunistic|disabled - -[delivery] -sender = "noreply@example.com" # From: email address - -[role_to_recipients] -"dev" = "dev-manager@example.com" # All requests to 'dev' role will be sent to this address -"*" = ["root@example.com", "admin@example.com"] # These recipients will receive review requests not handled by the roles above - -[log] -output = "stderr" # Logger output. Could be "stdout", "stderr" or "/var/lib/teleport/email.log" -severity = "INFO" # Logger severity. Could be "INFO", "ERROR", "DEBUG" or "WARN". -` - -// LoadConfig reads the config file, initializes a new Config struct object, and returns it. -// Optionally returns an error if the file is not readable, or if file format is invalid. -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 err := conf.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - return conf, nil -} - -// CheckAndSetDefaults checks MailgunConfig struct and set defaults if needed -func (c *MailgunConfig) CheckAndSetDefaults() error { - var err error - - if c.PrivateKey == "" { - if c.PrivateKeyFile == "" { - return trace.BadParameter("specify mailgun.private_key or mailgun.private_key_file") - } - - c.PrivateKey, err = lib.ReadPassword(c.PrivateKeyFile) - if err != nil { - return trace.Wrap(err) - } - - if c.PrivateKey == "" { - return trace.BadParameter("provide mailgun.private_key or mailgun.private_key_file to use Mailgun"+ - " and ensure that password file %v is not empty", c.PrivateKeyFile) - } - - } - - if c.Domain == "" { - return trace.BadParameter("provide mailgun.domain to use Mailgun") - } - - return nil -} - -// CheckAndSetDefaults checks SMTPConfig struct and set defaults if needed -func (c *SMTPConfig) CheckAndSetDefaults() error { - var err error - - if c.Host == "" { - return trace.BadParameter("provide smtp.host to use SMTP") - } - - if c.Port == 0 { - c.Port = 587 - } - - if c.Username == "" { - return trace.BadParameter("provide smtp.username to use SMTP") - } - - if c.Password == "" { - if c.PasswordFile == "" { - return trace.BadParameter("specify smtp.password or smtp.password_file") - } - - c.Password, err = lib.ReadPassword(c.PasswordFile) - if err != nil { - return trace.Wrap(err) - } - - if c.Password == "" { - return trace.BadParameter("provide smtp.password or smtp.password_file"+ - " and ensure that password file %v is not empty", c.PasswordFile) - } - } - - if c.MailStartTLSPolicy, err = mailStartTLSPolicy(c.StartTLSPolicy); err != nil { - return trace.BadParameter("invalid smtp.starttls_policy: %v", err) - } - - return nil -} - -func mailStartTLSPolicy(p string) (mail.StartTLSPolicy, error) { - switch p { - case "mandatory", "": - return mail.MandatoryStartTLS, nil - case "opportunistic": - return mail.OpportunisticStartTLS, nil - case "disabled": - return mail.NoStartTLS, nil - default: - return mail.MandatoryStartTLS, fmt.Errorf("unsupported starttls_policy %q - provide one of mandatory, opportunistic, disabled or leave empty to default to mandatory", p) - } -} - -// CheckAndSetDefaults checks the config struct for any logical errors, and sets default values -// if some values are missing. -// If critical values are missing and we can't set defaults for them — this will return an error. -func (c *Config) CheckAndSetDefaults() error { - if c.Log.Output == "" { - c.Log.Output = "stderr" - } - if c.Log.Severity == "" { - c.Log.Severity = "info" - } - - if len(c.Delivery.Recipients) > 0 { - if len(c.RoleToRecipients) > 0 { - return trace.BadParameter("provide either delivery.recipients or role_to_recipients, not both") - } - - c.RoleToRecipients = common.RawRecipientsMap{ - types.Wildcard: c.Delivery.Recipients, - } - c.Delivery.Recipients = nil - } - - if len(c.RoleToRecipients) == 0 { - return trace.BadParameter("missing required value role_to_recipients") - } - if len(c.RoleToRecipients[types.Wildcard]) == 0 { - return trace.BadParameter("missing required value role_to_recipients[%v]", types.Wildcard) - } - - for role, recipientsList := range c.RoleToRecipients { - for _, recipient := range recipientsList { - if !lib.IsEmail(recipient) { - return trace.BadParameter("invalid email address %v in role_to_recipients.%s", recipient, role) - } - } - } - - // Validate mailer settings - if c.SMTP == nil && c.Mailgun == nil { - return trace.BadParameter("provide either [mailgun] or [smtp] sections to work with plugin") - } - - // Validate Mailgun settings - if c.Mailgun != nil { - err := c.Mailgun.CheckAndSetDefaults() - if err != nil { - return trace.Wrap(err) - } - } - - if c.SMTP != nil { - err := c.SMTP.CheckAndSetDefaults() - if err != nil { - return trace.Wrap(err) - } - } - - return nil -} diff --git a/access/email/config_test.go b/access/email/config_test.go deleted file mode 100644 index 27c9add1f..000000000 --- a/access/email/config_test.go +++ /dev/null @@ -1,247 +0,0 @@ -/* -Copyright 2022 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "os" - "path/filepath" - "testing" - - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/access/common" - "github.com/gravitational/trace" - "github.com/stretchr/testify/require" - "gopkg.in/mail.v2" -) - -func TestRecipients(t *testing.T) { - testCases := []struct { - desc string - in string - expectErr require.ErrorAssertionFunc - expectRecipients common.RawRecipientsMap - }{ - { - desc: "test delivery recipients", - in: ` - [mailgun] - domain = "x" - private_key = "y" - [delivery] - sender = "email@example.org" - recipients = ["email1@example.org","email2@example.org"] - `, - expectRecipients: common.RawRecipientsMap{ - types.Wildcard: []string{"email1@example.org", "email2@example.org"}, - }, - }, - { - desc: "test role_to_recipients", - in: ` - [mailgun] - domain = "x" - private_key = "y" - [delivery] - sender = "email@example.org" - - [role_to_recipients] - "dev" = ["dev@example.org","sre@example.org"] - "*" = "admin@example.org" - `, - expectRecipients: common.RawRecipientsMap{ - "dev": []string{"dev@example.org", "sre@example.org"}, - types.Wildcard: []string{"admin@example.org"}, - }, - }, - { - desc: "test role_to_recipients but no wildcard", - in: ` - [mailgun] - domain = "x" - private_key = "y" - [delivery] - sender = "email@example.org" - - [role_to_recipients] - "dev" = ["dev@example.org","sre@example.org"] - `, - expectErr: func(tt require.TestingT, e error, i ...interface{}) { - require.Error(t, e) - require.True(t, trace.IsBadParameter(e)) - }, - }, - { - desc: "test role_to_recipients with wildcard but empty list of recipients", - in: ` - [mailgun] - domain = "x" - private_key = "y" - [delivery] - sender = "email@example.org" - - [role_to_recipients] - "dev" = "email@example.org" - "*" = [] - `, - expectErr: func(tt require.TestingT, e error, i ...interface{}) { - require.Error(t, e) - require.True(t, trace.IsBadParameter(e)) - }, - }, - { - desc: "test no recipients or role_to_recipients", - in: ` - [mailgun] - domain = "x" - private_key = "y" - [delivery] - sender = "email@example.org" - `, - expectErr: func(tt require.TestingT, e error, i ...interface{}) { - require.Error(t, e) - require.True(t, trace.IsBadParameter(e)) - }, - }, - { - desc: "test recipients and role_to_recipients", - in: ` - [slack] - token = "token" - recipients = ["dev@example.org","admin@example.org"] - - [role_to_recipients] - "dev" = ["dev@example.org","admin@example.org"] - "*" = "admin@example.org" - `, - expectErr: func(tt require.TestingT, e error, i ...interface{}) { - require.Error(t, e) - require.True(t, trace.IsBadParameter(e)) - }, - }, - } - - for _, tc := range testCases { - t.Run(tc.desc, func(t *testing.T) { - filePath := filepath.Join(t.TempDir(), "config_test.toml") - err := os.WriteFile(filePath, []byte(tc.in), 0777) - require.NoError(t, err) - - c, err := LoadConfig(filePath) - if tc.expectErr != nil { - tc.expectErr(t, err) - return - } - - require.NoError(t, err) - require.Equal(t, tc.expectRecipients, c.RoleToRecipients) - }) - } -} - -func TestSMTPStartTLSPolicy(t *testing.T) { - for _, tc := range []struct { - desc string - in string - expectErr require.ErrorAssertionFunc - expectedPolicy mail.StartTLSPolicy - }{ - { - desc: "test no policy should fallback to mandatory", - in: ` - [smtp] - host = "http://example.org/" - username = "user1" - password = "hidden" - [role_to_recipients] - "*" = "admin@example.org" - `, - expectedPolicy: mail.MandatoryStartTLS, - }, - { - desc: "test mandatory policy should return Mandatory policy", - in: ` - [smtp] - host = "http://example.org/" - username = "user1" - password = "hidden" - starttls_policy = "mandatory" - [role_to_recipients] - "*" = "admin@example.org" - `, - expectedPolicy: mail.MandatoryStartTLS, - }, - { - desc: "test opportunistic policy should return Opportunistic policy", - in: ` - [smtp] - host = "http://example.org/" - username = "user1" - password = "hidden" - starttls_policy = "opportunistic" - [role_to_recipients] - "*" = "admin@example.org" - `, - expectedPolicy: mail.OpportunisticStartTLS, - }, - { - desc: "test disabled policy should return NoStartTLS policy", - in: ` - [smtp] - host = "http://example.org/" - username = "user1" - password = "hidden" - starttls_policy = "disabled" - [role_to_recipients] - "*" = "admin@example.org" - `, - expectedPolicy: mail.NoStartTLS, - }, - { - desc: "test invalid policy should return an error", - in: ` - [smtp] - host = "http://example.org/" - username = "user1" - password = "hidden" - starttls_policy = "insecure" - [role_to_recipients] - "*" = "admin@example.org" - `, - expectErr: func(tt require.TestingT, e error, i ...interface{}) { - require.Error(t, e) - require.True(t, trace.IsBadParameter(e)) - require.Contains(t, e.Error(), "invalid smtp.starttls_policy") - require.Contains(t, e.Error(), "mandatory, opportunistic, disabled") - }, - }, - } { - t.Run(tc.desc, func(t *testing.T) { - filePath := filepath.Join(t.TempDir(), "config_test.toml") - err := os.WriteFile(filePath, []byte(tc.in), 0777) - require.NoError(t, err) - - c, err := LoadConfig(filePath) - if tc.expectErr != nil { - tc.expectErr(t, err) - return - } - - require.NoError(t, err) - require.Equal(t, tc.expectedPolicy, c.SMTP.MailStartTLSPolicy) - }) - } -} diff --git a/access/email/email_test.go b/access/email/email_test.go deleted file mode 100644 index 3b6dbd5b8..000000000 --- a/access/email/email_test.go +++ /dev/null @@ -1,655 +0,0 @@ -/* -Copyright 2022 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "fmt" - "os/user" - "runtime" - "sort" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/google/uuid" - "github.com/gravitational/teleport/api/client/proto" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/teleport/integrations/lib/testing/integration" - "github.com/gravitational/trace" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/stretchr/testify/suite" -) - -const ( - // sender default message sender - sender = "noreply@example.com" - // allRecipient is a recipient for all messages sent - allRecipient = "all@example.com" - // mailgunMockPrivateKey private key for mock mailgun - mailgunMockPrivateKey = "000000" - // mailgunMockDomain domain for mock mailgun - mailgunMockDomain = "test.example.com" - // subjectIDSubstring indicates start of request id - subjectIDSubstring = "Role Request " - // newMessageCount number of original emails - newMessageCount = 3 - // reviewMessageCount nubmer of review emails per thread - reviewMessageCount = 6 - // resolveMessageCount number of resolve emails per thread - resolveMessageCount = 3 - // messageCountPerThread number of total messages per thread - messageCountPerThread = newMessageCount + reviewMessageCount + resolveMessageCount -) - -type EmailSuite struct { - integration.AuthSetup - appConfig Config - userNames struct { - ruler string - requestor string - reviewer1 string - reviewer2 string - plugin string - } - raceNumber int - mockMailgun *MockMailgunServer - - clients map[string]*integration.Client - teleportFeatures *proto.Features - teleportConfig lib.TeleportConfig -} - -func TestEmailClient(t *testing.T) { suite.Run(t, &EmailSuite{}) } - -func (s *EmailSuite) SetupSuite() { - var err error - t := s.T() - - s.AuthSetup.SetupSuite(t) - s.AuthSetup.SetupService() - - ctx := s.Context() - - s.raceNumber = runtime.GOMAXPROCS(0) - me, err := user.Current() - require.NoError(t, err) - - s.clients = make(map[string]*integration.Client) - - // Set up the user who has an access to all kinds of resources. - - s.userNames.ruler = me.Username + "-ruler@example.com" - client, err := s.Integration.MakeAdmin(s.Context(), s.Auth, s.userNames.ruler) - require.NoError(t, err) - s.clients[s.userNames.ruler] = client - - // Get the server features. - pong, err := client.Ping(s.Context()) - require.NoError(t, err) - teleportFeatures := pong.GetServerFeatures() - - var bootstrap integration.Bootstrap - - // Set up user who can request the access to role "editor". - - conditions := types.RoleConditions{Request: &types.AccessRequestConditions{Roles: []string{"editor"}}} - if teleportFeatures.AdvancedAccessWorkflows { - conditions.Request.Thresholds = []types.AccessReviewThreshold{{Approve: 2, Deny: 2}} - } - role, err := bootstrap.AddRole("foo", types.RoleSpecV6{Allow: conditions}) - require.NoError(t, err) - - user, err := bootstrap.AddUserWithRoles(me.Username+"@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.requestor = user.GetName() - - // Set up TWO users who can review access requests to role "editor". - - conditions = types.RoleConditions{} - if teleportFeatures.AdvancedAccessWorkflows { - conditions.ReviewRequests = &types.AccessReviewConditions{Roles: []string{"editor"}} - } - role, err = bootstrap.AddRole("foo-reviewer", types.RoleSpecV6{Allow: conditions}) - require.NoError(t, err) - - user, err = bootstrap.AddUserWithRoles(me.Username+"-reviewer1@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.reviewer1 = user.GetName() - - user, err = bootstrap.AddUserWithRoles(me.Username+"-reviewer2@example.com", role.GetName()) - require.NoError(t, err) - s.userNames.reviewer2 = user.GetName() - - // Set up plugin user. - - role, err = bootstrap.AddRole("access-email", types.RoleSpecV6{ - Allow: types.RoleConditions{ - Rules: []types.Rule{ - types.NewRule("access_request", []string{"list", "read"}), - types.NewRule("access_plugin_data", []string{"update"}), - }, - }, - }) - require.NoError(t, err) - - user, err = bootstrap.AddUserWithRoles("access-email", role.GetName()) - require.NoError(t, err) - s.userNames.plugin = user.GetName() - - // Bake all the resources. - - err = s.Integration.Bootstrap(ctx, s.Auth, bootstrap.Resources()) - require.NoError(t, err) - - // Initialize the clients. - - client, err = s.Integration.NewClient(ctx, s.Auth, s.userNames.requestor) - require.NoError(t, err) - s.clients[s.userNames.requestor] = client - - if teleportFeatures.AdvancedAccessWorkflows { - client, err = s.Integration.NewClient(ctx, s.Auth, s.userNames.reviewer1) - require.NoError(t, err) - s.clients[s.userNames.reviewer1] = client - - client, err = s.Integration.NewClient(ctx, s.Auth, s.userNames.reviewer2) - require.NoError(t, err) - s.clients[s.userNames.reviewer2] = client - } - - identityPath, err := s.Integration.Sign(ctx, s.Auth, s.userNames.plugin) - require.NoError(t, err) - - s.teleportConfig.Addr = s.Auth.AuthAddr().String() - s.teleportConfig.Identity = identityPath - s.teleportFeatures = teleportFeatures -} - -func (s *EmailSuite) SetupTest() { - t := s.T() - - err := logger.Setup(logger.Config{Severity: "debug"}) - require.NoError(t, err) - - s.mockMailgun = NewMockMailgunServer(s.raceNumber) - s.mockMailgun.Start() - t.Cleanup(s.mockMailgun.Stop) - - var conf Config - conf.Teleport = s.teleportConfig - conf.Mailgun = &MailgunConfig{ - PrivateKey: mailgunMockPrivateKey, - Domain: mailgunMockDomain, - APIBase: s.mockMailgun.GetURL(), - } - conf.Delivery.Sender = sender - conf.RoleToRecipients = map[string][]string{ - types.Wildcard: {allRecipient}, - } - - s.appConfig = conf - s.SetContextTimeout(5 * time.Minute) - - s.startApp() -} - -func (s *EmailSuite) startApp() { - t := s.T() - t.Helper() - - app, err := NewApp(s.appConfig) - require.NoError(t, err) - - s.StartApp(app) -} - -func (s *EmailSuite) ruler() *integration.Client { - return s.clients[s.userNames.ruler] -} - -func (s *EmailSuite) requestor() *integration.Client { - return s.clients[s.userNames.requestor] -} - -func (s *EmailSuite) reviewer1() *integration.Client { - return s.clients[s.userNames.reviewer1] -} - -func (s *EmailSuite) reviewer2() *integration.Client { - return s.clients[s.userNames.reviewer2] -} - -func (s *EmailSuite) newAccessRequest(suggestedReviewers []string) types.AccessRequest { - t := s.T() - t.Helper() - - req, err := types.NewAccessRequest(uuid.New().String(), s.userNames.requestor, "editor") - require.NoError(t, err) - req.SetRequestReason("because of") - req.SetSuggestedReviewers(suggestedReviewers) - - return req -} - -func (s *EmailSuite) createAccessRequest(suggestedReviewers []string) types.AccessRequest { - t := s.T() - t.Helper() - - req := s.newAccessRequest(suggestedReviewers) - out, err := s.requestor().CreateAccessRequestV2(s.Context(), req) - require.NoError(t, err) - return out -} - -func (s *EmailSuite) checkPluginData(reqID string, cond func(PluginData) bool) PluginData { - t := s.T() - t.Helper() - - for { - rawData, err := s.ruler().PollAccessRequestPluginData(s.Context(), "email", reqID) - require.NoError(t, err) - if data := DecodePluginData(rawData); cond(data) { - return data - } - } -} - -func (s *EmailSuite) TestNewThreads() { - t := s.T() - - request := s.createAccessRequest([]string{s.userNames.reviewer1, s.userNames.reviewer2}) - - pluginData := s.checkPluginData(request.GetName(), func(data PluginData) bool { - return len(data.EmailThreads) > 0 - }) - require.Len(t, pluginData.EmailThreads, 3) // 2 recipients + all@example.com - - var messages = s.getMessages(s.Context(), t, 3) - - require.Len(t, messages, 3) - - // Senders - require.Equal(t, sender, messages[0].Sender) - require.Equal(t, sender, messages[1].Sender) - require.Equal(t, sender, messages[2].Sender) - - // Recipients - expectedRecipients := []string{allRecipient, s.userNames.reviewer1, s.userNames.reviewer2} - actualRecipients := []string{messages[0].Recipient, messages[1].Recipient, messages[2].Recipient} - sort.Strings(expectedRecipients) - sort.Strings(actualRecipients) - - require.Equal(t, expectedRecipients, actualRecipients) - - // Subjects - require.Contains(t, messages[0].Subject, request.GetName()) - require.Contains(t, messages[1].Subject, request.GetName()) - require.Contains(t, messages[2].Subject, request.GetName()) - - // Body - require.Contains(t, messages[0].Body, fmt.Sprintf("User: %v", s.userNames.requestor)) - require.Contains(t, messages[1].Body, "Reason: because of") - require.Contains(t, messages[2].Body, "Status: ⏳ PENDING") -} - -func (s *EmailSuite) TestApproval() { - t := s.T() - - req := s.createAccessRequest([]string{s.userNames.reviewer1}) - - s.skipMessages(s.Context(), t, 2) - - err := s.ruler().ApproveAccessRequest(s.Context(), req.GetName(), "okay") - require.NoError(t, err) - - messages := s.getMessages(s.Context(), t, 2) - - recipients := []string{messages[0].Recipient, messages[1].Recipient} - - require.Contains(t, recipients, allRecipient) - require.Contains(t, recipients, s.userNames.reviewer1) - - require.Contains(t, messages[0].Body, "Status: ✅ APPROVED (okay)") -} - -func (s *EmailSuite) TestDenial() { - t := s.T() - - req := s.createAccessRequest([]string{s.userNames.reviewer1}) - - s.skipMessages(s.Context(), t, 2) - - err := s.ruler().DenyAccessRequest(s.Context(), req.GetName(), "not okay") - require.NoError(t, err) - - messages := s.getMessages(s.Context(), t, 2) - - recipients := []string{messages[0].Recipient, messages[1].Recipient} - - require.Contains(t, recipients, allRecipient) - require.Contains(t, recipients, s.userNames.reviewer1) - - require.Contains(t, messages[0].Body, "Status: ❌ DENIED (not okay)") -} - -func (s *EmailSuite) TestReviewReplies() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - req := s.createAccessRequest([]string{s.userNames.reviewer1}) - s.checkPluginData(req.GetName(), func(data PluginData) bool { - return len(data.EmailThreads) > 0 - }) - - s.skipMessages(s.Context(), t, 2) - - err := s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer1, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }) - require.NoError(t, err) - - messages := s.getMessages(s.Context(), t, 2) - - reply := messages[0].Body - - require.Contains(t, reply, s.userNames.reviewer1+" reviewed the request", "reply must contain a review author") - require.Contains(t, reply, "Resolution: ✅ APPROVED", "reply must contain a proposed state") - require.Contains(t, reply, "Reason: okay", "reply must contain a reason") - - err = s.reviewer2().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer2, - ProposedState: types.RequestState_DENIED, - Created: time.Now(), - Reason: "not okay", - }) - require.NoError(t, err) - - messages = s.getMessages(s.Context(), t, 2) - - reply = messages[0].Body - - require.Contains(t, reply, s.userNames.reviewer2+" reviewed the request", "reply must contain a review author") - require.Contains(t, reply, "Resolution: ❌ DENIED", "reply must contain a proposed state") - require.Contains(t, reply, "Reason: not okay", "reply must contain a reason") -} - -func (s *EmailSuite) TestApprovalByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - req := s.createAccessRequest([]string{s.userNames.reviewer2}) - - s.skipMessages(s.Context(), t, 2) - - err := s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer1, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }) - require.NoError(t, err) - - messages := s.getMessages(s.Context(), t, 2) - - require.Contains(t, messages[0].Body, s.userNames.reviewer1+" reviewed the request", "reply must contain a review author") - - err = s.reviewer2().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer2, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "finally okay", - }) - require.NoError(t, err) - - messages = s.getMessages(s.Context(), t, 2) - require.Contains(t, messages[0].Body, s.userNames.reviewer2+" reviewed the request", "reply must contain a review author") - - messages = s.getMessages(s.Context(), t, 2) - require.Contains(t, messages[0].Body, "Status: ✅ APPROVED (finally okay)") -} - -func (s *EmailSuite) TestDenialByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - req := s.createAccessRequest([]string{s.userNames.requestor}) - - s.skipMessages(s.Context(), t, 2) - - err := s.reviewer1().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer1, - ProposedState: types.RequestState_DENIED, - Created: time.Now(), - Reason: "not okay", - }) - require.NoError(t, err) - - messages := s.getMessages(s.Context(), t, 2) - require.Contains(t, messages[0].Body, s.userNames.reviewer1+" reviewed the request", "reply must contain a review author") - - err = s.reviewer2().SubmitAccessRequestReview(s.Context(), req.GetName(), types.AccessReview{ - Author: s.userNames.reviewer2, - ProposedState: types.RequestState_DENIED, - Created: time.Now(), - Reason: "finally not okay", - }) - require.NoError(t, err) - - messages = s.getMessages(s.Context(), t, 2) - require.Contains(t, messages[0].Body, s.userNames.reviewer2+" reviewed the request", "reply must contain a review author") - - messages = s.getMessages(s.Context(), t, 2) - require.Contains(t, messages[0].Body, "Status: ❌ DENIED (finally not okay)") -} - -func (s *EmailSuite) TestExpiration() { - t := s.T() - - request := s.createAccessRequest([]string{s.userNames.requestor}) - s.skipMessages(s.Context(), t, 2) - - s.checkPluginData(request.GetName(), func(data PluginData) bool { - return len(data.EmailThreads) > 0 - }) - - err := s.ruler().DeleteAccessRequest(s.Context(), request.GetName()) // simulate expiration - require.NoError(t, err) - - messages := s.getMessages(s.Context(), t, 2) - require.Contains(t, messages[0].Body, "Status: ⌛ EXPIRED") -} - -func (s *EmailSuite) TestRace() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - err := logger.Setup(logger.Config{Severity: "info"}) // Turn off noisy debug logging - require.NoError(t, err) - - s.SetContextTimeout(20 * time.Second) - - var ( - raceErr error - raceErrOnce sync.Once - msgIDs sync.Map - msgCount int32 - threadIDs sync.Map - replyIDs sync.Map - resolveIDs sync.Map - ) - setRaceErr := func(err error) error { - raceErrOnce.Do(func() { - raceErr = err - }) - return err - } - incCounter := func(m *sync.Map, id string) { - var newCounter int32 - val, _ := m.LoadOrStore(id, &newCounter) - counterPtr := val.(*int32) - atomic.AddInt32(counterPtr, 1) - } - - process := lib.NewProcess(s.Context()) - for i := 0; i < s.raceNumber; i++ { - process.SpawnCritical(func(ctx context.Context) error { - req, err := types.NewAccessRequest(uuid.New().String(), s.userNames.requestor, "editor") - if err != nil { - return setRaceErr(trace.Wrap(err)) - } - req.SetSuggestedReviewers([]string{s.userNames.reviewer1, s.userNames.reviewer2}) - if _, err := s.requestor().CreateAccessRequestV2(ctx, req); err != nil { - return setRaceErr(trace.Wrap(err)) - } - return nil - }) - } - - // 3 original messages + 2*3 reviews + 3 resolve - for i := 0; i < messageCountPerThread*s.raceNumber; i++ { - process.SpawnCritical(func(ctx context.Context) error { - msg, err := s.mockMailgun.GetMessage(ctx) - if err != nil { - return setRaceErr(trace.Wrap(err)) - } - - if _, loaded := msgIDs.LoadOrStore(msg.ID, struct{}{}); loaded { - return setRaceErr(trace.Errorf("message %v already stored", msg.ID)) - } - atomic.AddInt32(&msgCount, 1) - - reqID := s.extractRequestID(msg.Subject) - - // Handle thread creation notifications - if strings.Contains(msg.Body, "You have a new Role Request") { - incCounter(&threadIDs, reqID) - - // We must approve message if it's not an all recipient - if msg.Recipient != allRecipient { - if err = s.clients[msg.Recipient].SubmitAccessRequestReview(ctx, reqID, types.AccessReview{ - Author: msg.Recipient, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }); err != nil { - return setRaceErr(trace.Wrap(err)) - } - } - } else if strings.Contains(msg.Body, "reviewed the request") { // Review - incCounter(&replyIDs, reqID) - } else if strings.Contains(msg.Body, "has been resolved") { // Resolution - incCounter(&resolveIDs, reqID) - } - - return nil - }) - } - - process.Terminate() - <-process.Done() - require.NoError(t, raceErr) - - threadIDs.Range(func(key, value interface{}) bool { - next := true - - val, loaded := threadIDs.LoadAndDelete(key) - next = next && assert.True(t, loaded) - - c, ok := val.(*int32) - require.True(t, ok) - require.Equal(t, int32(newMessageCount), *c) - - return next - }) - - replyIDs.Range(func(key, value interface{}) bool { - next := true - - val, loaded := replyIDs.LoadAndDelete(key) - next = next && assert.True(t, loaded) - - c, ok := val.(*int32) - require.True(t, ok) - require.Equal(t, int32(reviewMessageCount), *c) - - return next - }) - - resolveIDs.Range(func(key, value interface{}) bool { - next := true - - val, loaded := resolveIDs.LoadAndDelete(key) - next = next && assert.True(t, loaded) - - c, ok := val.(*int32) - require.True(t, ok) - require.Equal(t, int32(resolveMessageCount), *c) - - return next - }) - - // Total message count: - // (3 original threads + 6 review replies + 3 * resolve) * number of processes - require.Equal(t, int32(messageCountPerThread*s.raceNumber), msgCount) -} - -// skipEmails ensures that emails were received, but dumps the contents -func (s *EmailSuite) skipMessages(ctx context.Context, t *testing.T, n int) { - for i := 0; i < n; i++ { - _, err := s.mockMailgun.GetMessage(ctx) - require.NoError(t, err) - } -} - -// getMessages returns next n email messages -func (s *EmailSuite) getMessages(ctx context.Context, t *testing.T, n int) []MockMailgunMessage { - messages := make([]MockMailgunMessage, n) - for i := 0; i < n; i++ { - m, err := s.mockMailgun.GetMessage(ctx) - require.NoError(t, err) - messages[i] = m - } - - return messages -} - -// extractRequestID extracts request id from a subject -func (s *EmailSuite) extractRequestID(subject string) string { - idx := strings.Index(subject, subjectIDSubstring) - return subject[idx+len(subjectIDSubstring):] -} diff --git a/access/email/mailers.go b/access/email/mailers.go deleted file mode 100644 index 443b0d5d6..000000000 --- a/access/email/mailers.go +++ /dev/null @@ -1,149 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific languap governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "crypto/rand" - "encoding/binary" - "fmt" - "os" - "strconv" - "strings" - "time" - - "github.com/gravitational/trace" - "github.com/mailgun/mailgun-go/v4" - "gopkg.in/mail.v2" -) - -// Mailer is an interface to mail sender -type Mailer interface { - Send(ctx context.Context, id, recipient, body, references string) (string, error) -} - -// SMTPMailer implements SMTP mailer -type SMTPMailer struct { - dialer *mail.Dialer - sender string - clusterName string -} - -// MailgunMailer implements mailgun mailer -type MailgunMailer struct { - mailgun *mailgun.MailgunImpl - sender string - clusterName string -} - -// NewSMTPMailer inits new SMTP mailer -func NewSMTPMailer(c SMTPConfig, sender, clusterName string) Mailer { - dialer := mail.NewDialer(c.Host, c.Port, c.Username, c.Password) - dialer.StartTLSPolicy = c.MailStartTLSPolicy - - return &SMTPMailer{dialer, sender, clusterName} -} - -// NewMailgunMailer inits new Mailgun mailer -func NewMailgunMailer(c MailgunConfig, sender, clusterName string) Mailer { - m := mailgun.NewMailgun(c.Domain, c.PrivateKey) - if c.APIBase != "" { - m.SetAPIBase(c.APIBase) - } - return &MailgunMailer{m, sender, clusterName} -} - -// Send sends email via SMTP -func (m *SMTPMailer) Send(ctx context.Context, id, recipient, body, references string) (string, error) { - subject := fmt.Sprintf("%v Role Request %v", m.clusterName, id) - refHeader := fmt.Sprintf("<%v>", references) - - id, err := m.genMessageID() - if err != nil { - return "", trace.Wrap(err) - } - - msg := mail.NewMessage() - - msg.SetHeader("From", m.sender) - msg.SetHeader("To", recipient) - msg.SetHeader("Subject", subject) - msg.SetHeader("Message-ID", fmt.Sprintf("<%v>", id)) - msg.SetBody("text/plain", body) - - if references != "" { - msg.SetHeader("References", refHeader) - msg.SetHeader("In-Reply-To", refHeader) - } - - err = m.dialer.DialAndSend(msg) - if err != nil { - return "", trace.Wrap(err) - } - - return id, nil -} - -// genMessageID generates Message-ID header value -func (m *SMTPMailer) genMessageID() (string, error) { - now := uint64(time.Now().UnixNano()) - - nonceByte := make([]byte, 8) - if _, err := rand.Read(nonceByte); err != nil { - return "", trace.Wrap(err) - } - nonce := binary.BigEndian.Uint64(nonceByte) - - hostname, err := os.Hostname() - if err != nil { - return "", trace.Wrap(err) - } - - msgID := fmt.Sprintf("%s.%s@%s", m.base36(now), m.base36(nonce), hostname) - - return msgID, nil -} - -// base36 converts given value to a base 36 numbering system -func (m *SMTPMailer) base36(input uint64) string { - return strings.ToUpper(strconv.FormatUint(input, 36)) -} - -// Send sends email via Mailgun -func (m *MailgunMailer) Send(ctx context.Context, id, recipient, body, references string) (string, error) { - subject := fmt.Sprintf("%v Role Request %v", m.clusterName, id) - refHeader := fmt.Sprintf("<%v>", references) - - msg := m.mailgun.NewMessage(m.sender, subject, body, recipient) - msg.SetRequireTLS(true) - - if references != "" { - msg.AddHeader("References", refHeader) - msg.AddHeader("In-Reply-To", refHeader) - } - - ctx, cancel := context.WithTimeout(ctx, time.Second*10) - defer cancel() - - _, id, err := m.mailgun.Send(ctx, msg) - - if err != nil { - return "", trace.Wrap(err) - } - - return id, nil -} diff --git a/access/email/main.go b/access/email/main.go index 8722a6a9c..da4c999d7 100644 --- a/access/email/main.go +++ b/access/email/main.go @@ -23,6 +23,7 @@ import ( "time" "github.com/gravitational/kingpin" + "github.com/gravitational/teleport/integrations/access/email" "github.com/gravitational/teleport/integrations/lib" "github.com/gravitational/teleport/integrations/lib/logger" "github.com/gravitational/trace" @@ -51,7 +52,7 @@ func main() { switch selectedCmd { case "configure": - fmt.Print(exampleConfig) + fmt.Print(email.ExampleConfig) case "version": lib.PrintVersion(app.Name, Version, Gitref) case "start": @@ -64,7 +65,7 @@ func main() { } func run(configPath string, debug bool) error { - conf, err := LoadConfig(configPath) + conf, err := email.LoadConfig(configPath) if err != nil { return trace.Wrap(err) } @@ -84,7 +85,7 @@ func run(configPath string, debug bool) error { logger.Standard().Warn("The delivery.recipients config option is deprecated, set role_to_recipients[\"*\"] instead for the same functionality") } - app, err := NewApp(*conf) + app, err := email.NewApp(*conf) if err != nil { return trace.Wrap(err) } diff --git a/access/email/mock_mailgun.go b/access/email/mock_mailgun.go deleted file mode 100644 index b9b530379..000000000 --- a/access/email/mock_mailgun.go +++ /dev/null @@ -1,109 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific languap governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - - "github.com/google/uuid" - "github.com/gravitational/trace" - log "github.com/sirupsen/logrus" -) - -const ( - // multipartFormBufSize is a buffer size for ParseMultipartForm - multipartFormBufSize = 8192 -) - -// MockMailgunMessage is a mock mailgun message -type MockMailgunMessage struct { - ID string - Sender string - Recipient string - Subject string - Body string - References string -} - -// mockMailgun is a mock mailgun server -type MockMailgunServer struct { - server *httptest.Server - chMessages chan MockMailgunMessage -} - -// NewMockMailgun creates unstarted mock mailgun server instance. -// Standard server from mailgun-go does not catch message texts. -func NewMockMailgunServer(concurrency int) *MockMailgunServer { - mg := &MockMailgunServer{ - chMessages: make(chan MockMailgunMessage, concurrency*50), - } - - s := httptest.NewUnstartedServer(func(mg *MockMailgunServer) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseMultipartForm(multipartFormBufSize); err != nil { - log.Error(err) - } - - id := uuid.New().String() - - message := MockMailgunMessage{ - ID: id, - Sender: r.PostFormValue("from"), - Recipient: r.PostFormValue("to"), - Subject: r.PostFormValue("subject"), - Body: r.PostFormValue("text"), - References: r.PostFormValue("references"), - } - - mg.chMessages <- message - - fmt.Fprintf(w, `{"id": "%v"}`, id) - } - }(mg)) - - mg.server = s - - return mg -} - -// Start starts server -func (m *MockMailgunServer) Start() { - m.server.Start() -} - -// GetURL returns server url -func (m *MockMailgunServer) GetURL() string { - return m.server.URL + "/v4" -} - -// GetMessage gets the new Mailgun message from a queue -func (m *MockMailgunServer) GetMessage(ctx context.Context) (MockMailgunMessage, error) { - select { - case message := <-m.chMessages: - return message, nil - case <-ctx.Done(): - return MockMailgunMessage{}, trace.Wrap(ctx.Err()) - } -} - -// Close stops servers -func (m *MockMailgunServer) Stop() { - m.server.Close() -} diff --git a/access/email/plugindata.go b/access/email/plugindata.go deleted file mode 100644 index 351ec8f48..000000000 --- a/access/email/plugindata.go +++ /dev/null @@ -1,112 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "fmt" - "strconv" - "strings" - - "github.com/gravitational/teleport/api/types" -) - -// PluginData is a data associated with access request that we store in Teleport using UpdatePluginData API. -type PluginData struct { - RequestData - EmailThreads []EmailThread -} - -// Resolution is a review request resolution result -type Resolution struct { - Tag ResolutionTag - Reason string -} -type ResolutionTag string - -const Unresolved = ResolutionTag("") -const ResolvedApproved = ResolutionTag("APPROVED") -const ResolvedDenied = ResolutionTag("DENIED") -const ResolvedExpired = ResolutionTag("EXPIRED") - -// RequestData represents part of plugin data responsible for review request -type RequestData struct { - User string - Roles []string - RequestReason string - ReviewsCount int - Resolution Resolution -} - -// EmailThread stores value about particular original message -type EmailThread struct { - Email string - MessageID string - Timestamp string -} - -// NewRequestData converts types.AccessRequest to RequestData -func NewRequestData(req types.AccessRequest) RequestData { - return RequestData{User: req.GetUser(), Roles: req.GetRoles(), RequestReason: req.GetRequestReason()} -} - -// DecodePluginData deserializes a string map to PluginData struct. -func DecodePluginData(dataMap map[string]string) (data PluginData) { - data.User = dataMap["user"] - if str := dataMap["roles"]; str != "" { - data.Roles = strings.Split(str, ",") - } - data.RequestReason = dataMap["request_reason"] - if str := dataMap["reviews_count"]; str != "" { - data.ReviewsCount, _ = strconv.Atoi(dataMap["reviews_count"]) - } - data.Resolution.Tag = ResolutionTag(dataMap["resolution"]) - data.Resolution.Reason = dataMap["resolve_reason"] - if email, timestamp := dataMap["email"], dataMap["timestamp"]; email != "" && timestamp != "" { - data.EmailThreads = append(data.EmailThreads, EmailThread{Email: email, Timestamp: timestamp}) - } - if str := dataMap["email_threads"]; str != "" { - for _, encodedThread := range strings.Split(str, ",") { - if parts := strings.Split(encodedThread, "/"); len(parts) == 3 { - data.EmailThreads = append(data.EmailThreads, EmailThread{Email: parts[0], Timestamp: parts[1], MessageID: parts[2]}) - } - } - } - return -} - -// EncodePluginData serializes a PluginData struct into a string map. -func EncodePluginData(data PluginData) map[string]string { - result := make(map[string]string) - - result["user"] = data.User - result["roles"] = strings.Join(data.Roles, ",") - result["request_reason"] = data.RequestReason - var reviewsCountStr string - if data.ReviewsCount > 0 { - reviewsCountStr = fmt.Sprintf("%d", data.ReviewsCount) - } - result["reviews_count"] = reviewsCountStr - result["resolution"] = string(data.Resolution.Tag) - result["resolve_reason"] = data.Resolution.Reason - var emailThreads []string - for _, t := range data.EmailThreads { - emailThreads = append(emailThreads, fmt.Sprintf("%s/%s/%s", t.Email, t.Timestamp, t.MessageID)) - } - result["email_threads"] = strings.Join(emailThreads, ",") - - return result -} diff --git a/access/email/plugindata_test.go b/access/email/plugindata_test.go deleted file mode 100644 index 942481588..000000000 --- a/access/email/plugindata_test.go +++ /dev/null @@ -1,67 +0,0 @@ -/* -Copyright 2015-2021 Gravitational, Inc. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "testing" - - "github.com/stretchr/testify/require" -) - -var samplePluginData = PluginData{ - RequestData: RequestData{ - User: "user-foo", - Roles: []string{"role-foo", "role-bar"}, - RequestReason: "foo reason", - ReviewsCount: 3, - Resolution: Resolution{Tag: ResolvedApproved, Reason: "foo ok"}, - }, - EmailThreads: []EmailThread{ - {Email: "E1", MessageID: "M1", Timestamp: "0000001"}, - {Email: "E2", MessageID: "M2", Timestamp: "0000002"}, - }, -} - -func TestEncodeDecodePluginData(t *testing.T) { - dataMap := EncodePluginData(samplePluginData) - require.Len(t, dataMap, 7) - require.Equal(t, dataMap, map[string]string{ - "user": "user-foo", - "roles": "role-foo,role-bar", - "request_reason": "foo reason", - "reviews_count": "3", - "resolution": "APPROVED", - "resolve_reason": "foo ok", - "email_threads": "E1/0000001/M1,E2/0000002/M2", - }) - - pluginData := DecodePluginData(dataMap) - require.Equal(t, samplePluginData, pluginData) -} - -func TestEncodeEmptyPluginData(t *testing.T) { - dataMap := EncodePluginData(PluginData{}) - require.Len(t, dataMap, 7) - for key, value := range dataMap { - require.Emptyf(t, value, "value at key %q must be empty", key) - } -} - -func TestDecodeEmptyPluginData(t *testing.T) { - require.Empty(t, DecodePluginData(nil)) - require.Empty(t, DecodePluginData(make(map[string]string))) -}