diff --git a/access/mattermost/app.go b/access/mattermost/app.go deleted file mode 100644 index 5ea41f804..000000000 --- a/access/mattermost/app.go +++ /dev/null @@ -1,562 +0,0 @@ -// Copyright 2023 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" - "strings" - "time" - - "github.com/gravitational/teleport/api/client" - "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/backoff" - "github.com/gravitational/teleport/integrations/lib/credentials" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/teleport/integrations/lib/stringset" - "github.com/gravitational/teleport/integrations/lib/watcherjob" - "github.com/gravitational/trace" - "github.com/jonboulle/clockwork" - "google.golang.org/grpc" - grpcbackoff "google.golang.org/grpc/backoff" -) - -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 = "mattermost" - // grpcBackoffMaxDelay is a maximum time GRPC client waits before reconnection attempt. - grpcBackoffMaxDelay = time.Second * 2 - // 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 - // modifyPluginDataBackoffBase is an initial (minimum) backoff value. - modifyPluginDataBackoffBase = time.Millisecond - // modifyPluginDataBackoffMax is a backoff threshold - modifyPluginDataBackoffMax = time.Second -) - -// App contains global application state. -type App struct { - conf Config - - apiClient *client.Client - bot Bot - mainJob lib.ServiceJob - - *lib.Process -} - -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) -} - -func (a *App) run(ctx context.Context) error { - var err error - - log := logger.Get(ctx) - log.Infof("Starting Teleport Access Mattermost 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{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()) -} - -func (a *App) init(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, initTimeout) - defer cancel() - log := logger.Get(ctx) - - if validCred, err := credentials.CheckIfExpired(a.conf.Teleport.Credentials()); err != nil { - log.Warn(err) - if !validCred { - return trace.BadParameter( - "No valid credentials found, this likely means credentials are expired. In this case, please sign new credentials and increase their TTL if needed.", - ) - } - log.Info("At least one non-expired credential has been found, continuing startup") - } - - var ( - err error - pong proto.PingResponse - ) - - bk := grpcbackoff.DefaultConfig - bk.MaxDelay = grpcBackoffMaxDelay - if a.apiClient, err = client.New(ctx, client.Config{ - Addrs: a.conf.Teleport.GetAddrs(), - Credentials: a.conf.Teleport.Credentials(), - DialOpts: []grpc.DialOption{ - grpc.WithConnectParams(grpc.ConnectParams{Backoff: bk, MinConnectTimeout: initTimeout}), - grpc.WithDefaultCallOptions( - grpc.WaitForReady(true), - ), - grpc.WithReturnConnectionError(), - }, - }); err != nil { - return trace.Wrap(err) - } - - if pong, err = a.checkTeleportVersion(ctx); err != nil { - return trace.Wrap(err) - } - - var webProxyAddr string - if pong.ServerFeatures.AdvancedAccessWorkflows { - webProxyAddr = pong.ProxyPublicAddr - } - a.bot, err = NewBot(a.conf.Mattermost, pong.ClusterName, webProxyAddr) - if err != nil { - return trace.Wrap(err) - } - - log.Debug("Starting Mattermost API health check...") - if err = a.bot.HealthCheck(ctx); err != nil { - return trace.Wrap(err, "api health check failed. Check your token and make sure that bot is added to your team") - } - - log.Debug("Mattermost API health check finished ok") - return nil -} - -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) -} - -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) - } -} - -func (a *App) onPendingRequest(ctx context.Context, req types.AccessRequest) error { - log := logger.Get(ctx) - - reqID := req.GetName() - reqData := RequestData{User: req.GetUser(), Roles: req.GetRoles(), RequestReason: req.GetRequestReason()} - - 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 channels := a.getPostRecipients(ctx, req.GetSuggestedReviewers()); len(channels) > 0 { - if err := a.broadcastMessages(ctx, channels, reqID, reqData); err != nil { - return trace.Wrap(err) - } - } else { - log.Warning("No channel to post") - } - } - - if reqReviews := req.GetReviews(); len(reqReviews) > 0 { - if err := a.postReviewComments(ctx, reqID, reqReviews); err != nil { - return trace.Wrap(err) - } - } - - return nil -} - -func (a *App) onResolvedRequest(ctx context.Context, req types.AccessRequest) error { - var commentErr error - if err := a.postReviewComments(ctx, req.GetName(), req.GetReviews()); err != nil { - commentErr = 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 commentErr - } - err := trace.Wrap(a.updatePosts(ctx, req.GetName(), resolution)) - return trace.NewAggregate(commentErr, err) -} - -func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { - return a.updatePosts(ctx, reqID, Resolution{Tag: ResolvedExpired}) -} - -func (a *App) broadcastMessages(ctx context.Context, channels []string, reqID string, reqData RequestData) error { - mmData, err := a.bot.Broadcast(ctx, channels, reqID, reqData) - if len(mmData) == 0 && err != nil { - return trace.Wrap(err) - } - for _, data := range mmData { - logger.Get(ctx).WithFields(logger.Fields{ - "mm_channel_id": data.ChannelID, - "mm_post_id": data.PostID, - }).Info("Successfully posted to Mattermost") - } - if err != nil { - logger.Get(ctx).WithError(err).Error("Failed to post one or more messages to Mattermost") - } - - _, 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.MattermostData = mmData - return pluginData, true - }) - return trace.Wrap(err) -} - -func (a *App) postReviewComments(ctx context.Context, reqID string, reqReviews []types.AccessReview) error { - var oldCount int - var mmData MattermostData - ok, err := a.modifyPluginData(ctx, reqID, func(existing *PluginData) (PluginData, bool) { - if existing == nil { - return PluginData{}, false - } - - if mmData = existing.MattermostData; len(mmData) == 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 comment: plugin data is missing") - return nil - } - - slice := reqReviews[oldCount:] - if len(slice) == 0 { - return nil - } - - errors := make([]error, 0, len(slice)) - for _, data := range mmData { - ctx, _ = logger.WithFields(ctx, logger.Fields{"mm_channel_id": data.ChannelID, "mm_post_id": data.PostID}) - for _, review := range slice { - if err := a.bot.PostReviewComment(ctx, data.ChannelID, data.PostID, review); err != nil { - errors = append(errors, err) - } - } - } - return trace.NewAggregate(errors...) -} - -func (a *App) tryLookupDirectChannel(ctx context.Context, userEmail string) string { - log := logger.Get(ctx).WithField("mm_user_email", userEmail) - channel, err := a.bot.LookupDirectChannel(ctx, userEmail) - if err != nil { - if errResult, ok := trace.Unwrap(err).(*ErrorResult); ok { - log.Warningf("Failed to lookup direct channel info: %q", errResult.Message) - } else { - log.WithError(err).Error("Failed to lookup direct channel info") - } - return "" - } - return channel -} - -func (a *App) tryLookupChannel(ctx context.Context, team, name string) string { - log := logger.Get(ctx).WithFields(logger.Fields{ - "mm_team": team, - "mm_channel": name, - }) - channel, err := a.bot.LookupChannel(ctx, team, name) - if err != nil { - if errResult, ok := trace.Unwrap(err).(*ErrorResult); ok { - log.Warningf("Failed to lookup channel info: %q", errResult.Message) - } else { - log.WithError(err).Error("Failed to lookup channel info") - } - return "" - } - return channel -} - -func (a *App) getPostRecipients(ctx context.Context, suggestedReviewers []string) []string { - log := logger.Get(ctx) - - channelSet := stringset.NewWithCap(len(suggestedReviewers) + len(a.conf.Mattermost.Recipients)) - - for _, recipient := range suggestedReviewers { - // We require SuggestedReviewers to contain email-like data. Anything else is not supported. - if !lib.IsEmail(recipient) { - log.Warningf("Failed to notify a suggested reviewer: %q does not look like a valid email", recipient) - continue - } - channel := a.tryLookupDirectChannel(ctx, recipient) - if channel == "" { - continue - } - channelSet.Add(channel) - } - - for _, recipient := range a.conf.Mattermost.Recipients { - var channel string - // Recipients from config file could contain either email or team and channel names separated by '/' symbol. It's up to user what format to use. - if lib.IsEmail(recipient) { - channel = a.tryLookupDirectChannel(ctx, recipient) - } else { - parts := strings.Split(recipient, "/") - if len(parts) == 2 { - channel = a.tryLookupChannel(ctx, parts[0], parts[1]) - } else { - log.Warningf("Recipient must be either a user email or a channel in the format \"team/channel\" but got %q", recipient) - } - } - if channel == "" { - continue - } - channelSet.Add(channel) - } - - return channelSet.ToSlice() -} - -func (a *App) updatePosts(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 mattermost post IDs, we cannot do anything. - if existing == nil { - return PluginData{}, false - } - if pluginData = *existing; len(pluginData.MattermostData) == 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 posts: plugin data is missing") - return nil - } - - reqData, mmData := pluginData.RequestData, pluginData.MattermostData - if err := a.bot.UpdatePosts(ctx, reqID, reqData, mmData); err != nil { - return trace.Wrap(err) - } - - log.Infof("Successfully marked request as %s in all messages", resolution.Tag) - - 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) { - backoff := backoff.NewDecorr(modifyPluginDataBackoffBase, modifyPluginDataBackoffMax, clockwork.NewRealClock()) - for { - 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) { - return false, trace.Wrap(err) - } - if err := backoff.Do(ctx); err != nil { - return false, trace.Wrap(err) - } - } -} - -// 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 entry 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), - }) -} diff --git a/access/mattermost/bot.go b/access/mattermost/bot.go deleted file mode 100644 index 55f36c3a1..000000000 --- a/access/mattermost/bot.go +++ /dev/null @@ -1,432 +0,0 @@ -// Copyright 2023 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" - "net/http" - "net/url" - "strings" - "text/template" - "time" - - "github.com/go-resty/resty/v2" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/trace" - "github.com/mailgun/holster/v3/collections" -) - -const ( - mmMaxConns = 100 - mmHTTPTimeout = 10 * time.Second - mmCacheSize = 1024 -) - -var postTextTemplate = template.Must(template.New("description").Parse( - `{{if eq .Status "PENDING"}}*You have new pending request to review!*{{end}} -**User**: {{.User}} -**Roles**: {{range $index, $element := .Roles}}{{if $index}}, {{end}}{{ . }}{{end}} -**Request ID**: {{.ID}} -{{if .RequestReason}}**Reason**: {{.RequestReason}}{{end}} -**Status**: {{.StatusEmoji}} {{.Status}} -{{if .Resolution.Reason}}**Resolution reason**: {{.Resolution.Reason}}{{end}} -{{if .RequestLink}}**Link**: [{{.RequestLink}}]({{.RequestLink}}) -{{else if eq .Status "PENDING"}}**Approve**: ` + "`tsh request review --approve {{.ID}}`" + ` -**Deny**: ` + "`tsh request review --deny {{.ID}}`" + `{{end}}`, -)) -var reviewCommentTemplate = template.Must(template.New("review comment").Parse( - `{{.Author}} reviewed the request at {{.Created.Format .TimeFormat}}. -Resolution: {{.ProposedStateEmoji}} {{.ProposedState}}. -{{if .Reason}}Reason: {{.Reason}}.{{end}}`, -)) - -// Mattermost has a 4000 or 16k character limit for posts (depending on the -// configuration) so we truncate all reasons to a generous but conservative -// limit -const ( - requestReasonLimit = 500 - resolutionReasonLimit - reviewReasonLimit -) - -// Bot is a Mattermost client that works with access.Request. -type Bot struct { - client *resty.Client - clusterName string - webProxyURL *url.URL -} - -type getMeKey struct{} -type getChannelByTeamNameAndNameKey struct { - team string - name string -} -type getUserByEmail struct { - email string -} - -type etagCacheCtxKey struct{} - -type etagCacheEntry struct { - etag string - value interface{} -} - -func NewBot(conf MattermostConfig, clusterName, webProxyAddr string) (Bot, error) { - var ( - webProxyURL *url.URL - err error - ) - if webProxyAddr != "" { - if webProxyURL, err = lib.AddrToURL(webProxyAddr); err != nil { - return Bot{}, trace.Wrap(err) - } - } - - cache := collections.NewLRUCache(mmCacheSize) - - client := resty. - NewWithClient(&http.Client{ - Timeout: mmHTTPTimeout, - Transport: &http.Transport{ - MaxConnsPerHost: mmMaxConns, - MaxIdleConnsPerHost: mmMaxConns, - }, - }). - SetBaseURL(conf.URL). - SetHeader("Content-Type", "application/json"). - SetHeader("Accept", "application/json"). - SetHeader("Authorization", "BEARER "+conf.Token) - - // Error response parsing. - client.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error { - req.SetError(&ErrorResult{}) - return nil - }) - client.OnAfterResponse(func(_ *resty.Client, resp *resty.Response) error { - if !resp.IsError() { - return nil - } - - result := resp.Error() - if result == nil { - return nil - } - - if result, ok := result.(*ErrorResult); ok { - return trace.Wrap(result) - } - - return trace.Errorf("unknown error result %#v", result) - }) - - // ETag caching. - client.OnBeforeRequest(func(_ *resty.Client, req *resty.Request) error { - if req.Method != resty.MethodGet { - return nil - } - - cacheKey := req.Context().Value(etagCacheCtxKey{}) - if cacheKey == nil { - return nil - } - - val, ok := cache.Get(cacheKey) - if !ok { - return nil - } - - res, ok := val.(etagCacheEntry) - if !ok { - return trace.Errorf("etag cache entry of unknown type %T", val) - } - - req.SetHeader("If-None-Match", res.etag) - req.SetResult(res.value) - return nil - }) - client.OnAfterResponse(func(_ *resty.Client, resp *resty.Response) error { - req := resp.Request - if req.Method != resty.MethodGet { - return nil - } - - cacheKey := req.Context().Value(etagCacheCtxKey{}) - if cacheKey == nil { - return nil - } - - etag := resp.Header().Get("ETag") - if etag == "" { - return nil - } - - if resp.IsSuccess() || resp.StatusCode() == http.StatusNotModified { - cache.Add(cacheKey, etagCacheEntry{etag: etag, value: resp.Result()}) - } - - return nil - }) - - return Bot{ - client: client, - clusterName: clusterName, - webProxyURL: webProxyURL, - }, nil -} - -func (b Bot) HealthCheck(ctx context.Context) error { - _, err := b.GetMe(ctx) - return err -} - -func (b Bot) GetMe(ctx context.Context) (User, error) { - resp, err := b.client.NewRequest(). - SetContext(context.WithValue(ctx, etagCacheCtxKey{}, getMeKey{})). - SetResult(&User{}). - Get("api/v4/users/me") - if err != nil { - return User{}, trace.Wrap(err) - } - return userResult(resp) -} - -// Broadcast posts request info to Mattermost. -func (b Bot) Broadcast(ctx context.Context, channels []string, reqID string, reqData RequestData) (MattermostData, error) { - text, err := b.buildPostText(reqID, reqData) - if err != nil { - return nil, trace.Wrap(err) - } - - var data MattermostData - var errors []error - - for _, channel := range channels { - post := Post{ - ChannelID: channel, - Message: text, - } - _, err = b.client.NewRequest(). - SetContext(ctx). - SetBody(post). - SetResult(&post). - Post("api/v4/posts") - if err != nil { - errors = append(errors, trace.Wrap(err)) - continue - } - - data = append(data, MattermostDataPost{ChannelID: channel, PostID: post.ID}) - } - - return data, trace.NewAggregate(errors...) -} - -func (b Bot) PostReviewComment(ctx context.Context, channelID, rootID string, review types.AccessReview) error { - if review.Reason != "" { - review.Reason = lib.MarkdownEscape(review.Reason, reviewReasonLimit) - } - - var proposedStateEmoji string - switch review.ProposedState { - case types.RequestState_APPROVED: - proposedStateEmoji = "✅" - case types.RequestState_DENIED: - proposedStateEmoji = "❌" - } - - var builder strings.Builder - err := reviewCommentTemplate.Execute(&builder, struct { - types.AccessReview - ProposedState string - ProposedStateEmoji string - TimeFormat string - }{ - review, - review.ProposedState.String(), - proposedStateEmoji, - time.RFC822, - }) - if err != nil { - return trace.Wrap(err) - } - text := builder.String() - - _, err = b.client.NewRequest(). - SetContext(ctx). - SetBody(Post{ - ChannelID: channelID, - RootID: rootID, - Message: text, - }). - Post("api/v4/posts") - return trace.Wrap(err) -} - -// LookupChannel fetches channel id by its name and team name. -func (b Bot) LookupChannel(ctx context.Context, team, name string) (string, error) { - resp, err := b.client.NewRequest(). - SetContext(context.WithValue(ctx, etagCacheCtxKey{}, getChannelByTeamNameAndNameKey{team: team, name: name})). - SetPathParams(map[string]string{"team": team, "name": name}). - SetResult(&Channel{}). - Get("api/v4/teams/name/{team}/channels/name/{name}") - if err != nil { - return "", trace.Wrap(err) - } - - channel, err := channelResult(resp) - if err != nil { - return "", trace.Wrap(err) - } - - return channel.ID, nil -} - -// LookupDirectChannel fetches user's direct message channel id by email. -func (b Bot) LookupDirectChannel(ctx context.Context, email string) (string, error) { - resp, err := b.client.NewRequest(). - SetContext(context.WithValue(ctx, etagCacheCtxKey{}, getUserByEmail{email: email})). - SetPathParams(map[string]string{"email": email}). - SetResult(&User{}). - Get("api/v4/users/email/{email}") - if err != nil { - return "", trace.Wrap(err) - } - user, err := userResult(resp) - if err != nil { - return "", trace.Wrap(err) - } - - me, err := b.GetMe(ctx) - if err != nil { - return "", trace.Wrap(err) - } - - resp, err = b.client.NewRequest(). - SetContext(ctx). - SetBody([]string{me.ID, user.ID}). - SetResult(&Channel{}). - Post("api/v4/channels/direct") - if err != nil { - return "", trace.Wrap(err) - } - channel, err := channelResult(resp) - if err != nil { - return "", trace.Wrap(err) - } - - return channel.ID, nil -} - -func (b Bot) UpdatePosts(ctx context.Context, reqID string, reqData RequestData, mmData MattermostData) error { - text, err := b.buildPostText(reqID, reqData) - if err != nil { - return trace.Wrap(err) - } - - var errors []error - for _, msg := range mmData { - post := Post{ - ChannelID: msg.ChannelID, - ID: msg.PostID, - Message: text, - } - _, err := b.client.NewRequest(). - SetContext(ctx). - SetBody(post). - SetPathParams(map[string]string{"postID": msg.PostID}). - Put("api/v4/posts/{postID}") - if err != nil { - errors = append(errors, trace.Wrap(err)) - } - } - - return trace.NewAggregate(errors...) -} - -func (b Bot) buildPostText(reqID string, reqData RequestData) (string, error) { - resolutionTag := reqData.Resolution.Tag - - if reqData.RequestReason != "" { - reqData.RequestReason = lib.MarkdownEscape(reqData.RequestReason, requestReasonLimit) - } - if reqData.Resolution.Reason != "" { - reqData.Resolution.Reason = lib.MarkdownEscape(reqData.Resolution.Reason, resolutionReasonLimit) - } - - var statusEmoji string - status := string(resolutionTag) - switch resolutionTag { - case Unresolved: - status = "PENDING" - statusEmoji = "⏳" - case ResolvedApproved: - statusEmoji = "✅" - case ResolvedDenied: - statusEmoji = "❌" - case ResolvedExpired: - statusEmoji = "⌛" - } - - var requestLink string - if b.webProxyURL != nil { - reqURL := *b.webProxyURL - reqURL.Path = lib.BuildURLPath("web", "requests", reqID) - requestLink = reqURL.String() - } - - var ( - builder strings.Builder - err error - ) - - err = postTextTemplate.Execute(&builder, struct { - ID string - Status string - StatusEmoji string - RequestLink string - RequestData - }{ - reqID, - status, - statusEmoji, - requestLink, - reqData, - }) - if err != nil { - return "", trace.Wrap(err) - } - - return builder.String(), nil -} - -func userResult(resp *resty.Response) (User, error) { - result := resp.Result() - ptr, ok := result.(*User) - if !ok { - return User{}, trace.Errorf("unknown result type %T", result) - } - return *ptr, nil -} - -func channelResult(resp *resty.Response) (Channel, error) { - result := resp.Result() - ptr, ok := result.(*Channel) - if !ok { - return Channel{}, trace.Errorf("unknown result type %T", result) - } - return *ptr, nil -} diff --git a/access/mattermost/config.go b/access/mattermost/config.go deleted file mode 100644 index 8fff1b9df..000000000 --- a/access/mattermost/config.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2023 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 ( - "strings" - - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/logger" - "github.com/gravitational/trace" - "github.com/pelletier/go-toml" -) - -type Config struct { - Teleport lib.TeleportConfig `toml:"teleport"` - Mattermost MattermostConfig `toml:"mattermost"` - Log logger.Config `toml:"log"` -} - -type MattermostConfig struct { - URL string `toml:"url"` - Recipients []string `toml:"recipients"` - Token string `toml:"token"` -} - -const exampleConfig = `# example mattermost 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/mattermost/auth_id" # Identity file -# -# When using --format=tls: -# client_key = "/var/lib/teleport/plugins/mattermost/auth.key" # Teleport TLS secret key -# client_crt = "/var/lib/teleport/plugins/mattermost/auth.crt" # Teleport TLS certificate -# root_cas = "/var/lib/teleport/plugins/mattermost/auth.cas" # Teleport CA certs - -[mattermost] -url = "https://mattermost.example.com" # Mattermost Server URL -token = "api-token" # Mattermost Bot OAuth token - -[log] -output = "stderr" # Logger output. Could be "stdout", "stderr" or "/var/lib/teleport/mattermost.log" -severity = "INFO" # Logger severity. Could be "INFO", "ERROR", "DEBUG" or "WARN". -` - -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.Mattermost.Token, "/") { - conf.Mattermost.Token, err = lib.ReadPassword(conf.Mattermost.Token) - if err != nil { - return nil, trace.Wrap(err) - } - } - if err := conf.CheckAndSetDefaults(); err != nil { - return nil, trace.Wrap(err) - } - return conf, nil -} - -func (c *Config) CheckAndSetDefaults() error { - if err := c.Teleport.CheckAndSetDefaults(); err != nil { - return trace.Wrap(err) - } - if c.Mattermost.Token == "" { - return trace.BadParameter("missing required value mattermost.token") - } - if c.Mattermost.URL == "" { - return trace.BadParameter("missing required value mattermost.url") - } - if c.Log.Output == "" { - c.Log.Output = "stderr" - } - if c.Log.Severity == "" { - c.Log.Severity = "info" - } - return nil -} diff --git a/access/mattermost/example_config.toml b/access/mattermost/example_config.toml new file mode 100644 index 000000000..30eaea889 --- /dev/null +++ b/access/mattermost/example_config.toml @@ -0,0 +1,37 @@ +# example mattermost 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/mattermost/auth_id" # Identity file +# +# When using --format=tls: +# client_key = "/var/lib/teleport/plugins/mattermost/auth.key" # Teleport TLS secret key +# client_crt = "/var/lib/teleport/plugins/mattermost/auth.crt" # Teleport TLS certificate +# root_cas = "/var/lib/teleport/plugins/mattermost/auth.cas" # Teleport CA certs + +[mattermost] +url = "https://mattermost.example.com" # Mattermost Server URL +token = "api-token" # Mattermost Bot OAuth token + +# Notify recipients (optional) +# +# The value is an array of strings, where each element is either: +# - A channel name in the format 'team/channel', where / separates the +# name of the team and the name of the channel +# - The email address of a Mattermost user to notify via a direct message +# when the plugin receives an Access Request event +# recipients = [ +# "my-team-name/channel-name", +# "first.last@example.com" +# ] + +[log] +output = "stderr" # Logger output. Could be "stdout", "stderr" or "/var/lib/teleport/mattermost.log" +severity = "INFO" # Logger severity. Could be "INFO", "ERROR", "DEBUG" or "WARN". diff --git a/access/mattermost/fake_mattermost_test.go b/access/mattermost/fake_mattermost_test.go deleted file mode 100644 index 902fee40d..000000000 --- a/access/mattermost/fake_mattermost_test.go +++ /dev/null @@ -1,401 +0,0 @@ -// Copyright 2023 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" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "runtime/debug" - "sort" - "sync" - "sync/atomic" - - "github.com/gravitational/trace" - "github.com/julienschmidt/httprouter" - log "github.com/sirupsen/logrus" -) - -type FakeMattermost struct { - srv *httptest.Server - objects sync.Map - botUserID string - newPosts chan Post - postUpdates chan Post - - postIDCounter uint64 - userIDCounter uint64 - teamIDCounter uint64 - channelIDCounter uint64 -} - -type fakeUserByEmailKey string -type fakeTeamByNameKey string -type fakeChannelByTeamNameAndNameKey struct { - team string - channel string -} -type fakeDirectChannelUsersKey struct { - user1ID string - user2ID string -} -type fakeDirectChannelKey string - -type FakeDirectChannel struct { - User1ID string - User2ID string - Channel -} - -func NewFakeMattermost(botUser User, concurrency int) *FakeMattermost { - router := httprouter.New() - - mattermost := &FakeMattermost{ - newPosts: make(chan Post, concurrency*6), - postUpdates: make(chan Post, concurrency*2), - srv: httptest.NewServer(router), - } - mattermost.botUserID = mattermost.StoreUser(botUser).ID - - router.GET("/api/v4/teams/name/:team", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - name := ps.ByName("team") - team, found := mattermost.GetTeamByName(name) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the team."}) - panicIf(err) - return - } - err := json.NewEncoder(rw).Encode(team) - panicIf(err) - }) - - router.GET("/api/v4/teams/name/:team/channels/name/:channel", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - teamName := ps.ByName("team") - name := ps.ByName("channel") - channel, found := mattermost.GetChannelByTeamNameAndName(teamName, name) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the channel."}) - panicIf(err) - return - } - err := json.NewEncoder(rw).Encode(channel) - panicIf(err) - }) - - router.POST("/api/v4/channels/direct", func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - var userIDs []string - err := json.NewDecoder(r.Body).Decode(&userIDs) - panicIf(err) - if len(userIDs) != 2 { - rw.WriteHeader(http.StatusBadRequest) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusBadRequest, Message: "Expected only two user IDs."}) - panicIf(err) - return - } - - user1, found := mattermost.GetUser(userIDs[0]) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the user."}) - panicIf(err) - return - } - - user2, found := mattermost.GetUser(userIDs[1]) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the user."}) - panicIf(err) - return - } - - err = json.NewEncoder(rw).Encode(mattermost.GetDirectChannelFor(user1, user2).Channel) - panicIf(err) - }) - - router.GET("/api/v4/users/me", func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - err := json.NewEncoder(rw).Encode(mattermost.GetBotUser()) - panicIf(err) - }) - - router.GET("/api/v4/users/email/:email", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - email := ps.ByName("email") - user, found := mattermost.GetUserByEmail(email) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the user."}) - panicIf(err) - return - } - err := json.NewEncoder(rw).Encode(user) - panicIf(err) - }) - - router.GET("/api/v4/users/:id", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - id := ps.ByName("id") - user, found := mattermost.GetUser(id) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the user."}) - panicIf(err) - return - } - err := json.NewEncoder(rw).Encode(user) - panicIf(err) - }) - - router.POST("/api/v4/posts", func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - var post Post - err := json.NewDecoder(r.Body).Decode(&post) - panicIf(err) - - // message size limit as per - // https://github.com/mattermost/mattermost-server/blob/3d412b14af49701d842e72ef208f0ec0a35ce063/model/post.go#L54 - // (current master at time of writing) - if len(post.Message) > 4000 { - rw.WriteHeader(http.StatusBadRequest) - return - } - - post = mattermost.StorePost(post) - mattermost.newPosts <- post - - rw.WriteHeader(http.StatusCreated) - err = json.NewEncoder(rw).Encode(post) - panicIf(err) - - }) - - router.PUT("/api/v4/posts/:id", func(rw http.ResponseWriter, r *http.Request, ps httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - id := ps.ByName("id") - post, found := mattermost.GetPost(id) - if !found { - rw.WriteHeader(http.StatusNotFound) - err := json.NewEncoder(rw).Encode(ErrorResult{StatusCode: http.StatusNotFound, Message: "Unable to find the post."}) - panicIf(err) - return - } - - var newPost Post - err := json.NewDecoder(r.Body).Decode(&newPost) - panicIf(err) - - post.Message = newPost.Message - post.Props = newPost.Props - post = mattermost.UpdatePost(post) - - rw.WriteHeader(http.StatusOK) - err = json.NewEncoder(rw).Encode(post) - panicIf(err) - }) - - return mattermost -} - -func (s *FakeMattermost) URL() string { - return s.srv.URL -} - -func (s *FakeMattermost) Close() { - s.srv.Close() - close(s.newPosts) - close(s.postUpdates) -} - -func (s *FakeMattermost) GetPost(id string) (Post, bool) { - if obj, ok := s.objects.Load(id); ok { - post, ok := obj.(Post) - return post, ok - } - return Post{}, false -} - -func (s *FakeMattermost) StorePost(post Post) Post { - if post.ID == "" { - post.ID = fmt.Sprintf("post-%v", atomic.AddUint64(&s.postIDCounter, 1)) - } - s.objects.Store(post.ID, post) - return post -} - -func (s *FakeMattermost) UpdatePost(post Post) Post { - post = s.StorePost(post) - s.postUpdates <- post - return post -} - -func (s *FakeMattermost) GetBotUser() User { - user, ok := s.GetUser(s.botUserID) - if !ok { - panic("bot user not found") - } - return user -} - -func (s *FakeMattermost) GetUser(id string) (User, bool) { - if obj, ok := s.objects.Load(id); ok { - user, ok := obj.(User) - return user, ok - } - return User{}, false -} - -func (s *FakeMattermost) GetUserByEmail(email string) (User, bool) { - if obj, ok := s.objects.Load(fakeUserByEmailKey(email)); ok { - user, ok := obj.(User) - return user, ok - } - return User{}, false -} - -func (s *FakeMattermost) StoreUser(user User) User { - if user.ID == "" { - user.ID = fmt.Sprintf("user-%v", atomic.AddUint64(&s.userIDCounter, 1)) - } - s.objects.Store(user.ID, user) - s.objects.Store(fakeUserByEmailKey(user.Email), user) - return user -} - -func (s *FakeMattermost) GetTeam(id string) (Team, bool) { - if obj, ok := s.objects.Load(id); ok { - channel, ok := obj.(Team) - return channel, ok - } - return Team{}, false -} - -func (s *FakeMattermost) GetTeamByName(name string) (Team, bool) { - if obj, ok := s.objects.Load(fakeTeamByNameKey(name)); ok { - channel, ok := obj.(Team) - return channel, ok - } - return Team{}, false -} - -func (s *FakeMattermost) StoreTeam(team Team) Team { - if team.ID == "" { - team.ID = fmt.Sprintf("team-%v", atomic.AddUint64(&s.teamIDCounter, 1)) - } - s.objects.Store(team.ID, team) - s.objects.Store(fakeTeamByNameKey(team.Name), team) - return team -} - -func (s *FakeMattermost) GetChannel(id string) (Channel, bool) { - if obj, ok := s.objects.Load(id); ok { - channel, ok := obj.(Channel) - return channel, ok - } - return Channel{}, false -} - -func (s *FakeMattermost) GetDirectChannelFor(user1, user2 User) FakeDirectChannel { - ids := []string{user1.ID, user2.ID} - sort.Strings(ids) - user1ID, user2ID := ids[0], ids[1] - key := fakeDirectChannelUsersKey{user1ID, user2ID} - if obj, ok := s.objects.Load(key); ok { - directChannel, ok := obj.(FakeDirectChannel) - if !ok { - panic(fmt.Sprintf("bad channel type %T", obj)) - } - return directChannel - } - - channel := s.StoreChannel(Channel{}) - directChannel := FakeDirectChannel{ - User1ID: user1ID, - User2ID: user2ID, - Channel: channel, - } - s.objects.Store(key, directChannel) - s.objects.Store(fakeDirectChannelKey(channel.ID), directChannel) - return directChannel -} - -func (s *FakeMattermost) GetDirectChannel(id string) (FakeDirectChannel, bool) { - if obj, ok := s.objects.Load(fakeDirectChannelKey(id)); ok { - directChannel, ok := obj.(FakeDirectChannel) - return directChannel, ok - } - return FakeDirectChannel{}, false -} - -func (s *FakeMattermost) GetChannelByTeamNameAndName(team, name string) (Channel, bool) { - if obj, ok := s.objects.Load(fakeChannelByTeamNameAndNameKey{team: team, channel: name}); ok { - channel, ok := obj.(Channel) - return channel, ok - } - return Channel{}, false -} - -func (s *FakeMattermost) StoreChannel(channel Channel) Channel { - if channel.ID == "" { - channel.ID = fmt.Sprintf("channel-%v", atomic.AddUint64(&s.channelIDCounter, 1)) - } - s.objects.Store(channel.ID, channel) - - if channel.TeamID != "" { - team, ok := s.GetTeam(channel.TeamID) - if !ok { - panic(fmt.Sprintf("team id %q is not found", channel.TeamID)) - } - s.objects.Store(fakeChannelByTeamNameAndNameKey{team: team.Name, channel: channel.Name}, channel) - } - return channel -} - -func (s *FakeMattermost) CheckNewPost(ctx context.Context) (Post, error) { - select { - case post := <-s.newPosts: - return post, nil - case <-ctx.Done(): - return Post{}, trace.Wrap(ctx.Err()) - } -} - -func (s *FakeMattermost) CheckPostUpdate(ctx context.Context) (Post, error) { - select { - case post := <-s.postUpdates: - return post, nil - case <-ctx.Done(): - return Post{}, trace.Wrap(ctx.Err()) - } -} - -func panicIf(err error) { - if err != nil { - log.Panicf("%v at %v", err, string(debug.Stack())) - } -} diff --git a/access/mattermost/helper_test.go b/access/mattermost/helper_test.go deleted file mode 100644 index abecd67b0..000000000 --- a/access/mattermost/helper_test.go +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright 2023 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 - -type MattermostPostSlice []Post -type MattermostDataPostSet map[MattermostDataPost]struct{} - -func (slice MattermostPostSlice) Len() int { - return len(slice) -} - -func (slice MattermostPostSlice) Less(i, j int) bool { - if slice[i].ChannelID < slice[j].ChannelID { - return true - } - return slice[i].ID < slice[j].ID -} - -func (slice MattermostPostSlice) Swap(i, j int) { - slice[i], slice[j] = slice[j], slice[i] -} - -func (set MattermostDataPostSet) Add(msg MattermostDataPost) { - set[msg] = struct{}{} -} - -func (set MattermostDataPostSet) Contains(msg MattermostDataPost) bool { - _, ok := set[msg] - return ok -} diff --git a/access/mattermost/main.go b/access/mattermost/main.go index e07ec700c..b52669dfb 100644 --- a/access/mattermost/main.go +++ b/access/mattermost/main.go @@ -18,16 +18,21 @@ package main import ( "context" + _ "embed" "fmt" "os" "time" "github.com/gravitational/kingpin" + "github.com/gravitational/teleport/integrations/access/mattermost" "github.com/gravitational/teleport/integrations/lib" "github.com/gravitational/teleport/integrations/lib/logger" "github.com/gravitational/trace" ) +//go:embed example_config.toml +var exampleConfig string + func main() { logger.Init() app := kingpin.New("teleport-mattermost", "Teleport plugin for access requests approval via Mattermost.") @@ -64,7 +69,7 @@ func main() { } func run(configPath string, debug bool) error { - conf, err := LoadConfig(configPath) + conf, err := mattermost.LoadConfig(configPath) if err != nil { return trace.Wrap(err) } @@ -80,11 +85,7 @@ func run(configPath string, debug bool) error { logger.Standard().Debugf("DEBUG logging enabled") } - app, err := NewApp(*conf) - if err != nil { - return trace.Wrap(err) - } - + app := mattermost.NewMattermostApp(conf) go lib.ServeSignals(app, 15*time.Second) return trace.Wrap( diff --git a/access/mattermost/mattermost_test.go b/access/mattermost/mattermost_test.go deleted file mode 100644 index 37fc5f766..000000000 --- a/access/mattermost/mattermost_test.go +++ /dev/null @@ -1,762 +0,0 @@ -// Copyright 2023 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" - "os/user" - "regexp" - "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" -) - -var msgFieldRegexp = regexp.MustCompile(`(?im)^\*\*([a-zA-Z ]+)\*\*:\ +(.+)$`) -var requestReasonRegexp = regexp.MustCompile("(?im)^\\*\\*Reason\\*\\*:\\ ```\\n(.*?)```(.*?)$") -var resolutionReasonRegexp = regexp.MustCompile("(?im)^\\*\\*Resolution reason\\*\\*:\\ ```\\n(.*?)```(.*?)$") - -type MattermostSuite struct { - integration.Suite - appConfig Config - userNames struct { - ruler string - requestor string - reviewer1 string - reviewer2 string - plugin string - } - raceNumber int - fakeMattermost *FakeMattermost - mmUser User - - clients map[string]*integration.Client - teleportFeatures *proto.Features - teleportConfig lib.TeleportConfig -} - -func TestMattermost(t *testing.T) { suite.Run(t, &MattermostSuite{}) } - -func (s *MattermostSuite) SetupSuite() { - var err error - t := s.T() - - logger.Init() - err = logger.Setup(logger.Config{Severity: "debug"}) - require.NoError(t, err) - s.raceNumber = runtime.GOMAXPROCS(0) - me, err := user.Current() - require.NoError(t, err) - - // We set such a big timeout because integration.NewFromEnv could start - // downloading a Teleport *-bin.tar.gz file which can take a long time. - ctx := s.SetContextTimeout(2 * time.Minute) - - teleport, err := integration.NewFromEnv(ctx) - require.NoError(t, err) - t.Cleanup(teleport.Close) - - auth, err := teleport.NewAuthService() - require.NoError(t, err) - s.StartApp(auth) - - 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 := teleport.MakeAdmin(ctx, auth, s.userNames.ruler) - require.NoError(t, err) - s.clients[s.userNames.ruler] = client - - // Get the server features. - - pong, err := client.Ping(ctx) - 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{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-mattermost", 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-mattermost", role.GetName()) - require.NoError(t, err) - s.userNames.plugin = user.GetName() - - // Bake all the resources. - - err = teleport.Bootstrap(ctx, auth, bootstrap.Resources()) - require.NoError(t, err) - - // Initialize the clients. - - client, err = teleport.NewClient(ctx, auth, s.userNames.requestor) - require.NoError(t, err) - s.clients[s.userNames.requestor] = client - - if teleportFeatures.AdvancedAccessWorkflows { - client, err = teleport.NewClient(ctx, auth, s.userNames.reviewer1) - require.NoError(t, err) - s.clients[s.userNames.reviewer1] = client - - client, err = teleport.NewClient(ctx, auth, s.userNames.reviewer2) - require.NoError(t, err) - s.clients[s.userNames.reviewer2] = client - } - - identityPath, err := teleport.Sign(ctx, auth, s.userNames.plugin) - require.NoError(t, err) - - s.teleportConfig.Addr = auth.AuthAddr().String() - s.teleportConfig.Identity = identityPath - s.teleportFeatures = teleportFeatures -} - -func (s *MattermostSuite) SetupTest() { - t := s.T() - - err := logger.Setup(logger.Config{Severity: "debug"}) - require.NoError(t, err) - - s.fakeMattermost = NewFakeMattermost(User{Username: "bot", Email: "bot@example.com"}, s.raceNumber) - t.Cleanup(s.fakeMattermost.Close) - - s.mmUser = s.fakeMattermost.StoreUser(User{ - FirstName: "User", - LastName: "Test", - Username: "Vladimir", - Email: s.userNames.requestor, - }) - - var conf Config - conf.Teleport = s.teleportConfig - conf.Mattermost.Token = "000000" - conf.Mattermost.URL = s.fakeMattermost.URL() - - s.appConfig = conf - s.SetContextTimeout(5 * time.Second) -} - -func (s *MattermostSuite) startApp() { - t := s.T() - t.Helper() - - app, err := NewApp(s.appConfig) - require.NoError(t, err) - - s.StartApp(app) -} - -func (s *MattermostSuite) ruler() *integration.Client { - return s.clients[s.userNames.ruler] -} - -func (s *MattermostSuite) requestor() *integration.Client { - return s.clients[s.userNames.requestor] -} - -func (s *MattermostSuite) reviewer1() *integration.Client { - return s.clients[s.userNames.reviewer1] -} - -func (s *MattermostSuite) reviewer2() *integration.Client { - return s.clients[s.userNames.reviewer2] -} - -func (s *MattermostSuite) newAccessRequest(reviewers []User) types.AccessRequest { - t := s.T() - t.Helper() - - req, err := types.NewAccessRequest(uuid.New().String(), s.userNames.requestor, "editor") - require.NoError(t, err) - // max size of request was decreased here: https://github.com/gravitational/teleport/pull/13298 - req.SetRequestReason("because of " + strings.Repeat("A", 4000)) - var suggestedReviewers []string - for _, user := range reviewers { - suggestedReviewers = append(suggestedReviewers, user.Email) - } - req.SetSuggestedReviewers(suggestedReviewers) - return req -} - -func (s *MattermostSuite) createAccessRequest(reviewers []User) types.AccessRequest { - t := s.T() - t.Helper() - - req := s.newAccessRequest(reviewers) - err := s.requestor().CreateAccessRequest(s.Context(), req) - require.NoError(s.T(), err) - return req -} - -func (s *MattermostSuite) checkPluginData(reqID string, cond func(PluginData) bool) PluginData { - t := s.T() - t.Helper() - - for { - rawData, err := s.ruler().PollAccessRequestPluginData(s.Context(), "mattermost", reqID) - require.NoError(t, err) - if data := DecodePluginData(rawData); cond(data) { - return data - } - } -} - -func (s *MattermostSuite) TestMattermostMessagePosting() { - t := s.T() - - reviewer1 := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - reviewer2 := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer2}) - directChannel1 := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer1) - directChannel2 := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer2) - - s.startApp() - request := s.createAccessRequest([]User{reviewer2, reviewer1}) - - pluginData := s.checkPluginData(request.GetName(), func(data PluginData) bool { - return len(data.MattermostData) > 0 - }) - assert.Len(t, pluginData.MattermostData, 2) - - var posts []Post - postSet := make(MattermostDataPostSet) - for i := 0; i < 2; i++ { - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err, "no new messages posted") - postSet.Add(MattermostDataPost{ChannelID: post.ChannelID, PostID: post.ID}) - posts = append(posts, post) - } - - assert.Len(t, postSet, 2) - assert.Contains(t, postSet, pluginData.MattermostData[0]) - assert.Contains(t, postSet, pluginData.MattermostData[1]) - - sort.Sort(MattermostPostSlice(posts)) - - assert.Equal(t, directChannel1.ID, posts[0].ChannelID) - assert.Equal(t, directChannel2.ID, posts[1].ChannelID) - - post := posts[0] - reqID, err := parsePostField(post, "Request ID") - require.NoError(t, err) - assert.Equal(t, request.GetName(), reqID) - - username, err := parsePostField(post, "User") - require.NoError(t, err) - assert.Equal(t, s.userNames.requestor, username) - - matches := requestReasonRegexp.FindAllStringSubmatch(post.Message, -1) - require.Equal(t, 1, len(matches)) - require.Equal(t, 3, len(matches[0])) - assert.Equal(t, "because of "+strings.Repeat("A", 489), matches[0][1]) - assert.Equal(t, " (truncated)", matches[0][2]) - - statusLine, err := parsePostField(post, "Status") - require.NoError(t, err) - assert.Equal(t, "⏳ PENDING", statusLine) -} - -func (s *MattermostSuite) TestApproval() { - t := s.T() - - reviewer := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]User{reviewer}) - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err, "no new messages posted") - directChannelID := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer).ID - assert.Equal(t, directChannelID, post.ChannelID) - - err = s.ruler().ApproveAccessRequest(s.Context(), req.GetName(), "okay") - require.NoError(t, err) - - postUpdate, err := s.fakeMattermost.CheckPostUpdate(s.Context()) - require.NoError(t, err, "no messages updated") - assert.Equal(t, post.ID, postUpdate.ID) - assert.Equal(t, post.ChannelID, postUpdate.ChannelID) - - statusLine, err := parsePostField(postUpdate, "Status") - require.NoError(t, err) - assert.Equal(t, "✅ APPROVED", statusLine) - - matches := resolutionReasonRegexp.FindAllStringSubmatch(postUpdate.Message, -1) - require.Equal(t, 1, len(matches)) - require.Equal(t, 3, len(matches[0])) - assert.Equal(t, "okay", matches[0][1]) - assert.Equal(t, "", matches[0][2]) -} - -func (s *MattermostSuite) TestDenial() { - t := s.T() - - reviewer := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]User{reviewer}) - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err, "no new messages posted") - directChannelID := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer).ID - assert.Equal(t, directChannelID, post.ChannelID) - - // max size of request was decreased here: https://github.com/gravitational/teleport/pull/13298 - err = s.ruler().DenyAccessRequest(s.Context(), req.GetName(), "not okay "+strings.Repeat("A", 4000)) - require.NoError(t, err) - - postUpdate, err := s.fakeMattermost.CheckPostUpdate(s.Context()) - require.NoError(t, err, "no messages updated") - assert.Equal(t, post.ID, postUpdate.ID) - assert.Equal(t, post.ChannelID, postUpdate.ChannelID) - - statusLine, err := parsePostField(postUpdate, "Status") - require.NoError(t, err) - assert.Equal(t, "❌ DENIED", statusLine) - - matches := resolutionReasonRegexp.FindAllStringSubmatch(postUpdate.Message, -1) - require.Equal(t, 1, len(matches)) - require.Equal(t, 3, len(matches[0])) - assert.Equal(t, "not okay "+strings.Repeat("A", 491), matches[0][1]) - assert.Equal(t, " (truncated)", matches[0][2]) -} - -func (s *MattermostSuite) TestReviewComments() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - reviewer := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - directChannelID := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer).ID - - s.startApp() - - req := s.createAccessRequest([]User{reviewer}) - s.checkPluginData(req.GetName(), func(data PluginData) bool { - return len(data.MattermostData) > 0 - }) - - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, directChannelID, post.ChannelID) - - 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) - - comment, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, post.ChannelID, comment.ChannelID) - assert.Equal(t, post.ID, comment.RootID) - assert.Contains(t, comment.Message, s.userNames.reviewer1+" reviewed the request", "comment must contain a review author") - assert.Contains(t, comment.Message, "Resolution: ✅ APPROVED", "comment must contain a proposed state") - assert.Contains(t, comment.Message, "Reason: ```\nokay```", "comment 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) - - comment, err = s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, post.ChannelID, comment.ChannelID) - assert.Equal(t, post.ID, comment.RootID) - assert.Contains(t, comment.Message, s.userNames.reviewer2+" reviewed the request", "comment must contain a review author") - assert.Contains(t, comment.Message, "Resolution: ❌ DENIED", "comment must contain a proposed state") - assert.Contains(t, comment.Message, "Reason: ```\nnot okay```", "comment must contain a reason") -} - -func (s *MattermostSuite) TestApprovalByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - reviewer := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]User{reviewer}) - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err, "no new messages posted") - directChannelID := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer).ID - assert.Equal(t, directChannelID, post.ChannelID) - - 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) - - comment, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, post.ChannelID, comment.ChannelID) - assert.Equal(t, post.ID, comment.RootID) - assert.Contains(t, comment.Message, s.userNames.reviewer1+" reviewed the request", "comment 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) - - comment, err = s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, post.ChannelID, comment.ChannelID) - assert.Equal(t, post.ID, comment.RootID) - assert.Contains(t, comment.Message, s.userNames.reviewer2+" reviewed the request", "comment must contain a review author") - - postUpdate, err := s.fakeMattermost.CheckPostUpdate(s.Context()) - require.NoError(t, err, "no messages updated") - assert.Equal(t, post.ID, postUpdate.ID) - assert.Equal(t, post.ChannelID, postUpdate.ChannelID) - - statusLine, err := parsePostField(postUpdate, "Status") - require.NoError(t, err) - assert.Equal(t, "✅ APPROVED", statusLine) - - matches := resolutionReasonRegexp.FindAllStringSubmatch(postUpdate.Message, -1) - require.Equal(t, 1, len(matches)) - require.Equal(t, 3, len(matches[0])) - assert.Equal(t, "finally okay", matches[0][1]) - assert.Equal(t, "", matches[0][2]) -} - -func (s *MattermostSuite) TestDenialByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - reviewer := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]User{reviewer}) - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err, "no new messages posted") - directChannelID := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer).ID - assert.Equal(t, directChannelID, post.ChannelID) - - 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) - - comment, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, post.ChannelID, comment.ChannelID) - assert.Equal(t, post.ID, comment.RootID) - assert.Contains(t, comment.Message, s.userNames.reviewer1+" reviewed the request", "comment 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) - - comment, err = s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err) - assert.Equal(t, post.ChannelID, comment.ChannelID) - assert.Equal(t, post.ID, comment.RootID) - assert.Contains(t, comment.Message, s.userNames.reviewer2+" reviewed the request", "comment must contain a review author") - - postUpdate, err := s.fakeMattermost.CheckPostUpdate(s.Context()) - require.NoError(t, err, "no messages updated") - assert.Equal(t, post.ID, postUpdate.ID) - assert.Equal(t, post.ChannelID, postUpdate.ChannelID) - - statusLine, err := parsePostField(postUpdate, "Status") - require.NoError(t, err) - assert.Equal(t, "❌ DENIED", statusLine) - - matches := resolutionReasonRegexp.FindAllStringSubmatch(postUpdate.Message, -1) - require.Equal(t, 1, len(matches)) - require.Equal(t, 3, len(matches[0])) - assert.Equal(t, "finally not okay", matches[0][1]) - assert.Equal(t, "", matches[0][2]) -} - -func (s *MattermostSuite) TestExpiration() { - t := s.T() - - reviewer := s.fakeMattermost.StoreUser(User{Email: "user@example.com"}) - - s.startApp() - - request := s.createAccessRequest([]User{reviewer}) - post, err := s.fakeMattermost.CheckNewPost(s.Context()) - require.NoError(t, err, "no new messages posted") - directChannelID := s.fakeMattermost.GetDirectChannelFor(s.fakeMattermost.GetBotUser(), reviewer).ID - assert.Equal(t, directChannelID, post.ChannelID) - - s.checkPluginData(request.GetName(), func(data PluginData) bool { - return len(data.MattermostData) > 0 - }) - - err = s.ruler().DeleteAccessRequest(s.Context(), request.GetName()) // simulate expiration - require.NoError(t, err) - - postUpdate, err := s.fakeMattermost.CheckPostUpdate(s.Context()) - require.NoError(t, err, "no new messages updated") - assert.Equal(t, post.ID, postUpdate.ID) - assert.Equal(t, post.ChannelID, postUpdate.ChannelID) - - statusLine, err := parsePostField(postUpdate, "Status") - require.NoError(t, err) - assert.Equal(t, "⌛ EXPIRED", statusLine) -} - -func (s *MattermostSuite) 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) - - reviewer1 := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer1}) - reviewer2 := s.fakeMattermost.StoreUser(User{Email: s.userNames.reviewer2}) - botUser := s.fakeMattermost.GetBotUser() - - s.SetContextTimeout(20 * time.Second) - s.startApp() - - var ( - raceErr error - raceErrOnce sync.Once - postIDs sync.Map - postsCount int32 - postUpdateCounters sync.Map - reviewCommentCounters sync.Map - ) - setRaceErr := func(err error) error { - raceErrOnce.Do(func() { - raceErr = err - }) - return err - } - - 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{reviewer1.Email, reviewer2.Email}) - if err := s.requestor().CreateAccessRequest(ctx, req); err != nil { - return setRaceErr(trace.Wrap(err)) - } - return nil - }) - } - - // Having TWO suggested reviewers will post TWO messages for each request. - // We also have approval threshold of TWO set in the role properties - // so lets simply submit the approval from each of the suggested reviewers. - // - // Multiplier SIX means that we handle TWO messages for each request and also - // TWO comments for each message: 2 * (1 message + 2 comments). - for i := 0; i < 6*s.raceNumber; i++ { - process.SpawnCritical(func(ctx context.Context) error { - post, err := s.fakeMattermost.CheckNewPost(ctx) - if err := trace.Wrap(err); err != nil { - return setRaceErr(err) - } - - if post.RootID == "" { - // Handle "root" notifications. - - postKey := MattermostDataPost{ChannelID: post.ChannelID, PostID: post.ID} - if _, loaded := postIDs.LoadOrStore(postKey, struct{}{}); loaded { - return setRaceErr(trace.Errorf("post %v already stored", postKey)) - } - atomic.AddInt32(&postsCount, 1) - - reqID, err := parsePostField(post, "Request ID") - if err != nil { - return setRaceErr(trace.Wrap(err)) - } - - directChannel, ok := s.fakeMattermost.GetDirectChannel(post.ChannelID) - if !ok { - return setRaceErr(trace.Errorf("direct channel %s not found", post.ChannelID)) - } - - var userID string - if directChannel.User2ID == botUser.ID { - userID = directChannel.User1ID - } else { - userID = directChannel.User2ID - } - user, ok := s.fakeMattermost.GetUser(userID) - if !ok { - return setRaceErr(trace.Errorf("user %s not found", userID)) - } - - if err = s.clients[user.Email].SubmitAccessRequestReview(ctx, reqID, types.AccessReview{ - Author: user.Email, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }); err != nil { - return setRaceErr(trace.Wrap(err)) - } - } else { - // Handle review comments. - - postKey := MattermostDataPost{ChannelID: post.ChannelID, PostID: post.RootID} - var newCounter int32 - val, _ := reviewCommentCounters.LoadOrStore(postKey, &newCounter) - counterPtr := val.(*int32) - atomic.AddInt32(counterPtr, 1) - } - - return nil - }) - } - - // Multiplier TWO means that we handle updates for each of the two messages posted to reviewers. - for i := 0; i < 2*s.raceNumber; i++ { - process.SpawnCritical(func(ctx context.Context) error { - post, err := s.fakeMattermost.CheckPostUpdate(ctx) - if err != nil { - return setRaceErr(trace.Wrap(err)) - } - - postKey := MattermostDataPost{ChannelID: post.ChannelID, PostID: post.ID} - var newCounter int32 - val, _ := postUpdateCounters.LoadOrStore(postKey, &newCounter) - counterPtr := val.(*int32) - atomic.AddInt32(counterPtr, 1) - - return nil - }) - } - - process.Terminate() - <-process.Done() - require.NoError(t, raceErr) - - assert.Equal(t, int32(2*s.raceNumber), postsCount) - postIDs.Range(func(key, value interface{}) bool { - next := true - - val, loaded := reviewCommentCounters.LoadAndDelete(key) - next = next && assert.True(t, loaded) - counterPtr := val.(*int32) - next = next && assert.Equal(t, int32(2), *counterPtr) - - val, loaded = postUpdateCounters.LoadAndDelete(key) - next = next && assert.True(t, loaded) - counterPtr = val.(*int32) - next = next && assert.Equal(t, int32(1), *counterPtr) - - return next - }) -} - -func parsePostField(post Post, field string) (string, error) { - text := post.Message - matches := msgFieldRegexp.FindAllStringSubmatch(text, -1) - if matches == nil { - return "", trace.Errorf("cannot parse fields from text %s", text) - } - var fields []string - for _, match := range matches { - if match[1] == field { - return match[2], nil - } - fields = append(fields, match[1]) - } - return "", trace.Errorf("cannot find field %s in %v", field, fields) -} diff --git a/access/mattermost/plugindata.go b/access/mattermost/plugindata.go deleted file mode 100644 index fe1dc4c7e..000000000 --- a/access/mattermost/plugindata.go +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright 2023 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" - "strings" -) - -// PluginData is a data associated with access request that we store in Teleport using UpdatePluginData API. -type PluginData struct { - RequestData - MattermostData -} - -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") - -type RequestData struct { - User string - Roles []string - RequestReason string - ReviewsCount int - Resolution Resolution -} - -type MattermostDataPost struct { - PostID string - ChannelID string -} - -type MattermostData = []MattermostDataPost - -// 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 != "" { - fmt.Sscanf(str, "%d", &data.ReviewsCount) - } - data.Resolution.Tag = ResolutionTag(dataMap["resolution"]) - data.Resolution.Reason = dataMap["resolve_reason"] - if channelID, postID := dataMap["channel_id"], dataMap["postID"]; channelID != "" && postID != "" { - data.MattermostData = append(data.MattermostData, MattermostDataPost{ChannelID: channelID, PostID: postID}) - } - if str := dataMap["messages"]; str != "" { - for _, encodedMsg := range strings.Split(str, ",") { - if parts := strings.Split(encodedMsg, "/"); len(parts) == 2 { - data.MattermostData = append(data.MattermostData, MattermostDataPost{ChannelID: parts[0], PostID: parts[1]}) - } - } - } - 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 encodedMessages []string - for _, msg := range data.MattermostData { - encodedMessages = append(encodedMessages, fmt.Sprintf("%s/%s", msg.ChannelID, msg.PostID)) - } - result["messages"] = strings.Join(encodedMessages, ",") - - return result -} diff --git a/access/mattermost/plugindata_test.go b/access/mattermost/plugindata_test.go deleted file mode 100644 index 9a31977a1..000000000 --- a/access/mattermost/plugindata_test.go +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2023 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/assert" -) - -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"}, - }, - MattermostData: MattermostData{ - {ChannelID: "CHANNEL1", PostID: "POST01"}, - {ChannelID: "CHANNEL2", PostID: "POST02"}, - }, -} - -func TestEncodePluginData(t *testing.T) { - dataMap := EncodePluginData(samplePluginData) - assert.Len(t, dataMap, 7) - assert.Equal(t, "user-foo", dataMap["user"]) - assert.Equal(t, "role-foo,role-bar", dataMap["roles"]) - assert.Equal(t, "foo reason", dataMap["request_reason"]) - assert.Equal(t, "3", dataMap["reviews_count"]) - assert.Equal(t, "APPROVED", dataMap["resolution"]) - assert.Equal(t, "foo ok", dataMap["resolve_reason"]) - assert.Equal(t, "CHANNEL1/POST01,CHANNEL2/POST02", dataMap["messages"]) -} - -func TestDecodePluginData(t *testing.T) { - pluginData := DecodePluginData(map[string]string{ - "user": "user-foo", - "roles": "role-foo,role-bar", - "request_reason": "foo reason", - "reviews_count": "3", - "resolution": "APPROVED", - "resolve_reason": "foo ok", - "messages": "CHANNEL1/POST01,CHANNEL2/POST02", - }) - assert.Equal(t, samplePluginData, pluginData) -} - -func TestEncodeEmptyPluginData(t *testing.T) { - dataMap := EncodePluginData(PluginData{}) - assert.Len(t, dataMap, 7) - for key, value := range dataMap { - assert.Emptyf(t, value, "value at key %q must be empty", key) - } -} - -func TestDecodeEmptyPluginData(t *testing.T) { - assert.Empty(t, DecodePluginData(nil)) - assert.Empty(t, DecodePluginData(make(map[string]string))) -} diff --git a/access/mattermost/types.go b/access/mattermost/types.go deleted file mode 100644 index 0e5afadc2..000000000 --- a/access/mattermost/types.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright 2023 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 ( - "encoding/json" - "fmt" -) - -// Mattermost API types - -type Props map[string]interface{} - -type Post struct { - ID string `json:"id,omitempty"` - ChannelID string `json:"channel_id"` - Message string `json:"message"` - RootID string `json:"root_id"` - Props Props `json:"props"` -} - -type Attachment struct { - ID int64 `json:"id"` - Text string `json:"text"` - Actions []PostAction `json:"actions,omitempty"` -} - -type PostAction struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Integration *PostActionIntegration `json:"integration,omitempty"` -} - -type PostActionIntegration struct { - URL string `json:"url,omitempty"` - Context map[string]interface{} `json:"context,omitempty"` -} - -type User struct { - ID string `json:"id"` - Username string `json:"username"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - Email string `json:"email"` -} - -type Team struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type Channel struct { - ID string `json:"id"` - TeamID string `json:"team_id"` - Type string `json:"type"` - Name string `json:"name"` -} - -type ErrorResult struct { - ID string `json:"id"` - Message string `json:"message"` - DetailedError string `json:"detailed_error"` - RequestID string `json:"request_id"` - StatusCode int `json:"status_code"` -} - -func (e ErrorResult) Error() string { - return fmt.Sprintf("api error status_code=%v, message=%q", e.StatusCode, e.Message) -} - -func (post Post) Attachments() []Attachment { - var attachments []Attachment - if slice, ok := post.Props["attachments"].([]interface{}); ok { - for _, dec := range slice { - if enc, err := json.Marshal(dec); err == nil { - var attachment Attachment - if json.Unmarshal(enc, &attachment) == nil { - attachments = append(attachments, attachment) - } - } - } - } - return attachments -} diff --git a/go.mod b/go.mod index d35d67eba..51883d258 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.19 require ( github.com/DanielTitkov/go-adaptive-cards v0.2.2 github.com/alecthomas/kong v0.2.22 - github.com/go-resty/resty/v2 v2.7.0 + github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/gogo/protobuf v1.3.2 github.com/google/uuid v1.3.0 github.com/gravitational/kingpin v2.1.11-0.20220901134012-2a1956e29525+incompatible @@ -18,7 +18,6 @@ require ( github.com/jonboulle/clockwork v0.4.0 github.com/json-iterator/go v1.1.12 github.com/julienschmidt/httprouter v1.3.0 - github.com/mailgun/holster/v3 v3.16.2 github.com/mailgun/mailgun-go/v4 v4.5.3 github.com/manifoldco/promptui v0.8.0 github.com/olekukonko/tablewriter v0.0.5 @@ -84,6 +83,7 @@ require ( github.com/jhump/protoreflect v1.12.0 // indirect github.com/juju/ansiterm v0.0.0-20180109212912-720a0952cc2a // indirect github.com/lunixbochs/vtclean v0.0.0-20180621232353-2d01aacdc34a // indirect + github.com/mailgun/holster/v3 v3.16.2 // indirect github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect