diff --git a/access/msteams/_tpl/color.png b/access/msteams/_tpl/color.png deleted file mode 100644 index a39376ddf..000000000 Binary files a/access/msteams/_tpl/color.png and /dev/null differ diff --git a/access/msteams/_tpl/manifest.json b/access/msteams/_tpl/manifest.json deleted file mode 100644 index ecd1509a5..000000000 --- a/access/msteams/_tpl/manifest.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.11/MicrosoftTeams.schema.json", - "manifestVersion": "1.11", - "version": "1.0.0", - "id": "{{ .TeamsAppID }}", - "packageName": "com.gravitational.telebot", - "developer": { - "name": "Gravitational", - "websiteUrl": "https://goteleport.com", - "privacyUrl": "https://goteleport.com/legal/privacy", - "termsOfUseUrl": "https://goteleport.com/legal/tos" - }, - "icons": { - "color": "color.png", - "outline": "outline.png" - }, - "name": { - "short": "TeleBot", - "full": "TeleBot" - }, - "description": { - "short": "Teleport bot", - "full": "Teleport bot sends AccessRequests to team users" - }, - "accentColor": "#FFFFFF", - "bots": [ - { - "botId": "{{ .AppID }}", - "scopes": [ - "team" - ], - "supportsFiles": false, - "isNotificationOnly": true - } - ], - "permissions": [ - "identity", - "messageTeamMembers" - ], - "validDomains": [], - "webApplicationInfo": { - "id": "{{ .AppID }}", - "resource": "https://inapplicable" - } -} \ No newline at end of file diff --git a/access/msteams/_tpl/outline.png b/access/msteams/_tpl/outline.png deleted file mode 100644 index 57137e7bb..000000000 Binary files a/access/msteams/_tpl/outline.png and /dev/null differ diff --git a/access/msteams/_tpl/teleport-msteams-role.yaml b/access/msteams/_tpl/teleport-msteams-role.yaml deleted file mode 100644 index 0bb59f44f..000000000 --- a/access/msteams/_tpl/teleport-msteams-role.yaml +++ /dev/null @@ -1,16 +0,0 @@ -kind: role -metadata: - name: teleport-msteams -spec: - allow: - rules: - - resources: ['access_request'] - verbs: ['list', 'read', 'update'] -version: v6 ---- -kind: user -metadata: - name: teleport-msteams -spec: - roles: ['teleport-msteams'] -version: v2 diff --git a/access/msteams/_tpl/teleport-msteams.toml b/access/msteams/_tpl/teleport-msteams.toml deleted file mode 100644 index 777d8f9e2..000000000 --- a/access/msteams/_tpl/teleport-msteams.toml +++ /dev/null @@ -1,50 +0,0 @@ -# Example ms teams plugin configuration TOML file - -# If true, recipients existence got checked on plugin start -# When a recipient is checked, the app is installed for the user if it was not already. -# This takes some time and does not fits well with HTTP timeouts, hence preload is necessary when a new -# recipient is added. -preload = true - -[teleport] -# Teleport Auth/Proxy Server address. -# addr = "example.com:3025" -# -# 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". - -# Credentials generated with `tctl auth sign`. -# -# When using --format=file: -# identity = "/var/lib/teleport/plugins/msteams/auth_id" # Identity file -# refresh_identity = true # Refresh identity file on a periodic basis. -# -# When using --format=tls: -# client_key = "/var/lib/teleport/plugins/msteams/auth.key" # Teleport TLS secret key -# client_crt = "/var/lib/teleport/plugins/msteams/auth.crt" # Teleport TLS certificate -# root_cas = "/var/lib/teleport/plugins/msteams/auth.cas" # Teleport CA certs -addr = "localhost:3025" -identity = "identity" - -[msapi] -# MS API ID's. Please, check the documentation. -app_id = "{{ .AppID }}" -# Either contains the app secret or the path of a file containing the secret -app_secret = "{{ .AppSecret }}" -tenant_id = "{{ .TenantID }}" -teams_app_id = "{{ .TeamsAppID }}" - -[role_to_recipients] -# Map roles to recipients. -# -# Provide msteams user email/id recipients for access requests for specific roles. -# role.suggested_reviewers will automatically be treated as additional email recipients. -# "*" must be provided to match non-specified roles. -# -# "dev" = "devs-slack-channel" -# "*" = ["admin@email.com", "admin-slack-channel"] -"*" = ["foo@example.com"] - -[log] -output = "stderr" # Logger output. Could be "stdout", "stderr" or "/var/lib/teleport/msteams.log" -severity = "INFO" # Logger severity. Could be "INFO", "ERROR", "DEBUG" or "WARN". diff --git a/access/msteams/app.go b/access/msteams/app.go deleted file mode 100644 index 19bc6c9fa..000000000 --- a/access/msteams/app.go +++ /dev/null @@ -1,490 +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" - "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" - pd "github.com/gravitational/teleport/integrations/lib/plugindata" - "github.com/gravitational/teleport/integrations/lib/stringset" - "github.com/gravitational/teleport/integrations/lib/watcherjob" - "github.com/gravitational/trace" -) - -const ( - // pluginName used as Teleport plugin identifier - pluginName = "msteams" - // minServerVersion is the minimal teleport version the plugin supports. - minServerVersion = "8.0.0" - // 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 -) - -// App contains global application state. -type App struct { - conf Config - - apiClient teleport.Client - bot *Bot - mainJob lib.ServiceJob - watcherJob lib.ServiceJob - pd *pd.CompareAndSwap[PluginData] - - *lib.Process -} - -// NewApp initializes a new teleport-msteams app and returns it. -func NewApp(conf Config) (*App, error) { - app := &App{conf: conf} - - app.mainJob = lib.NewServiceJob(app.run) - - return app, nil -} - -// Run starts the main job process -func (a *App) Run(ctx context.Context) error { - log := logger.Get(ctx) - log.Infof("Starting Teleport MS Teams Plugin %s:%s", Version, Gitref) - - err := a.init(ctx) - if err != nil { - return trace.Wrap(err) - } - - a.Process = lib.NewProcess(ctx) - a.watcherJob, err = a.newWatcherJob() - if err != nil { - return trace.Wrap(err) - } - - a.SpawnCriticalJob(a.mainJob) - a.SpawnCriticalJob(a.watcherJob) - - select { - case <-ctx.Done(): - return ctx.Err() - case <-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) -} - -// init initializes the application -func (a *App) init(ctx context.Context) error { - ctx, cancel := context.WithTimeout(ctx, initTimeout) - defer cancel() - - var err error - a.apiClient, err = common.GetTeleportClient(ctx, a.conf.Teleport) - if err != nil { - return trace.Wrap(err) - } - - a.pd = pd.NewCAS( - a.apiClient, - pluginName, - types.KindAccessRequest, - EncodePluginData, - DecodePluginData, - ) - - pong, err := a.checkTeleportVersion(ctx) - if err != nil { - return trace.Wrap(err) - } - - var webProxyAddr string - if pong.ServerFeatures.AdvancedAccessWorkflows { - webProxyAddr = pong.ProxyPublicAddr - } - - a.bot, err = NewBot(a.conf.MSAPI, pong.ClusterName, webProxyAddr) - if err != nil { - return trace.Wrap(err) - } - - return a.initBot(ctx) -} - -// initBot initializes bot -func (a *App) initBot(ctx context.Context) error { - log := logger.Get(ctx) - - teamsApp, err := a.bot.GetTeamsApp(ctx) - if trace.IsNotFound(err) { - return trace.Wrap(err, "MS Teams app not found in org app store.") - } - if err != nil { - return trace.Wrap(err) - } - - log.WithField("name", teamsApp.DisplayName). - WithField("id", teamsApp.ID). - Info("MS Teams app found in org app store") - - if !a.conf.Preload { - return nil - } - - log.Info("Preloading recipient data...") - - for _, recipient := range a.conf.Recipients.GetAllRawRecipients() { - recipientData, err := a.bot.FetchRecipient(ctx, recipient) - if err != nil { - return trace.Wrap(err) - } - log.WithField("recipient", recipient). - WithField("chat_id", recipientData.Chat.ID). - WithField("kind", recipientData.Kind). - Info("Recipient found, chat found") - } - - log.Info("Recipient data preloaded and cached.") - - return nil -} - -// newWatcherJob creates WatcherJob -func (a *App) newWatcherJob() (lib.ServiceJob, error) { - return watcherjob.NewJob( - a.apiClient, - watcherjob.Config{ - Watch: types.Watch{ - Kinds: []types.WatchKind{{Kind: types.KindAccessRequest}}, - }, - EventFuncTimeout: handlerTimeout, - }, - a.onWatcherEvent, - ) -} - -// run starts the main process -func (a *App) run(ctx context.Context) error { - log := logger.Get(ctx) - - ok, err := a.watcherJob.WaitReady(ctx) - if err != nil { - return trace.Wrap(err) - } - - if ok { - log.Info("Plugin is ready") - } else { - log.Error("Plugin is not ready") - } - - a.mainJob.SetReady(ok) - - <-a.watcherJob.Done() - - return trace.Wrap(a.watcherJob.Err()) -} - -// checkTeleportVersion loads Teleport version and checks that it meets the minimal 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 called when an access request event is received -func (a *App) onWatcherEvent(ctx context.Context, event types.Event) error { - kind := event.Resource.GetKind() - if 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(): - log.Debug("Pending request received") - err = a.onPendingRequest(ctx, req) - case req.GetState().IsApproved(): - log.Debug("Approval request received") - err = a.onResolvedRequest(ctx, req) - case req.GetState().IsDenied(): - log.Debug("Denial request received") - 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") - - log.Debug("Expiration request received") - - 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 there's a new request or a review -func (a *App) onPendingRequest(ctx context.Context, req types.AccessRequest) error { - log := logger.Get(ctx) - - id := req.GetName() - data := pd.AccessRequestData{ - User: req.GetUser(), - Roles: req.GetRoles(), - RequestReason: req.GetRequestReason(), - } - - // Let's try to create PluginData. This equals to locking AccessRequest to this - // instance of a plugin. - _, err := a.pd.Create(ctx, id, PluginData{AccessRequestData: data}) - - // If we succeeded to create PluginData, let's post a messages and save created Teams messages - if !trace.IsAlreadyExists(err) { - if err != nil { - return trace.Wrap(err) - } - - recipients := a.getMessageRecipients(ctx, req) - - if len(recipients) == 0 { - log.Warning("No recipients to notify") - } else { - err = a.postMessages(ctx, recipients, id, data) - if err != nil { - return trace.Wrap(err) - } - } - } - - // Update the received reviews - reviews := req.GetReviews() - if len(reviews) == 0 { - return nil - } - - err = a.postReviews(ctx, id, reviews) - if err != nil { - return trace.Wrap(err) - } - - return nil -} - -// onResolvedRequest is called when a request is resolved -func (a *App) onResolvedRequest(ctx context.Context, req types.AccessRequest) error { - var tag pd.ResolutionTag - state := req.GetState() - switch state { - case types.RequestState_APPROVED: - tag = pd.ResolvedApproved - case types.RequestState_DENIED: - tag = pd.ResolvedDenied - default: - logger.Get(ctx).Warningf("Unknown state %v (%s)", state, state.String()) - return trace.Errorf("Unknown state") - } - err := a.updateMessages(ctx, req.GetName(), tag, req.GetResolveReason(), req.GetReviews()) - if err != nil { - return trace.Wrap(err) - } - return nil -} - -// onDeleteRequest gets called when a request is deleted -func (a *App) onDeletedRequest(ctx context.Context, reqID string) error { - return a.updateMessages(ctx, reqID, pd.ResolvedExpired, "", nil) -} - -// postMessages posts initial Teams messages -func (a *App) postMessages(ctx context.Context, recipients []string, id string, data pd.AccessRequestData) error { - teamsData, err := a.bot.PostMessages(ctx, recipients, id, data) - if err != nil { - if len(teamsData) == 0 { - // TODO: add better logging here - return trace.Wrap(err) - } - - logger.Get(ctx).WithError(err).Error("Failed to post one or more messages to MS Teams") - } - - for _, data := range teamsData { - logger.Get(ctx).WithFields(logger.Fields{ - "id": data.ID, - "timestamp": data.Timestamp, - "recipient": data.RecipientID, - }).Info("Successfully posted to MS Teams") - } - - // Let's update sent messages data - _, err = a.pd.Update(ctx, id, func(existing PluginData) (PluginData, error) { - existing.TeamsData = teamsData - return existing, nil - }) - - return trace.Wrap(err) -} - -// postReviews updates a message with reviews -func (a *App) postReviews(ctx context.Context, id string, reviews []types.AccessReview) error { - pluginData, err := a.pd.Update(ctx, id, func(existing PluginData) (PluginData, error) { - teamsData := existing.TeamsData - if len(teamsData) == 0 { - // No teamsData found in the plugin data. This might be because of a race condition - // (messages not sent yet) or because sending failed (msapi error or no recipient) - // We don't know which one is true, so we'll still return `CompareFailed` to retry - // TODO: find a better way to handle failures - return existing, trace.CompareFailed("existing teamsData is empty, no messages were sent about this access request") - } - - count := len(reviews) - oldCount := existing.ReviewsCount - if oldCount >= count { - return existing, trace.AlreadyExists("reviews are sent already") - } - - existing.ReviewsCount = count - return existing, nil - }) - if err != nil { - return trace.Wrap(err) - } - - err = a.bot.UpdateMessages(ctx, id, pluginData, reviews) - if err != nil { - return trace.Wrap(err) - } - - return nil -} - -// updateMessages updates the messages status and adds the resolve reason. -func (a *App) updateMessages(ctx context.Context, reqID string, tag pd.ResolutionTag, reason string, reviews []types.AccessReview) error { - log := logger.Get(ctx) - - pluginData, err := a.pd.Update(ctx, reqID, func(existing PluginData) (PluginData, error) { - // No teamsData found in the plugin data. This might be because of a race condition - // (messages not sent yet) or because sending failed (msapi error or no recipient) - // We don't know which one is true, so we'll still return `CompareFailed` to retry - // TODO: find a better way to handle failures - if len(existing.TeamsData) == 0 { - return existing, trace.CompareFailed("existing teamsData is empty") - } - - // If resolution field is not empty then we already resolved the incident before. In this case we just quit. - if existing.AccessRequestData.ResolutionTag != pd.Unresolved { - return existing, trace.AlreadyExists("request has already been resolved, skipping message update") - } - - // Mark plugin data as resolved. - existing.ResolutionTag = tag - existing.ResolutionReason = reason - - return existing, nil - }) - if err != nil { - return trace.Wrap(err) - } - - if err := a.bot.UpdateMessages(ctx, reqID, pluginData, reviews); err != nil { - return trace.Wrap(err) - } - - log.Infof("Successfully marked request as %s in all messages", tag) - - return nil -} - -// getMessageRecipients returns a recipients list for the access request -func (a *App) getMessageRecipients(ctx context.Context, req types.AccessRequest) []string { - log := logger.Get(ctx) - - // We receive a set from GetRawRecipientsFor but we still might end up with duplicate channel names. - // This can happen if this set contains the channel `C` and the email for channel `C`. - recipientSet := stringset.New() - - var validEmailsSuggReviewers []string - for _, reviewer := range req.GetSuggestedReviewers() { - if !lib.IsEmail(reviewer) { - log.Warningf("Failed to notify a suggested reviewer: %q does not look like a valid email", reviewer) - continue - } - - validEmailsSuggReviewers = append(validEmailsSuggReviewers, reviewer) - } - - recipients := a.conf.Recipients.GetRawRecipientsFor(req.GetRoles(), validEmailsSuggReviewers) - for _, recipient := range recipients { - if recipient != "" { - recipientSet.Add(recipient) - } - } - - return recipientSet.ToSlice() -} diff --git a/access/msteams/bot.go b/access/msteams/bot.go deleted file mode 100644 index 236650dd2..000000000 --- a/access/msteams/bot.go +++ /dev/null @@ -1,365 +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" - "fmt" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/plugindata" - "github.com/gravitational/trace" - - "github.com/gravitational/teleport-plugins/access/msteams/msapi" -) - -const ( - RecipientKindUser RecipientKind = "user" - RecipientKindChannel RecipientKind = "channel" -) - -type RecipientKind string - -// RecipientData represents cached data for a recipient (user or channel) -type RecipientData struct { - // ID identifies the recipient, for users it is the UserID, for channels it is "tenant/group/channelName" - ID string - // App installation for the recipient - App msapi.InstalledApp - // Chat for the recipient - Chat msapi.Chat - // Kind of the recipient (user or channel) - Kind RecipientKind -} - -// Channel represents a MSTeams channel parsed from its web URL -type Channel struct { - Name string - Group string - Tenant string - URL url.URL - ChatID string -} - -// Bot represents the facade to MS Teams API -type Bot struct { - // Config MS API configuration - msapi.Config - // teamsApp represents MS Teams app installed for an org - teamsApp *msapi.TeamsApp - // graphClient represents MS API Graph client - graphClient *msapi.GraphClient - // botClient represents MS Bot Framework client - botClient *msapi.BotFrameworkClient - // mu recipients access mutex - mu *sync.RWMutex - // recipients represents the cache of potential message recipients - recipients map[string]RecipientData - // webProxyURL represents Web UI address, if enabled - webProxyURL *url.URL - // clusterName cluster name - clusterName string -} - -// NewBot creates new bot struct -func NewBot(c msapi.Config, clusterName, webProxyAddr string) (*Bot, error) { - var ( - webProxyURL *url.URL - err error - ) - - if webProxyAddr != "" { - webProxyURL, err = lib.AddrToURL(webProxyAddr) - if err != nil { - return nil, trace.Wrap(err) - } - } - - bot := &Bot{ - Config: c, - graphClient: msapi.NewGraphClient(c), - botClient: msapi.NewBotFrameworkClient(c), - recipients: make(map[string]RecipientData), - webProxyURL: webProxyURL, - clusterName: clusterName, - mu: &sync.RWMutex{}, - } - - return bot, nil -} - -// GetTeamsApp finds the application in org store and caches it in a bot instance -func (b *Bot) GetTeamsApp(ctx context.Context) (*msapi.TeamsApp, error) { - teamsApp, err := b.graphClient.GetTeamsApp(ctx, b.Config.TeamsAppID) - if err != nil { - return nil, trace.Wrap(err) - } - - b.teamsApp = teamsApp - return b.teamsApp, nil -} - -// GetUserIDByEmail gets a user ID by email. NotFoundError if not found. -func (b *Bot) GetUserIDByEmail(ctx context.Context, email string) (string, error) { - user, err := b.graphClient.GetUserByEmail(ctx, email) - if trace.IsNotFound(err) { - return "", trace.Wrap(err, "try user id instead") - } else if err != nil { - return "", trace.Wrap(err) - } - - return user.ID, nil -} - -// UserExists return true if a user exists. Returns NotFoundError if not found. -func (b *Bot) UserExists(ctx context.Context, id string) error { - _, err := b.graphClient.GetUserByID(ctx, id) - if err != nil { - return trace.Wrap(err) - } - - return nil -} - -func (b *Bot) UninstallAppForUser(ctx context.Context, userIDOrEmail string) error { - if b.teamsApp == nil { - return trace.Errorf("Bot is not configured, run GetTeamsApp first") - } - - userID, err := b.getUserID(ctx, userIDOrEmail) - if err != nil { - return trace.Wrap(err) - } - - installedApp, err := b.graphClient.GetAppForUser(ctx, b.teamsApp, userID) - if trace.IsNotFound(err) { - // App is already uninstalled, nothing to do - return nil - } else if err != nil { - return trace.Wrap(err) - } - - err = b.graphClient.UninstallAppForUser(ctx, userID, installedApp.ID) - return trace.Wrap(err) -} - -// FetchRecipient checks if recipient is a user or a channel, installs app for a user if missing, fetches chat id -// and saves everything to cache. This method is used for priming the cache. Returns trace.NotFound if a -// user was not found. -func (b *Bot) FetchRecipient(ctx context.Context, recipient string) (*RecipientData, error) { - if b.teamsApp == nil { - return nil, trace.Errorf("Bot is not configured, run GetTeamsApp first") - } - - b.mu.RLock() - d, ok := b.recipients[recipient] - b.mu.RUnlock() - if ok { - return &d, nil - } - - // Check if the recipient is a channel - channel, isChannel := checkChannelURL(recipient) - if isChannel { - // A team and a group are different but in MsTeams the team is associated to a group and will have the same id. - installedApp, err := b.graphClient.GetAppForTeam(ctx, b.teamsApp, channel.Group) - if err != nil { - return nil, trace.Wrap(err) - } - d = RecipientData{ - ID: fmt.Sprintf("%s/%s/%s", channel.Tenant, channel.Group, channel.Name), - App: *installedApp, - Chat: msapi.Chat{ - ID: channel.ChatID, - TenantID: channel.Tenant, - WebURL: channel.URL.String(), - }, - Kind: RecipientKindChannel, - } - // If the recipient is not a channel, it means it is a user (either email or userID) - } else { - userID, err := b.getUserID(ctx, recipient) - if err != nil { - return &RecipientData{}, trace.Wrap(err) - } - - var installedApp *msapi.InstalledApp - - installedApp, err = b.graphClient.GetAppForUser(ctx, b.teamsApp, userID) - if trace.IsNotFound(err) { - err := b.graphClient.InstallAppForUser(ctx, userID, b.teamsApp.ID) - // If two installations are running at the same time, one of them will return "Conflict". - // This status code is OK to ignore as it means the app is already installed. - if err != nil && msapi.GetErrorCode(err) != http.StatusText(http.StatusConflict) { - return nil, trace.Wrap(err) - } - - installedApp, err = b.graphClient.GetAppForUser(ctx, b.teamsApp, userID) - if err != nil { - return nil, trace.Wrap(err, "Failed to install app %v for user %v", b.teamsApp.ID, userID) - } - } else if err != nil { - return nil, trace.Wrap(err) - } - - chat, err := b.graphClient.GetChatForInstalledApp(ctx, userID, installedApp.ID) - if err != nil { - return nil, trace.Wrap(err) - } - - d = RecipientData{userID, *installedApp, chat, RecipientKindUser} - } - - b.mu.Lock() - b.recipients[recipient] = d - b.mu.Unlock() - - return &d, nil -} - -// getUserID takes a userID or an email, checks if it exists, and returns the userID. -func (b *Bot) getUserID(ctx context.Context, userIDOrEmail string) (string, error) { - if lib.IsEmail(userIDOrEmail) { - uid, err := b.GetUserIDByEmail(ctx, userIDOrEmail) - if err != nil { - return "", trace.Wrap(err) - } - - return uid, nil - } - _, err := b.graphClient.GetUserByID(ctx, userIDOrEmail) - if err != nil { - return "", trace.Wrap(err) - } - return userIDOrEmail, nil -} - -// PostAdaptiveCardActivity sends the AdaptiveCard to a user -func (b *Bot) PostAdaptiveCardActivity(ctx context.Context, recipient, cardBody, updateID string) (string, error) { - recipientData, err := b.FetchRecipient(ctx, recipient) - if err != nil { - return "", trace.Wrap(err) - } - - id, err := b.botClient.PostAdaptiveCardActivity( - ctx, recipientData.App.ID, recipientData.Chat.ID, cardBody, updateID, - ) - if err != nil { - return "", trace.Wrap(err) - } - - return id, nil -} - -// PostMessages sends a message to a set of recipients. Returns array of TeamsMessage to cache. -func (b *Bot) PostMessages(ctx context.Context, recipients []string, id string, reqData plugindata.AccessRequestData) ([]TeamsMessage, error) { - var data []TeamsMessage - var errors []error - - body, err := BuildCard(id, b.webProxyURL, b.clusterName, reqData, nil) - if err != nil { - return nil, trace.Wrap(err) - } - - for _, recipient := range recipients { - id, err := b.PostAdaptiveCardActivity(ctx, recipient, body, "") - if err != nil { - errors = append(errors, trace.Wrap(err)) - continue - } - msg := TeamsMessage{ - ID: id, - Timestamp: time.Now().Format(time.RFC822), - RecipientID: recipient, - } - data = append(data, msg) - } - - if len(errors) == 0 { - return data, nil - } - - return data, trace.NewAggregate(errors...) -} - -// UpdateMessages posts message updates -func (b *Bot) UpdateMessages(ctx context.Context, id string, data PluginData, reviews []types.AccessReview) error { - var errors []error - - body, err := BuildCard(id, b.webProxyURL, b.clusterName, data.AccessRequestData, reviews) - if err != nil { - return trace.Wrap(err) - } - - for _, msg := range data.TeamsData { - _, err := b.PostAdaptiveCardActivity(ctx, msg.RecipientID, body, msg.ID) - if err != nil { - errors = append(errors, trace.Wrap(err)) - } - } - - if len(errors) == 0 { - return nil - } - - return trace.NewAggregate(errors...) -} - -// checkChannelURL receives a recipient and checks if it is a channel URL. -// If it is the case, the URL is parsed and the channel RecipientData is returned -func checkChannelURL(recipient string) (*Channel, bool) { - channelURL, err := url.Parse(recipient) - if err != nil { - return nil, false - } - - var tenantID, groupID, channelName, chatID string - for k, v := range channelURL.Query() { - switch k { - case "tenantId": - tenantID = v[0] - case "groupId": - groupID = v[0] - default: - } - } - if tenantID == "" || groupID == "" { - return nil, false - } - - // There is no risk to have a channelName with a "/" as they are url-encoded twice - path := strings.Split(channelURL.Path, "/") - if len(path) != 5 { - return nil, false - } - channelName = path[len(path)-1] - chatID = path[len(path)-2] - - channel := Channel{ - Name: channelName, - Group: groupID, - Tenant: tenantID, - URL: *channelURL, - ChatID: chatID, - } - - return &channel, true -} diff --git a/access/msteams/bot_test.go b/access/msteams/bot_test.go deleted file mode 100644 index aa302a49d..000000000 --- a/access/msteams/bot_test.go +++ /dev/null @@ -1,83 +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 ( - "net/url" - "testing" - - "github.com/stretchr/testify/require" -) - -func mustParseURL(t *testing.T, urlString string) *url.URL { - parsedURL, err := url.Parse(urlString) - require.NoError(t, err) - return parsedURL -} - -func Test_CheckChannelURL(t *testing.T) { - tests := []struct { - name string - url string - expectedUserData *Channel - validURL bool - }{ - { - name: "Valid URL", - url: "https://teams.microsoft.com/l/channel/19%3ae06a7383ed98468f90217a35fa1980d7%40thread.tacv2/Approval%2520Channel%25202?groupId=f2b3c8ed-5502-4449-b76f-dc3acea81f1c&tenantId=ff882432-09b0-437b-bd22-ca13c0037ded", - expectedUserData: &Channel{ - Name: "Approval%20Channel%202", - Group: "f2b3c8ed-5502-4449-b76f-dc3acea81f1c", - Tenant: "ff882432-09b0-437b-bd22-ca13c0037ded", - URL: *mustParseURL(t, "https://teams.microsoft.com/l/channel/19%3ae06a7383ed98468f90217a35fa1980d7%40thread.tacv2/Approval%2520Channel%25202?groupId=f2b3c8ed-5502-4449-b76f-dc3acea81f1c&tenantId=ff882432-09b0-437b-bd22-ca13c0037ded"), - ChatID: "19:e06a7383ed98468f90217a35fa1980d7@thread.tacv2", - }, - validURL: true, - }, - { - name: "Invalid URL (no tenant)", - url: "https://teams.microsoft.com/l/channel/19%3ae06a7383ed98468f90217a35fa1980d7%40thread.tacv2/Approval%2520Channel%25202?groupId=f2b3c8ed-5502-4449-b76f-dc3acea81f1c", - expectedUserData: nil, - validURL: false, - }, - { - name: "Invalid URL (wrong length)", - url: "https://teams.microsoft.com/channel/19%3ae06a7383ed98468f90217a35fa1980d7%40thread.tacv2/Approval%2520Channel%25202?groupId=f2b3c8ed-5502-4449-b76f-dc3acea81f1c&tenantId=ff882432-09b0-437b-bd22-ca13c0037ded", - expectedUserData: nil, - validURL: false, - }, - { - name: "Email", - url: "foo@example.com", - expectedUserData: nil, - validURL: false, - }, - { - name: "Not an URL", - url: "This is not an url πŸ™‚", - expectedUserData: nil, - validURL: false, - }, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - data, ok := checkChannelURL(tc.url) - require.Equal(t, tc.validURL, ok) - if tc.validURL { - require.Equal(t, tc.expectedUserData, data) - } - }) - } -} diff --git a/access/msteams/card.go b/access/msteams/card.go deleted file mode 100644 index 391eb58f7..000000000 --- a/access/msteams/card.go +++ /dev/null @@ -1,179 +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" - "net/url" - "strings" - "time" - - cards "github.com/DanielTitkov/go-adaptive-cards" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/plugindata" -) - -// BuildCard builds the MS Teams message from a request data -func BuildCard(id string, webProxyURL *url.URL, clusterName string, data plugindata.AccessRequestData, reviews []types.AccessReview) (string, error) { - var statusEmoji string - status := string(data.ResolutionTag) - statusColor := "" - statusEmoji = resolutionIcon(data.ResolutionTag) - - switch data.ResolutionTag { - case plugindata.Unresolved: - status = "PENDING" - statusColor = "Accent" - case plugindata.ResolvedApproved: - statusColor = "Good" - case plugindata.ResolvedDenied: - statusColor = "Attention" - case plugindata.ResolvedExpired: - statusColor = "Accent" - } - - var actions []cards.Node - - facts := []*cards.Fact{ - {Title: "Cluster", Value: clusterName}, - {Title: "User", Value: data.User}, - {Title: "Role(s)", Value: strings.Join(data.Roles, ", ")}, - } - - if data.RequestReason != "" { - facts = append(facts, &cards.Fact{Title: "Reason", Value: data.RequestReason}) - } - - if data.ResolutionReason != "" { - facts = append(facts, &cards.Fact{Title: "Resolution reason", Value: data.ResolutionReason}) - } - - if webProxyURL != nil { - reqURL := *webProxyURL - reqURL.Path = lib.BuildURLPath("web", "requests", id) - actions = []cards.Node{ - &cards.ActionOpenURL{ - URL: reqURL.String(), - Title: "Open", - }, - } - } else { - if data.ResolutionTag == plugindata.Unresolved { - facts = append( - facts, - &cards.Fact{Title: "Approve", Value: fmt.Sprintf("tsh request review --approve %s", id)}, - &cards.Fact{Title: "Deny", Value: fmt.Sprintf("tsh request review --deny %s", id)}, - ) - } - } - - body := []cards.Node{ - &cards.TextBlock{ - Text: fmt.Sprintf("Access Request %v", id), - Size: "small", - }, - &cards.ColumnSet{ - Columns: []*cards.Column{ - { - Width: "stretch", - Items: []cards.Node{ - &cards.TextBlock{ - Text: statusEmoji, - Size: "large", - }, - }, - }, - { - Width: "auto", - Items: []cards.Node{ - &cards.TextBlock{ - Text: status, - Size: "large", - Weight: "bolder", - Color: statusColor, - }, - }, - }, - }, - }, - &cards.FactSet{ - Facts: facts, - }, - } - - if len(reviews) > 0 { - body = append( - body, - &cards.TextBlock{ - Text: "Reviews", - Weight: "bolder", - Color: "accent", - Separator: cards.TruePtr(), - }, - ) - - nodes := make([]cards.Node, 0) - - for _, r := range reviews { - facts := []*cards.Fact{ - { - Title: "Status", - Value: resolutionIcon(plugindata.ResolutionTag(r.ProposedState.String())), - }, - { - Title: "Author", - Value: r.Author, - }, - { - Title: "Created at", - Value: r.Created.Format(time.RFC822), - }, - } - - if r.Reason != "" { - facts = append(facts, &cards.Fact{ - Title: "Reason", - Value: r.Reason, - }) - } - - nodes = append(nodes, &cards.FactSet{Facts: facts}) - } - - body = append(body, nodes...) - } - - card := cards.New(body, actions). - WithSchema(cards.DefaultSchema). - WithVersion(cards.Version12) - - return card.StringIndent("", " ") -} - -func resolutionIcon(tag plugindata.ResolutionTag) string { - switch tag { - case plugindata.Unresolved: - return "⏳" - case plugindata.ResolvedApproved: - return "βœ…" - case plugindata.ResolvedDenied: - return "❌" - case plugindata.ResolvedExpired: - return "βŒ›" - } - - return "" -} diff --git a/access/msteams/config.go b/access/msteams/config.go deleted file mode 100644 index 448e1d628..000000000 --- a/access/msteams/config.go +++ /dev/null @@ -1,89 +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/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" - - "github.com/gravitational/teleport-plugins/access/msteams/msapi" -) - -// Config represents plugin configuration -type Config struct { - Teleport lib.TeleportConfig - Recipients common.RawRecipientsMap `toml:"role_to_recipients"` - Log logger.Config - MSAPI msapi.Config `toml:"msapi"` - Preload bool `toml:"preload"` -} - -// 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) - } - - // Azure secret format does not seem to support starting with a "/" - if strings.HasPrefix(conf.MSAPI.AppSecret, "/") { - conf.MSAPI.AppSecret, err = lib.ReadPassword(conf.MSAPI.AppSecret) - if err != nil { - return nil, trace.Wrap(err) - } - } - - err = conf.CheckAndSetDefaults() - if err != nil { - return nil, trace.Wrap(err) - } - return conf, nil -} - -// 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 err := c.Teleport.CheckAndSetDefaults(); err != nil { - return trace.Wrap(err) - } - - if c.Log.Output == "" { - c.Log.Output = "stderr" - } - if c.Log.Severity == "" { - c.Log.Severity = "info" - } - - if len(c.Recipients) == 0 { - return trace.BadParameter("missing required value role_to_recipients.") - } else if len(c.Recipients[types.Wildcard]) == 0 { - return trace.BadParameter("missing required value role_to_recipients[%v].", types.Wildcard) - } - - return nil -} diff --git a/access/msteams/configure.go b/access/msteams/configure.go deleted file mode 100644 index b23734bd8..000000000 --- a/access/msteams/configure.go +++ /dev/null @@ -1,185 +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 ( - "archive/zip" - "embed" - "fmt" - "html/template" - "io" - "os" - "path" - - "github.com/google/uuid" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/trace" -) - -const ( - guideURL = "https://goteleport.com/docs/enterprise/workflow/" -) - -var ( - //go:embed _tpl/teleport-msteams.toml - confTpl string - - //go:embed _tpl/manifest.json - manifestTpl string - - //go:embed _tpl/outline.png _tpl/color.png _tpl/teleport-msteams-role.yaml - assets embed.FS - - // zipFiles represents file names which should be compressed into app.zip - zipFiles = []string{"manifest.json", "outline.png", "color.png"} -) - -// payload represents template payload -type payload struct { - AppID string - AppSecret string - TenantID string - TeamsAppID string -} - -// configure creates required template files -func configure(targetDir, appID, appSecret, tenantID string) error { - var step byte = 1 - - p := payload{ - AppID: appID, - AppSecret: appSecret, - TenantID: tenantID, - TeamsAppID: uuid.New().String(), - } - - lib.PrintVersion(appName, Version, Gitref) - fmt.Println() - - fi, err := os.Stat(targetDir) - if err != nil && !os.IsNotExist(err) { - return trace.Wrap(err) - } - if fi != nil { - return trace.Errorf("%v exists! Please, specify an empty folder", targetDir) - } - - err = os.MkdirAll(targetDir, 0777) - if err != nil { - return trace.Wrap(err) - } - - printStep(&step, "Created target directory: %s", targetDir) - - if err := renderTemplateTo(confTpl, p, path.Join(targetDir, "teleport-msteams.toml")); err != nil { - return trace.Wrap(err) - } - if err := renderTemplateTo(manifestTpl, p, path.Join(targetDir, "manifest.json")); err != nil { - return trace.Wrap(err) - } - - printStep(&step, "Generated configuration files") - - a, err := assets.ReadDir("_tpl") - if err != nil { - return trace.Wrap(err) - } - - for _, d := range a { - in, err := assets.Open(path.Join("_tpl", d.Name())) - if err != nil { - return trace.Wrap(err) - } - defer in.Close() - - out, err := os.Create(path.Join(targetDir, d.Name())) - if err != nil { - return trace.Wrap(err) - } - defer out.Close() - - _, err = io.Copy(out, in) - if err != nil { - return trace.Wrap(err) - } - } - - printStep(&step, "Copied assets") - - z, err := os.Create(path.Join(targetDir, "app.zip")) - if err != nil { - return trace.Wrap(err) - } - defer z.Close() - - w := zip.NewWriter(z) - defer w.Close() - - for _, n := range zipFiles { - in, err := os.Open(path.Join(targetDir, n)) - if err != nil { - return trace.Wrap(err) - } - defer in.Close() - - out, err := w.Create(n) - if err != nil { - return trace.Wrap(err) - } - _, err = io.Copy(out, in) - if err != nil { - return trace.Wrap(err) - } - } - - printStep(&step, "Created app.zip") - - fmt.Println() - fmt.Printf("TeamsAppID: %v\n", p.TeamsAppID) - fmt.Println() - fmt.Println("Follow-along with our getting started guide:") - fmt.Println() - fmt.Println(guideURL) - - return nil -} - -// printStep prints formatted string leaded with step number -func printStep(step *byte, message string, args ...interface{}) { - p := append([]interface{}{*step}, args...) - fmt.Printf("[%v] "+message+"\n", p...) - *step++ -} - -// renderTemplateTo renders template from a string and writes file to targetPath -func renderTemplateTo(content string, payload interface{}, targetPath string) error { - tpl, err := template.New("template").Parse(content) - if err != nil { - return trace.Wrap(err) - } - - w, err := os.Create(targetPath) - if err != nil { - return trace.Wrap(err) - } - defer w.Close() - - err = tpl.ExecuteTemplate(w, "template", payload) - if err != nil { - return trace.Wrap(err) - } - - return nil -} diff --git a/access/msteams/images/add_channel.png b/access/msteams/images/add_channel.png deleted file mode 100644 index 84354cf3a..000000000 Binary files a/access/msteams/images/add_channel.png and /dev/null differ diff --git a/access/msteams/images/add_password.png b/access/msteams/images/add_password.png deleted file mode 100644 index 5208fa654..000000000 Binary files a/access/msteams/images/add_password.png and /dev/null differ diff --git a/access/msteams/images/add_to_team.png b/access/msteams/images/add_to_team.png deleted file mode 100644 index 01e457cf4..000000000 Binary files a/access/msteams/images/add_to_team.png and /dev/null differ diff --git a/access/msteams/images/app_create.png b/access/msteams/images/app_create.png deleted file mode 100644 index 8fd622417..000000000 Binary files a/access/msteams/images/app_create.png and /dev/null differ diff --git a/access/msteams/images/hello_world.png b/access/msteams/images/hello_world.png deleted file mode 100644 index 7bafa5022..000000000 Binary files a/access/msteams/images/hello_world.png and /dev/null differ diff --git a/access/msteams/images/manage_app.png b/access/msteams/images/manage_app.png deleted file mode 100644 index 9fb32690d..000000000 Binary files a/access/msteams/images/manage_app.png and /dev/null differ diff --git a/access/msteams/images/upload_app.png b/access/msteams/images/upload_app.png deleted file mode 100644 index 63182a185..000000000 Binary files a/access/msteams/images/upload_app.png and /dev/null differ diff --git a/access/msteams/main.go b/access/msteams/main.go index 9742e004c..10d7a3dac 100644 --- a/access/msteams/main.go +++ b/access/msteams/main.go @@ -20,6 +20,7 @@ import ( "time" "github.com/gravitational/kingpin" + "github.com/gravitational/teleport/integrations/access/msteams" "github.com/gravitational/teleport/integrations/lib" "github.com/gravitational/teleport/integrations/lib/logger" "github.com/gravitational/trace" @@ -73,19 +74,19 @@ func main() { switch selectedCmd { case "configure": - err := configure(*targetDir, *appID, *appSecret, *tenantID) + err := msteams.Configure(*targetDir, *appID, *appSecret, *tenantID) if err != nil { lib.Bail(err) } case "uninstall": - err := uninstall(context.Background(), *uninstallConfigPath) + err := msteams.Uninstall(context.Background(), *uninstallConfigPath) if err != nil { lib.Bail(err) } case "validate": - err := validate(*validateConfigPath, *validateRecipientID) + err := msteams.Validate(*validateConfigPath, *validateRecipientID) if err != nil { lib.Bail(err) } @@ -103,7 +104,7 @@ func main() { } func run(configPath string, debug bool) error { - conf, err := LoadConfig(configPath) + conf, err := msteams.LoadConfig(configPath) if err != nil { return trace.Wrap(err) } @@ -119,7 +120,7 @@ func run(configPath string, debug bool) error { logger.Standard().Debugf("DEBUG logging enabled") } - app, err := NewApp(*conf) + app, err := msteams.NewApp(*conf) if err != nil { return trace.Wrap(err) } diff --git a/access/msteams/mock_ms_teams_api_test.go b/access/msteams/mock_ms_teams_api_test.go deleted file mode 100644 index 2911fc0cd..000000000 --- a/access/msteams/mock_ms_teams_api_test.go +++ /dev/null @@ -1,331 +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" - "io" - "net/http" - "net/http/httptest" - "runtime/debug" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/google/uuid" - "github.com/gravitational/trace" - "github.com/julienschmidt/httprouter" - log "github.com/sirupsen/logrus" - - "github.com/gravitational/teleport-plugins/access/msteams/msapi" -) - -type response struct { - Value interface{} `json:"value"` -} - -type Msg struct { - ID string - RecipientID string - Body string -} - -type MockMSTeamsAPI struct { - srv *httptest.Server - - msapi.Config - - teamsApp msapi.TeamsApp - - objects sync.Map - newMessages chan Msg - updatedMessages chan Msg - userIDCounter uint64 - startTime time.Time -} - -func NewMockMSTeamsAPI(concurrency int) *MockMSTeamsAPI { - router := httprouter.New() - - s := &MockMSTeamsAPI{ - newMessages: make(chan Msg, concurrency*2), - updatedMessages: make(chan Msg, concurrency*2), - startTime: time.Now(), - srv: httptest.NewServer(router), - Config: msapi.Config{ - AppID: uuid.NewString(), - AppSecret: uuid.NewString(), - TenantID: uuid.NewString(), - TeamsAppID: uuid.NewString(), - }, - } - - s.teamsApp = msapi.TeamsApp{ - ID: s.Config.AppID, - ExternalID: s.Config.TeamsAppID, - DisplayName: "Teleport Bot", - } - - router.POST("/"+s.Config.TenantID+"/oauth2/v2.0/token", func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - err := json.NewEncoder(rw).Encode(response{Value: msapi.Token{ - AccessToken: uuid.New().String(), - ExpiresIn: 3600, - }}) - panicIf(err) - }) - - router.GET("/appCatalogs/teamsApps", func(rw http.ResponseWriter, r *http.Request, _ httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - err := json.NewEncoder(rw).Encode(response{Value: []msapi.TeamsApp{s.teamsApp}}) - panicIf(err) - }) - - router.GET("/users", func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { - var v interface{} = []msapi.User{} - - filter := r.URL.Query().Get("$filter") - value := filter[strings.Index(filter, "'")+1 : len(filter)-1] - - if strings.Contains(filter, "mail eq") { - u, ok := s.GetUserByEmail(value) - if ok { - v = []msapi.User{u} - } - } - - if strings.Contains(filter, "id eq") { - u, ok := s.GetUser(value) - if ok { - v = []msapi.User{u} - } - } - - err := json.NewEncoder(rw).Encode(response{Value: v}) - panicIf(err) - }) - - router.POST("/users/:userID/teamWork/installedApps", func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { - var v struct { - URL string `json:"teamsApp@odata.bind"` - } - - _, ok := s.GetUser(p.ByName("userID")) - if !ok { - rw.WriteHeader(http.StatusBadRequest) - return - } - - err := json.NewDecoder(r.Body).Decode(&v) - id := v.URL[strings.LastIndex(v.URL, "/")+1 : len(v.URL)] - - s.StoreApp(msapi.InstalledApp{ID: id}) - - rw.WriteHeader(http.StatusCreated) - panicIf(err) - }) - - router.GET("/users/:userID/teamWork/installedApps", func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { - var v interface{} = []msapi.InstalledApp{} - - _, ok := s.GetUser(p.ByName("userID")) - if !ok { - rw.WriteHeader(http.StatusBadRequest) - return - } - - filter := r.URL.Query().Get("$filter") - id := filter[strings.Index(filter, "'")+1 : len(filter)-1] - - a, ok := s.GetApp(id) - if ok { - v = []msapi.InstalledApp{a} - } - err := json.NewEncoder(rw).Encode(response{Value: v}) - panicIf(err) - }) - - router.GET("/users/:userID/teamWork/installedApps/:appID/chat", func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { - var c msapi.Chat - - _, ok := s.GetUser(p.ByName("userID")) - if !ok { - rw.WriteHeader(http.StatusBadRequest) - return - } - - _, ok = s.GetApp(p.ByName("appID")) - if !ok { - rw.WriteHeader(http.StatusBadRequest) - return - } - - id := uuid.NewString() - c = msapi.Chat{ID: id, TenantID: p.ByName("userID")} - s.StoreChat(c) - - err := json.NewEncoder(rw).Encode(c) - panicIf(err) - }) - - router.POST("/emea/v3/conversations/:chatID/activities", func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - b, err := io.ReadAll(r.Body) - panicIf(err) - - id := uuid.NewString() - - c, ok := s.GetChat(p.ByName("chatID")) - if !ok { - rw.WriteHeader(http.StatusBadRequest) - return - } - - msg := Msg{ - ID: id, - RecipientID: c.TenantID, - Body: string(b), - } - - s.newMessages <- msg - - rw.WriteHeader(http.StatusCreated) - - _, err = rw.Write([]byte(`{"id":"` + id + `"}`)) - panicIf(err) - }) - - router.PUT("/emea/v3/conversations/:chatID/activities/:id", func(rw http.ResponseWriter, r *http.Request, p httprouter.Params) { - rw.Header().Add("Content-Type", "application/json") - - b, err := io.ReadAll(r.Body) - panicIf(err) - - id := p.ByName("chatID") - - c, ok := s.GetChat(id) - if !ok { - rw.WriteHeader(http.StatusBadRequest) - return - } - - msg := Msg{ - ID: p.ByName("id"), - RecipientID: c.TenantID, - Body: string(b), - } - - s.updatedMessages <- msg - - rw.WriteHeader(http.StatusOK) - - _, err = rw.Write([]byte(`{"id":"` + id + `"}`)) - panicIf(err) - }) - - return s -} - -func (s *MockMSTeamsAPI) URL() string { - return s.srv.URL -} - -func (s *MockMSTeamsAPI) Close() { - s.srv.Close() - close(s.newMessages) - close(s.updatedMessages) -} - -func (s *MockMSTeamsAPI) StoreUser(user msapi.User) msapi.User { - if user.ID == "" { - user.ID = fmt.Sprintf("U%d", atomic.AddUint64(&s.userIDCounter, 1)) - } - - s.objects.Store(fmt.Sprintf("user-%s", user.ID), user) - s.objects.Store(fmt.Sprintf("userByEmail-%s", user.Mail), user) - - return user -} - -func (s *MockMSTeamsAPI) StoreApp(a msapi.InstalledApp) msapi.InstalledApp { - a.TeamsApp = s.teamsApp - s.objects.Store(fmt.Sprintf("app-%s", a.ID), a) - return a -} - -func (s *MockMSTeamsAPI) StoreChat(c msapi.Chat) msapi.Chat { - s.objects.Store(fmt.Sprintf("chat-%s", c.ID), c) - return c -} - -func (s *MockMSTeamsAPI) GetUser(id string) (msapi.User, bool) { - if obj, ok := s.objects.Load(fmt.Sprintf("user-%s", id)); ok { - user, ok := obj.(msapi.User) - return user, ok - } - return msapi.User{}, false -} - -func (s *MockMSTeamsAPI) GetUserByEmail(email string) (msapi.User, bool) { - if obj, ok := s.objects.Load(fmt.Sprintf("userByEmail-%s", email)); ok { - user, ok := obj.(msapi.User) - return user, ok - } - return msapi.User{}, false -} - -func (s *MockMSTeamsAPI) GetApp(id string) (msapi.InstalledApp, bool) { - if obj, ok := s.objects.Load(fmt.Sprintf("app-%s", id)); ok { - user, ok := obj.(msapi.InstalledApp) - return user, ok - } - return msapi.InstalledApp{}, false -} - -func (s *MockMSTeamsAPI) GetChat(id string) (msapi.Chat, bool) { - if obj, ok := s.objects.Load(fmt.Sprintf("chat-%s", id)); ok { - user, ok := obj.(msapi.Chat) - return user, ok - } - return msapi.Chat{}, false -} - -func (s *MockMSTeamsAPI) CheckNewMessage(ctx context.Context) (Msg, error) { - select { - case message := <-s.newMessages: - return message, nil - case <-ctx.Done(): - return Msg{}, trace.Wrap(ctx.Err()) - } -} - -func (s *MockMSTeamsAPI) CheckMessageUpdate(ctx context.Context) (Msg, error) { - select { - case message := <-s.updatedMessages: - return message, nil - case <-ctx.Done(): - return Msg{}, trace.Wrap(ctx.Err()) - } -} - -func panicIf(err error) { - if err != nil { - log.Panicf("%v at %v", err, string(debug.Stack())) - } -} diff --git a/access/msteams/ms_teams_test.go b/access/msteams/ms_teams_test.go deleted file mode 100644 index d995f6cc9..000000000 --- a/access/msteams/ms_teams_test.go +++ /dev/null @@ -1,694 +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" - "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/access/common" - "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" - "github.com/tidwall/gjson" - - "github.com/gravitational/teleport-plugins/access/msteams/msapi" -) - -type TeamsSuite struct { - integration.Suite - appConfig Config - userNames struct { - ruler string - requestor string - reviewer1 string - reviewer2 string - plugin string - } - raceNumber int - mockAPI *MockMSTeamsAPI - - clients map[string]*integration.Client - teleportFeatures *proto.Features - teleportConfig lib.TeleportConfig -} - -func TestMSTeams(t *testing.T) { suite.Run(t, &TeamsSuite{}) } - -func (s *TeamsSuite) 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-msteams", 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-msteams", 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 *TeamsSuite) SetupTest() { - t := s.T() - - err := logger.Setup(logger.Config{Severity: "debug"}) - require.NoError(t, err) - - s.mockAPI = NewMockMSTeamsAPI(s.raceNumber) - t.Cleanup(s.mockAPI.Close) - - var conf Config - conf.Teleport = s.teleportConfig - conf.MSAPI = s.mockAPI.Config - conf.MSAPI.SetBaseURLs(s.mockAPI.URL(), s.mockAPI.URL(), s.mockAPI.URL()) - - s.appConfig = conf - s.SetContextTimeout(5 * time.Second) -} - -func (s *TeamsSuite) startApp() { - t := s.T() - t.Helper() - - app, err := NewApp(s.appConfig) - require.NoError(t, err) - - s.StartApp(app) -} - -func (s *TeamsSuite) ruler() *integration.Client { - return s.clients[s.userNames.ruler] -} - -func (s *TeamsSuite) requestor() *integration.Client { - return s.clients[s.userNames.requestor] -} - -func (s *TeamsSuite) reviewer1() *integration.Client { - return s.clients[s.userNames.reviewer1] -} - -func (s *TeamsSuite) reviewer2() *integration.Client { - return s.clients[s.userNames.reviewer2] -} - -func (s *TeamsSuite) newAccessRequest(reviewers []msapi.User) 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 " + strings.Repeat("A", 4000)) - - var suggestedReviewers []string - for _, user := range reviewers { - suggestedReviewers = append(suggestedReviewers, user.Mail) - } - req.SetSuggestedReviewers(suggestedReviewers) - - return req -} - -func (s *TeamsSuite) createAccessRequest(reviewers []msapi.User) types.AccessRequest { - t := s.T() - t.Helper() - - req := s.newAccessRequest(reviewers) - out, err := s.requestor().CreateAccessRequestV2(s.Context(), req) - require.NoError(t, err) - return out -} - -func (s *TeamsSuite) checkPluginData(reqID string, cond func(interface{}) bool) interface{} { - t := s.T() - t.Helper() - - for { - rawData, err := s.ruler().PollAccessRequestPluginData(s.Context(), "msteams", reqID) - require.NoError(t, err) - data, err := DecodePluginData(rawData) - require.NoError(t, err) - if cond(data) { - return data - } - } -} - -func (s *TeamsSuite) getNewMessages(t *testing.T, n int) MsgSlice { - msgs := MsgSlice{} - for i := 0; i < 2; i++ { - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - msgs = append(msgs, msg) - } - sort.Sort(msgs) - return msgs -} - -func (s *TeamsSuite) TestMessagePosting() { - t := s.T() - - reviewer1 := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - reviewer2 := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer2}) - - s.startApp() - - request := s.createAccessRequest([]msapi.User{reviewer2, reviewer1}) - - pluginData := s.checkPluginData(request.GetName(), func(data interface{}) bool { - return len(data.(PluginData).TeamsData) > 0 - }) - require.Len(t, pluginData.(PluginData).TeamsData, 2) - - title := "Access Request " + request.GetName() - - msgs := s.getNewMessages(t, 2) - - require.Equal(t, gjson.Get(msgs[0].Body, "attachments.0.content.body.0.text").String(), title) - require.Equal(t, msgs[0].RecipientID, reviewer1.ID) - - require.Equal(t, gjson.Get(msgs[1].Body, "attachments.0.content.body.0.text").String(), title) - require.Equal(t, msgs[1].RecipientID, reviewer2.ID) -} - -func (s *TeamsSuite) TestRecipientsConfig() { - t := s.T() - - reviewer1 := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - reviewer2 := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer2}) - s.appConfig.Recipients = common.RawRecipientsMap{ - types.Wildcard: []string{reviewer2.Mail, reviewer1.ID}, - } - - s.startApp() - - request := s.createAccessRequest(nil) - pluginData := s.checkPluginData(request.GetName(), func(data interface{}) bool { - return len(data.(PluginData).TeamsData) > 0 - }) - require.Len(t, pluginData.(PluginData).TeamsData, 2) - - title := "Access Request " + request.GetName() - - msgs := s.getNewMessages(t, 2) - - require.Equal(t, gjson.Get(msgs[0].Body, "attachments.0.content.body.0.text").String(), title) - require.Equal(t, msgs[0].RecipientID, reviewer1.ID) - - require.Equal(t, gjson.Get(msgs[1].Body, "attachments.0.content.body.0.text").String(), title) - require.Equal(t, msgs[1].RecipientID, reviewer2.ID) -} - -func (s *TeamsSuite) TestApproval() { - t := s.T() - - reviewer := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]msapi.User{reviewer}) - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msg.RecipientID) - - err = s.ruler().ApproveAccessRequest(s.Context(), req.GetName(), "okay") - require.NoError(t, err) - - msgUpdate, err := s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, reviewer.ID, msgUpdate.RecipientID) - require.Equal(t, msg.ID, msgUpdate.ID) - - require.NoError(t, err) - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.1.columns.0.items.0.text").String(), "βœ…") - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.1.columns.1.items.0.text").String(), "APPROVED") - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.2.facts.4.value").String(), "okay") -} - -func (s *TeamsSuite) TestDenial() { - t := s.T() - - reviewer := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]msapi.User{reviewer}) - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msg.RecipientID) - - // max size of request was decreased here: https://github.com/gravitational/teleport/pull/13298 - err = s.ruler().DenyAccessRequest(s.Context(), req.GetName(), "not okay") - require.NoError(t, err) - - msgUpdate, err := s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, reviewer.ID, msgUpdate.RecipientID) - require.Equal(t, msg.ID, msgUpdate.ID) - - require.NoError(t, err) - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.1.columns.0.items.0.text").String(), "❌") - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.1.columns.1.items.0.text").String(), "DENIED") - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.2.facts.4.value").String(), "not okay") -} - -func (s *TeamsSuite) TestReviewReplies() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - reviewer := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]msapi.User{reviewer}) - s.checkPluginData(req.GetName(), func(data interface{}) bool { - return len(data.(PluginData).TeamsData) > 0 - }) - - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msg.RecipientID) - - 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) - - reply, err := s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, msg.RecipientID, reply.RecipientID) - require.Equal(t, msg.ID, reply.ID) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.0.value").String(), "βœ…") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.1.value").String(), s.userNames.reviewer1) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.3.value").String(), "okay") - - 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) - - reply, err = s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, msg.RecipientID, reply.RecipientID) - require.Equal(t, msg.ID, reply.ID) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.0.value").String(), "❌") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.1.value").String(), s.userNames.reviewer2) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.3.value").String(), "not okay") -} - -func (s *TeamsSuite) TestApprovalByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - reviewer := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]msapi.User{reviewer}) - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msg.RecipientID) - - 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) - - reply, err := s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, msg.RecipientID, reply.RecipientID) - require.Equal(t, msg.ID, reply.ID) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.0.value").String(), "βœ…") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.1.value").String(), s.userNames.reviewer1) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.3.value").String(), "okay") - - 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) - - reply, err = s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, msg.RecipientID, reply.RecipientID) - require.Equal(t, msg.ID, reply.ID) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.0.value").String(), "βœ…") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.1.value").String(), s.userNames.reviewer2) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.3.value").String(), "finally okay") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.1.columns.0.items.0.text").String(), "βœ…") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.1.columns.1.items.0.text").String(), "APPROVED") -} - -func (s *TeamsSuite) TestDenialByReview() { - t := s.T() - - if !s.teleportFeatures.AdvancedAccessWorkflows { - t.Skip("Doesn't work in OSS version") - } - - reviewer := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - - s.startApp() - - req := s.createAccessRequest([]msapi.User{reviewer}) - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msg.RecipientID) - - 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) - - reply, err := s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, msg.RecipientID, reply.RecipientID) - require.Equal(t, msg.ID, reply.ID) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.0.value").String(), "❌") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.1.value").String(), s.userNames.reviewer1) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.4.facts.3.value").String(), "not okay") - - 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) - - reply, err = s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - - require.Equal(t, msg.RecipientID, reply.RecipientID) - require.Equal(t, msg.ID, reply.ID) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.0.value").String(), "❌") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.1.value").String(), s.userNames.reviewer2) - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.5.facts.3.value").String(), "finally not okay") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.1.columns.0.items.0.text").String(), "❌") - require.Equal(t, gjson.Get(reply.Body, "attachments.0.content.body.1.columns.1.items.0.text").String(), "DENIED") -} - -func (s *TeamsSuite) TestExpiration() { - t := s.T() - - reviewer := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - - s.startApp() - - request := s.createAccessRequest([]msapi.User{reviewer}) - msg, err := s.mockAPI.CheckNewMessage(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msg.RecipientID) - - s.checkPluginData(request.GetName(), func(data interface{}) bool { - return len(data.(PluginData).TeamsData) > 0 - }) - - err = s.ruler().DeleteAccessRequest(s.Context(), request.GetName()) // simulate expiration - require.NoError(t, err) - - msgUpdate, err := s.mockAPI.CheckMessageUpdate(s.Context()) - require.NoError(t, err) - require.Equal(t, reviewer.ID, msgUpdate.RecipientID) - require.Equal(t, msg.ID, msgUpdate.ID) - - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.1.columns.0.items.0.text").String(), "βŒ›") - require.Equal(t, gjson.Get(msgUpdate.Body, "attachments.0.content.body.1.columns.1.items.0.text").String(), "EXPIRED") -} - -func (s *TeamsSuite) 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.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer1}) - reviewer2 := s.mockAPI.StoreUser(msapi.User{Mail: s.userNames.reviewer2}) - - s.SetContextTimeout(20 * time.Second) - s.startApp() - - var ( - raceErr error - raceErrOnce sync.Once - msgIDs sync.Map - msgsCount int32 - msgUpdateCounters 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.Mail, reviewer2.Mail}) - if _, err := s.requestor().CreateAccessRequestV2(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 < 2*s.raceNumber; i++ { - process.SpawnCritical(func(ctx context.Context) error { - msg, err := s.mockAPI.CheckNewMessage(ctx) - if err != nil { - return setRaceErr(trace.Wrap(err)) - } - - threadMsgKey := TeamsMessage{ID: msg.ID, RecipientID: msg.RecipientID} - if _, loaded := msgIDs.LoadOrStore(threadMsgKey, struct{}{}); loaded { - return setRaceErr(trace.Errorf("thread %v already stored", threadMsgKey)) - } - atomic.AddInt32(&msgsCount, 1) - - user, ok := s.mockAPI.GetUser(msg.RecipientID) - if !ok { - return setRaceErr(trace.Errorf("user %s is not found", msg.RecipientID)) - } - - title := gjson.Get(msg.Body, "attachments.0.content.body.0.text").String() - reqID := title[strings.LastIndex(title, " ")+1:] - - if err = s.clients[user.Mail].SubmitAccessRequestReview(ctx, reqID, types.AccessReview{ - Author: user.Mail, - ProposedState: types.RequestState_APPROVED, - Created: time.Now(), - Reason: "okay", - }); err != nil { - return setRaceErr(trace.Wrap(err)) - } - - return nil - }) - } - - // Multiplier TWO means that we handle updates for each of the two messages posted to reviewers. - for i := 0; i < 4*s.raceNumber; i++ { - process.SpawnCritical(func(ctx context.Context) error { - msg, err := s.mockAPI.CheckMessageUpdate(ctx) - if err != nil { - return setRaceErr(trace.Wrap(err)) - } - - threadMsgKey := TeamsMessage{ID: msg.ID, RecipientID: msg.RecipientID} - var newCounter int32 - val, _ := msgUpdateCounters.LoadOrStore(threadMsgKey, &newCounter) - counterPtr := val.(*int32) - atomic.AddInt32(counterPtr, 1) - - return nil - }) - } - - time.Sleep(1 * time.Second) - - process.Terminate() - <-process.Done() - require.NoError(t, raceErr) - - require.Equal(t, int32(2*s.raceNumber), msgsCount) - msgIDs.Range(func(key, value interface{}) bool { - next := true - - val, loaded := msgUpdateCounters.LoadAndDelete(key) - next = next && assert.True(t, loaded) - counterPtr := val.(*int32) - next = next && assert.Equal(t, int32(2), *counterPtr) - - return next - }) -} diff --git a/access/msteams/msapi/bot_framework_client.go b/access/msteams/msapi/bot_framework_client.go deleted file mode 100644 index 3692243b5..000000000 --- a/access/msteams/msapi/bot_framework_client.go +++ /dev/null @@ -1,141 +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 msapi - -import ( - "context" - "encoding/json" - "net/http" - - "github.com/gravitational/trace" -) - -const ( - botFrameworkAuthScope = "https://api.botframework.com/.default" - botFrameworkBaseURL = "https://smba.trafficmanager.net" - botFrameworkDefaultRegion = "emea" - botFrameworkVersion = "v3" - - // https://hackandchat.com/teams-proactive-messaging/ - botDesignator = "29:" -) - -// BotFrameworkClient represents client to MS Graph API -type BotFrameworkClient struct { - Client -} - -// PostActivityResponse represents json response with a single id field -type PostActivityResponse struct { - ID string `json:"id"` -} - -// botError represents MS Graph error -type botError struct { - E struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` -} - -// postMessagePayload represents utility struct for PostAdaptiveCard payload -type postMessagePayload struct { - Type string `json:"type"` - From postMessagePayloadFrom `json:"from"` - Attachments []postMessagePayloadAttachment `json:"attachments"` -} - -type postMessagePayloadFrom struct { - ID string `json:"id"` - Name string `json:"name"` -} - -type postMessagePayloadAttachment struct { - ContentType string `json:"contentType"` - Content json.RawMessage `json:"content"` -} - -// Error returns error string -func (e *botError) Error() string { - return e.E.Code + " " + e.E.Message -} - -// NewBotFrameworkClient creates MS Graph API client -func NewBotFrameworkClient(config Config) *BotFrameworkClient { - region := config.Region - if region == "" { - region = botFrameworkDefaultRegion - } - - baseURL := config.url.botFrameworkBaseURL - if baseURL == "" { - baseURL = botFrameworkBaseURL - } - - baseURL = baseURL + "/" + region + "/" + botFrameworkVersion - - return &BotFrameworkClient{ - Client: Client{ - token: tokenWithTTL{scope: botFrameworkAuthScope, baseURL: config.url.tokenBaseURL}, - baseURL: baseURL, - config: config, - }, - } -} - -// PostAdaptiveCardActivity sends an activity to the chat, content is AdaptiveCard -func (c *BotFrameworkClient) PostAdaptiveCardActivity(ctx context.Context, botID, chatID, card, updateID string) (string, error) { - m := postMessagePayload{ - Type: "message", - From: postMessagePayloadFrom{ - ID: botDesignator + botID, - Name: "TeleBot", - }, - Attachments: []postMessagePayloadAttachment{{ - ContentType: "application/vnd.microsoft.card.adaptive", - Content: []byte(card), - }}, - } - - body, err := json.MarshalIndent(&m, "", " ") - if err != nil { - return "", trace.Wrap(err) - } - - id := PostActivityResponse{} - - meth := http.MethodPost - status := http.StatusCreated - if updateID != "" { - meth = http.MethodPut - status = http.StatusOK - } - - request := request{ - Method: meth, - Path: "conversations/" + chatID + "/activities/" + updateID, - Body: string(body), - Response: &id, - SuccessCode: status, - Err: &botError{}, - } - - err = c.request(ctx, request) - if err != nil { - return "", trace.Wrap(err) - } - - return id.ID, nil -} diff --git a/access/msteams/msapi/client.go b/access/msteams/msapi/client.go deleted file mode 100644 index b0b9a1be5..000000000 --- a/access/msteams/msapi/client.go +++ /dev/null @@ -1,157 +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 msapi - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/url" - "strings" - - "github.com/gravitational/teleport/integrations/lib/backoff" - "github.com/gravitational/trace" - "github.com/jonboulle/clockwork" -) - -// Client represents generic MS API client -type Client struct { - token tokenWithTTL - baseURL string - config Config -} - -// request represents generic request structure -type request struct { - // Method HTTP method - Method string - // Path to a resource - Path string - // Expand $expand value - Expand []string - // Filter $filter value - Filter string - // Body request body - Body string - // Response represents template structure for a response - Response interface{} - // Err represents template structure for an error - Err error - // SuccessCode http code representing success - SuccessCode int -} - -// buildURL builds the request URL -func (c *Client) buildURL(request request) (string, error) { - u, err := url.Parse(c.baseURL) - if err != nil { - return "", trace.Wrap(err) - } - - data := url.Values{} - if len(request.Expand) > 0 { - data.Set("$expand", strings.Join(request.Expand, ",")) - } - if request.Filter != "" { - data.Set("$filter", request.Filter) - } - - u.Path = u.Path + "/" + request.Path - u.RawQuery = data.Encode() - - return u.String(), nil -} - -// request sends the request to the graph/bot service and returns response body as bytes slice -func (c *Client) request(ctx context.Context, request request) error { - client := http.Client{Timeout: httpTimeout} - - url, err := c.buildURL(request) - if err != nil { - return trace.Wrap(err) - } - - token, err := c.token.Bearer(ctx, c.config) - if err != nil { - return trace.Wrap(err) - } - - r, err := http.NewRequestWithContext(ctx, request.Method, url, strings.NewReader(request.Body)) - if err != nil { - return trace.Wrap(err) - } - - r.Header.Set("Authorization", token) - r.Header.Set("Content-Type", "application/json") - - backoff := backoff.NewDecorr(backoffBase, backoffMax, clockwork.NewRealClock()) - - for { - resp, err := client.Do(r) - if err != nil { - return trace.Wrap(err) - } - - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if err != nil { - return trace.Wrap(err) - } - - if resp.StatusCode > 499 || resp.StatusCode == http.StatusTooManyRequests { - err := backoff.Do(ctx) - if err != nil { - return trace.Wrap(err) - } - continue - } - - expectedCode := request.SuccessCode - if expectedCode == 0 { - expectedCode = http.StatusOK - } - - if expectedCode == resp.StatusCode { - if request.Response == nil { - return nil - } - - err := json.NewDecoder(bytes.NewReader(b)).Decode(request.Response) - if err != nil { - return trace.Wrap(err) - } - } else { - if request.Err == nil { - return trace.Errorf("Error requesting MS Graph API: %v", string(b)) - } - - err := json.NewDecoder(bytes.NewReader(b)).Decode(request.Err) - if err != nil { - return trace.Wrap(err) - } - - if request.Err.Error() == "" { - return trace.Errorf("Error requesting MS Graph API. Expected response code was %v, but is %v", expectedCode, resp.StatusCode) - } - - return request.Err - } - - return nil - } -} diff --git a/access/msteams/msapi/config.go b/access/msteams/msapi/config.go deleted file mode 100644 index 5672feaf3..000000000 --- a/access/msteams/msapi/config.go +++ /dev/null @@ -1,50 +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 msapi - -import "time" - -const ( - backoffBase = time.Millisecond - backoffMax = time.Second * 5 - httpTimeout = time.Second * 30 -) - -// Config represents MS Graph API and Bot API config -type Config struct { - // AppID application id (uuid, for bots must be underlying app id, not bot's id) - AppID string `toml:"app_id"` - // AppSecret application secret token - AppSecret string `toml:"app_secret"` - // TenantID ms tenant id - TenantID string `toml:"tenant_id"` - // Region bot framework api AP region - Region string `toml:"region"` - // TeamsAppID represents Teams App ID - TeamsAppID string `toml:"teams_app_id"` - // url represents url configuration for testing - url struct { - tokenBaseURL string - graphBaseURL string - botFrameworkBaseURL string - } `toml:"-"` -} - -// SetBaseURLs is used to point MS Graph API to test servers -func (c *Config) SetBaseURLs(token, graph, bot string) { - c.url.tokenBaseURL = token - c.url.graphBaseURL = graph - c.url.botFrameworkBaseURL = bot -} diff --git a/access/msteams/msapi/graph_client.go b/access/msteams/msapi/graph_client.go deleted file mode 100644 index b4226a419..000000000 --- a/access/msteams/msapi/graph_client.go +++ /dev/null @@ -1,340 +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 msapi - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - - "github.com/gravitational/trace" -) - -const ( - graphAuthScope = "https://graph.microsoft.com/.default" - graphBaseURL = "https://graph.microsoft.com/v1.0" -) - -// GraphClient represents client to MS Graph API -type GraphClient struct { - Client -} - -// graphError represents MS Graph error -type graphError struct { - E struct { - Code string `json:"code"` - Message string `json:"message"` - } `json:"error"` -} - -// genericGraphResponse represents the utility struct for parsing MS Graph API response -type genericGraphResponse struct { - Context string `json:"@odata.context"` - Count int `json:"@odata.count"` - Value json.RawMessage `json:"value"` -} - -// TeamsApp represents teamsApp resource -type TeamsApp struct { - ID string `json:"id"` - ExternalID string `json:"externalId"` - DisplayName string `json:"displayName"` - DistributionMethod string `json:"distributionMethod"` -} - -// InstalledApp represents teamsAppInstallation resource -type InstalledApp struct { - ID string `json:"id"` - TeamsApp TeamsApp `json:"teamsApp"` -} - -// Chat represents chat resource -type Chat struct { - ID string `json:"id"` - TenantID string `json:"tenantId"` - WebURL string `json:"webUrl"` -} - -// User represents user resource -type User struct { - ID string `json:"id"` - Name string `json:"displayName"` - Mail string `json:"mail"` - JobTitle string `json:"jobTitle"` -} - -// NewGraphClient creates MS Graph API client -func NewGraphClient(config Config) *GraphClient { - baseURL := config.url.graphBaseURL - if baseURL == "" { - baseURL = graphBaseURL - } - - return &GraphClient{ - Client: Client{ - token: tokenWithTTL{scope: graphAuthScope, baseURL: config.url.tokenBaseURL}, - baseURL: baseURL, - config: config, - }, - } -} - -// Error returns error string -func (e *graphError) Error() string { - return e.E.Code + " " + e.E.Message -} - -// GetErrorCode returns the -func GetErrorCode(err error) string { - graphErr, ok := trace.Unwrap(err).(*graphError) - if !ok { - return "" - } - return graphErr.E.Code -} - -// GetTeamsApp returns the list of installed team apps -func (c *GraphClient) GetTeamsApp(ctx context.Context, teamsAppID string) (*TeamsApp, error) { - g := &genericGraphResponse{} - - request := request{ - Method: http.MethodGet, - Path: "appCatalogs/teamsApps", - Filter: "externalId eq '" + teamsAppID + "'", - Response: g, - Err: &graphError{}, - } - - err := c.request(ctx, request) - if err != nil { - return nil, trace.Wrap(err) - } - - var apps []TeamsApp - err = json.NewDecoder(bytes.NewReader(g.Value)).Decode(&apps) - if err != nil { - return nil, trace.Wrap(err) - } - - if len(apps) == 0 { - return nil, trace.NotFound("App %v not found", teamsAppID) - } - - if len(apps) > 1 { - return nil, trace.Errorf("There is more than one app having externalID eq %v", teamsAppID) - } - - return &apps[0], nil -} - -// GetAppForUser returns installedApp for a given app and user -func (c *GraphClient) GetAppForUser(ctx context.Context, app *TeamsApp, userID string) (*InstalledApp, error) { - g := &genericGraphResponse{} - - request := request{ - Method: http.MethodGet, - Path: "users/" + userID + "/teamWork/installedApps", - Expand: []string{"teamsApp"}, - Filter: "teamsApp/id eq '" + app.ID + "'", - Response: g, - Err: &graphError{}, - } - - err := c.request(ctx, request) - if err != nil { - return nil, trace.Wrap(err) - } - - var apps []InstalledApp - err = json.NewDecoder(bytes.NewReader(g.Value)).Decode(&apps) - if err != nil { - return nil, trace.Wrap(err) - } - - if len(apps) == 0 { - return nil, trace.NotFound("App %v for user %v not found", app.ID, userID) - } - - if len(apps) > 1 { - return nil, trace.Errorf("There is more than one app having id eq %v", app.ID) - } - - return &apps[0], nil -} - -// GetAppForTeam returns installedApp for a given app and user -// This call requires the permission `TeamsAppInstallation.ReadWriteSelfForTeam.All`. This is overkill as we're only -// reading. Resource Specific Consent is not enabled by default (still in preview) and we cannot rely on it. -// https://docs.microsoft.com/en-us/microsoftteams/platform/graph-api/rsc/resource-specific-consent -func (c *GraphClient) GetAppForTeam(ctx context.Context, app *TeamsApp, teamID string) (*InstalledApp, error) { - g := &genericGraphResponse{} - - request := request{ - Method: http.MethodGet, - Path: "teams/" + teamID + "/installedApps", - Expand: []string{"teamsApp"}, - Filter: "teamsApp/id eq '" + app.ID + "'", - Response: g, - Err: &graphError{}, - } - - err := c.request(ctx, request) - if err != nil { - return nil, trace.Wrap(err) - } - - var apps []InstalledApp - err = json.NewDecoder(bytes.NewReader(g.Value)).Decode(&apps) - if err != nil { - return nil, trace.Wrap(err) - } - - if len(apps) == 0 { - return nil, trace.NotFound("App %v for team %v not found", app.ID, teamID) - } - - if len(apps) > 1 { - return nil, trace.Errorf("There is more than one app having id eq %v", app.ID) - } - - return &apps[0], nil -} - -// InstallAppForUser returns installed apps for user -func (c *GraphClient) InstallAppForUser(ctx context.Context, userID, teamAppID string) error { - body := ` - { - "teamsApp@odata.bind": "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/` + teamAppID + `" - } - ` - - request := request{ - Method: http.MethodPost, - Path: "users/" + userID + "/teamWork/installedApps", - Body: body, - Err: &graphError{}, - SuccessCode: http.StatusCreated, - } - - return trace.Wrap(c.request(ctx, request)) -} - -// UninstallAppForUser returns installed apps for user -func (c *GraphClient) UninstallAppForUser(ctx context.Context, userID, teamAppID string) error { - body := ` - { - "teamsApp@odata.bind": "https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/` + teamAppID + `" - } - ` - - request := request{ - Method: http.MethodDelete, - Path: "users/" + userID + "/teamWork/installedApps/" + teamAppID, - Body: body, - Err: &graphError{}, - SuccessCode: http.StatusNoContent, - } - - return trace.Wrap(c.request(ctx, request)) -} - -// GetChatForInstalledApp returns a chat between user and installed app -func (c *GraphClient) GetChatForInstalledApp(ctx context.Context, userID, installationID string) (Chat, error) { - var chat Chat - - request := request{ - Method: http.MethodGet, - Path: "users/" + userID + "/teamwork/installedApps/" + installationID + "/chat", - Response: &chat, - Err: &graphError{}, - } - - err := c.request(ctx, request) - if err != nil { - return chat, trace.Wrap(err) - } - - return chat, nil -} - -// GetUserByEmail searches a user by email -func (c *GraphClient) GetUserByEmail(ctx context.Context, email string) (*User, error) { - g := &genericGraphResponse{} - - request := request{ - Method: http.MethodGet, - Path: "users", - Filter: "mail eq '" + email + "'", - Response: &g, - Err: &graphError{}, - } - - err := c.request(ctx, request) - if err != nil { - return nil, trace.Wrap(err) - } - - var users []User - err = json.NewDecoder(bytes.NewReader(g.Value)).Decode(&users) - if err != nil { - return nil, trace.Wrap(err) - } - - if len(users) == 0 { - return nil, trace.NotFound("User by email %v not found", email) - } - - if len(users) > 1 { - return nil, trace.Errorf("There is more than one user with email eq %v", email) - } - - return &users[0], nil -} - -// GetUserByID returns a user by ID -func (c *GraphClient) GetUserByID(ctx context.Context, id string) (*User, error) { - g := &genericGraphResponse{} - - request := request{ - Method: http.MethodGet, - Path: "users", - Filter: "id eq '" + id + "'", - Response: &g, - Err: &graphError{}, - } - - err := c.request(ctx, request) - if err != nil { - return nil, trace.Wrap(err) - } - - var users []User - err = json.NewDecoder(bytes.NewReader(g.Value)).Decode(&users) - if err != nil { - return nil, trace.Wrap(err) - } - - if len(users) == 0 { - return nil, trace.NotFound("User %v not found", id) - } - - if len(users) > 1 { - return nil, trace.Errorf("There is more than one user with id %v", id) - } - - return &users[0], nil -} diff --git a/access/msteams/msapi/token.go b/access/msteams/msapi/token.go deleted file mode 100644 index d65b43693..000000000 --- a/access/msteams/msapi/token.go +++ /dev/null @@ -1,143 +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 msapi - -import ( - "bytes" - "context" - "encoding/json" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/gravitational/teleport/integrations/lib/backoff" - "github.com/gravitational/trace" - "github.com/jonboulle/clockwork" -) - -const ( - getTokenBaseURL = "https://login.microsoftonline.com" - getTokenContentType = "application/x-www-form-urlencoded" -) - -// Token represents utility struct used for parsing GetToken resposne -type Token struct { - AccessToken string `json:"access_token"` - ExpiresIn int64 `json:"expires_in"` -} - -// tokenWithTTL represents struct which handles token refresh on expiration -type tokenWithTTL struct { - mu sync.RWMutex - token Token - scope string - expiresAt int64 - baseURL string -} - -// Bearer returns current token value and refreshes it if token is expired. -// -// MS Graph API issues no refresh_token for client_credentials grant type. There also is no -// extended validity window for this grant type. -func (c *tokenWithTTL) Bearer(ctx context.Context, config Config) (string, error) { - c.mu.RLock() - expiresAt := c.expiresAt - c.mu.RUnlock() - - if expiresAt == 0 || expiresAt < time.Now().UnixNano() { - token, err := c.getToken(ctx, c.scope, config) - if err != nil { - return "", trace.Wrap(err) - } - - c.mu.Lock() - defer c.mu.Unlock() - - c.token = token - // We renew the token 1 minute before its expiration to deal with possible time skew - c.expiresAt = time.Now().UnixNano() + (token.ExpiresIn * int64(time.Second)) - int64(time.Minute) - } - - return "Bearer " + c.token.AccessToken, nil -} - -// getToken calls /token endpoint and returns Bearer string -func (c *tokenWithTTL) getToken(ctx context.Context, scope string, config Config) (Token, error) { - client := http.Client{Timeout: httpTimeout} - t := Token{} - - data := url.Values{} - data.Set("grant_type", "client_credentials") - data.Set("client_id", config.AppID) - data.Set("client_secret", config.AppSecret) - data.Set("scope", scope) - - baseURL := c.baseURL - if baseURL == "" { - baseURL = getTokenBaseURL - } - - getTokenURL := baseURL + "/" + config.TenantID + "/oauth2/v2.0/token" - - r, err := http.NewRequestWithContext( - ctx, - http.MethodPost, - getTokenURL, - strings.NewReader(data.Encode()), - ) - if err != nil { - return t, trace.Wrap(err) - } - - u, err := url.Parse(getTokenBaseURL) - if err != nil { - return t, trace.Wrap(err) - } - - r.Header.Add("Host", u.Host) - r.Header.Add("Content-Type", getTokenContentType) - - backoff := backoff.NewDecorr(backoffBase, backoffMax, clockwork.NewRealClock()) - for { - resp, err := client.Do(r) - if err != nil { - return t, trace.Wrap(err) - } - - defer resp.Body.Close() - b, err := io.ReadAll(resp.Body) - if err != nil { - return t, trace.Wrap(err) - } - - if resp.StatusCode != http.StatusOK { - err = backoff.Do(ctx) - if err != nil { - return t, trace.Errorf("Failed to get auth token %v %v %v", resp.StatusCode, scope, string(b)) - } - continue - } - - err = json.NewDecoder(bytes.NewReader(b)).Decode(&t) - if err != nil { - return t, trace.Wrap(err) - } - - return t, nil - } -} diff --git a/access/msteams/msg_slice_test.go b/access/msteams/msg_slice_test.go deleted file mode 100644 index a97dceb82..000000000 --- a/access/msteams/msg_slice_test.go +++ /dev/null @@ -1,39 +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 MsgSlice []Msg -type MsgSet map[Msg]struct{} - -func (slice MsgSlice) Len() int { - return len(slice) -} - -func (slice MsgSlice) Less(i, j int) bool { - return slice[i].RecipientID < slice[j].RecipientID -} - -func (slice MsgSlice) Swap(i, j int) { - slice[i], slice[j] = slice[j], slice[i] -} - -func (set MsgSet) Add(msg Msg) { - set[msg] = struct{}{} -} - -func (set MsgSet) Contains(msg Msg) bool { - _, ok := set[msg] - return ok -} diff --git a/access/msteams/plugindata.go b/access/msteams/plugindata.go deleted file mode 100644 index 7018cc4c9..000000000 --- a/access/msteams/plugindata.go +++ /dev/null @@ -1,97 +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/base64" - "encoding/json" - "strings" - - "github.com/gravitational/teleport/integrations/lib/plugindata" - "github.com/gravitational/trace" -) - -// PluginData is a data associated with access request that we store in Teleport using UpdatePluginData API. -type PluginData struct { - plugindata.AccessRequestData - TeamsData []TeamsMessage -} - -// TeamsMessage represents sent message information -type TeamsMessage struct { - ID string `json:"id"` - Timestamp string `json:"ts"` - RecipientID string `json:"rid"` -} - -// DecodePluginData deserializes a string map to PluginData struct. -func DecodePluginData(dataMap map[string]string) (PluginData, error) { - data := PluginData{} - var errors []error - - accessRequestData, err := plugindata.DecodeAccessRequestData(dataMap) - if err != nil { - return data, trace.Wrap(err, "failed to decode access request data") - } - data.AccessRequestData = accessRequestData - - if str := dataMap["messages"]; str != "" { - for _, encodedMsg := range strings.Split(str, ",") { - decodedMsg, err := base64.StdEncoding.DecodeString(encodedMsg) - if err != nil { - // Backward compatibility - // TODO(hugoShaka): remove in v12 - parts := strings.Split(encodedMsg, "/") - if len(parts) == 3 { - data.TeamsData = append(data.TeamsData, TeamsMessage{ID: parts[0], Timestamp: parts[1], RecipientID: parts[2]}) - } - continue - } - - msg := &TeamsMessage{} - err = json.Unmarshal(decodedMsg, msg) - if err != nil { - errors = append(errors, err) - } - data.TeamsData = append(data.TeamsData, *msg) - } - } - - return data, trace.NewAggregate(errors...) -} - -// EncodePluginData serializes plugin data to a string map -func EncodePluginData(data PluginData) (map[string]string, error) { - result, err := plugindata.EncodeAccessRequestData(data.AccessRequestData) - if err != nil { - return nil, trace.Wrap(err, "failed to encode access request data") - } - - var errors []error - - var encodedMessages []string - for _, msg := range data.TeamsData { - jsonMessage, err := json.Marshal(msg) - if err != nil { - errors = append(errors, err) - } - encodedMessage := base64.StdEncoding.EncodeToString(jsonMessage) - encodedMessages = append(encodedMessages, encodedMessage) - } - - result["messages"] = strings.Join(encodedMessages, ",") - - return result, trace.NewAggregate(errors...) -} diff --git a/access/msteams/plugindata_test.go b/access/msteams/plugindata_test.go deleted file mode 100644 index 3b5a02556..000000000 --- a/access/msteams/plugindata_test.go +++ /dev/null @@ -1,108 +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/gravitational/teleport/integrations/lib/plugindata" - "github.com/stretchr/testify/assert" -) - -var samplePluginData = PluginData{ - AccessRequestData: plugindata.AccessRequestData{ - User: "user-foo", - Roles: []string{"role-foo", "role-bar"}, - RequestReason: "foo reason", - ReviewsCount: 3, - ResolutionTag: plugindata.ResolvedApproved, - ResolutionReason: "foo ok", - }, - TeamsData: []TeamsMessage{ - {ID: "CHANNEL1", Timestamp: "0000001", RecipientID: "foo@example.com"}, - {ID: "CHANNEL2", Timestamp: "0000002", RecipientID: "2ca235ec-37d0-44b0-964d-ca359e770603"}, - {ID: "CHANNEL3", Timestamp: "0000003", RecipientID: "https://teams.microsoft.com/l/channel/19%3af09f38d6d1594065862b1ca4a417319e%40thread.tacv2/Approval%2520Channel%25203?groupId=f2b3c8ed-5502-4449-b76f-dc3acea81f1c&tenantId=ff882432-09b0-437b-bd22-ca13c0037ded"}, - }, -} - -const messageData = "eyJpZCI6IkNIQU5ORUwxIiwidHMiOiIwMDAwMDAxIiwicmlkIjoiZm9vQGV4YW1wbGUuY29tIn0=,eyJpZCI6IkNIQU5ORUwyIiwidHMiOiIwMDAwMDAyIiwicmlkIjoiMmNhMjM1ZWMtMzdkMC00NGIwLTk2NGQtY2EzNTllNzcwNjAzIn0=,eyJpZCI6IkNIQU5ORUwzIiwidHMiOiIwMDAwMDAzIiwicmlkIjoiaHR0cHM6Ly90ZWFtcy5taWNyb3NvZnQuY29tL2wvY2hhbm5lbC8xOSUzYWYwOWYzOGQ2ZDE1OTQwNjU4NjJiMWNhNGE0MTczMTllJTQwdGhyZWFkLnRhY3YyL0FwcHJvdmFsJTI1MjBDaGFubmVsJTI1MjAzP2dyb3VwSWQ9ZjJiM2M4ZWQtNTUwMi00NDQ5LWI3NmYtZGMzYWNlYTgxZjFjXHUwMDI2dGVuYW50SWQ9ZmY4ODI0MzItMDliMC00MzdiLWJkMjItY2ExM2MwMDM3ZGVkIn0=" - -func TestEncodePluginData(t *testing.T) { - dataMap, err := EncodePluginData(samplePluginData) - assert.NoError(t, err) - assert.GreaterOrEqual(t, len(dataMap), 8) - 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, "", dataMap["resources"]) - assert.Equal( - t, - messageData, - dataMap["messages"]) -} - -func TestDecodePluginDataCompatibility(t *testing.T) { - pluginData, err := 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/0000001/foo@example.com,CHANNEL2/0000002/2ca235ec-37d0-44b0-964d-ca359e770603", - }) - assert.NoError(t, err) - assert.Equal(t, samplePluginData.AccessRequestData, pluginData.AccessRequestData) - // Legacy way of encoding messages does not support recipients containing '/' or ',' - // Hence we don't test the CHANNEL3 - assert.Equal(t, samplePluginData.TeamsData[0], pluginData.TeamsData[0]) - assert.Equal(t, samplePluginData.TeamsData[1], pluginData.TeamsData[1]) -} - -func TestDecodePluginData(t *testing.T) { - pluginData, err := 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": messageData, - }) - assert.NoError(t, err) - assert.Equal(t, samplePluginData, pluginData) -} - -func TestEncodeEmptyPluginData(t *testing.T) { - dataMap, err := EncodePluginData(PluginData{}) - assert.NoError(t, err) - assert.GreaterOrEqual(t, len(dataMap), 8) - for key, value := range dataMap { - assert.Emptyf(t, value, "value at key %q must be empty", key) - } -} - -func TestDecodeEmptyPluginData(t *testing.T) { - result, err := DecodePluginData(nil) - assert.NoError(t, err) - assert.Empty(t, result) - - result, err = DecodePluginData(make(map[string]string)) - assert.NoError(t, err) - assert.Empty(t, result) -} diff --git a/access/msteams/uninstall.go b/access/msteams/uninstall.go deleted file mode 100644 index 4dbf53f81..000000000 --- a/access/msteams/uninstall.go +++ /dev/null @@ -1,48 +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" - - "github.com/gravitational/trace" - log "github.com/sirupsen/logrus" -) - -func uninstall(ctx context.Context, configPath string) error { - b, c, err := loadConfig(configPath) - if err != nil { - return trace.Wrap(err) - } - err = checkApp(ctx, b) - if err != nil { - return trace.Wrap(err) - } - - var errs []error - for _, recipient := range c.Recipients.GetAllRawRecipients() { - _, isChannel := checkChannelURL(recipient) - if !isChannel { - errs = append(errs, b.UninstallAppForUser(ctx, recipient)) - } - } - err = trace.NewAggregate(errs...) - if err != nil { - log.Errorln("The following error(s) happened when uninstalling the Teams App:") - return err - } - log.Info("Successfully uninstalled app for all recipients") - return nil -} diff --git a/access/msteams/validate.go b/access/msteams/validate.go deleted file mode 100644 index 0ae6a738b..000000000 --- a/access/msteams/validate.go +++ /dev/null @@ -1,165 +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" - "fmt" - "time" - - cards "github.com/DanielTitkov/go-adaptive-cards" - "github.com/google/uuid" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/integrations/lib" - "github.com/gravitational/teleport/integrations/lib/plugindata" - "github.com/gravitational/trace" -) - -// validate installs the application for a user if required and sends the Hello, world! message -func validate(configPath, recipient string) error { - - lib.PrintVersion(appName, Version, Gitref) - fmt.Println() - - ctx := context.Background() - b, _, err := loadConfig(configPath) - if err != nil { - return trace.Wrap(err) - } - err = checkApp(ctx, b) - if err != nil { - return trace.Wrap(err) - } - - if lib.IsEmail(recipient) { - userID, err := b.GetUserIDByEmail(context.Background(), recipient) - if trace.IsNotFound(err) { - fmt.Printf(" - User %v not found! Try to use user ID instead\n", recipient) - return nil - } - if err != nil { - return trace.Wrap(err) - } - - fmt.Printf(" - User %v found: %v\n", recipient, userID) - - recipient = userID - } - - recipientData, err := b.FetchRecipient(context.Background(), recipient) - if err != nil { - return trace.Wrap(err) - } - - fmt.Printf(" - Application installation ID for recipient: %v\n", recipientData.App.ID) - fmt.Printf(" - Chat ID for recipient: %v\n", recipientData.Chat.ID) - fmt.Printf(" - Chat web URL: %v\n", recipientData.Chat.WebURL) - - card := cards.New([]cards.Node{ - &cards.TextBlock{ - Text: "Hello, world!", - Size: "large", - }, - &cards.TextBlock{ - Text: "*Sincerely yours,*", - }, - &cards.TextBlock{ - Text: "Teleport Bot!", - }, - }, []cards.Node{}). - WithSchema(cards.DefaultSchema). - WithVersion(cards.Version12) - - body, err := card.StringIndent("", " ") - if err != nil { - return trace.Wrap(err) - } - - fmt.Println(" - Sending the message...") - - id, err := b.PostAdaptiveCardActivity(context.Background(), recipient, body, "") - if err != nil { - return trace.Wrap(err) - } - - fmt.Printf(" - Message sent, ID: %v\n", id) - - data := plugindata.AccessRequestData{ - User: "foo", - Roles: []string{"editor"}, - RequestReason: "Example request posted by 'validate' command.", - ReviewsCount: 1, - } - - reviews := []types.AccessReview{ - { - Author: "bar", - Roles: []string{"reviewer"}, - ProposedState: types.RequestState_APPROVED, - Reason: "Looks fine", - Created: time.Now(), - }, - { - Author: "baz", - Roles: []string{"reviewer"}, - ProposedState: types.RequestState_DENIED, - Reason: "Not good", - Created: time.Now(), - }, - } - - body, err = BuildCard(uuid.NewString(), nil, "local-cluster", data, reviews) - if err != nil { - return trace.Wrap(err) - } - - _, err = b.PostAdaptiveCardActivity(context.Background(), recipient, body, "") - if err != nil { - return trace.Wrap(err) - } - - fmt.Println() - fmt.Println("Check your MS Teams!") - - return nil -} - -func loadConfig(configPath string) (*Bot, *Config, error) { - c, err := LoadConfig(configPath) - if err != nil { - return nil, nil, trace.Wrap(err) - } - - fmt.Printf(" - Checking application %v status...\n", c.MSAPI.TeamsAppID) - - b, err := NewBot(c.MSAPI, "local", "") - if err != nil { - return nil, nil, trace.Wrap(err) - } - return b, c, nil -} - -func checkApp(ctx context.Context, b *Bot) error { - teamApp, err := b.GetTeamsApp(ctx) - if trace.IsNotFound(err) { - fmt.Printf("Application %v not found in the org app store. Please, ensure that you have the application uploaded and installed for your team.", b.Config.TeamsAppID) - return trace.Wrap(err) - } else if err != nil { - return trace.Wrap(err) - } - - fmt.Printf(" - Application found in the team app store (internal ID: %v)\n", teamApp.ID) - return nil -}