diff --git a/assets/msteams_icon.svg b/assets/msteams_icon.svg new file mode 100644 index 0000000000..d3068ffb2a --- /dev/null +++ b/assets/msteams_icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/actions.go b/client/actions.go index 2c4219eb4c..108ddf77e4 100644 --- a/client/actions.go +++ b/client/actions.go @@ -18,7 +18,7 @@ type ActionsService struct { // Create an action. Returns the id of the newly created action. func (s *ActionsService) Create(ctx context.Context, channelID string, opts ChannelActionCreateOptions) (string, error) { actionURL := fmt.Sprintf("actions/channels/%s", channelID) - req, err := s.client.newRequest(http.MethodPost, actionURL, opts) + req, err := s.client.newAPIRequest(http.MethodPost, actionURL, opts) if err != nil { return "", err } @@ -46,7 +46,7 @@ func (s *ActionsService) List(ctx context.Context, channelID string, opts Channe return nil, fmt.Errorf("failed to build options: %w", err) } - req, err := s.client.newRequest(http.MethodGet, actionURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, actionURL, nil) if err != nil { return nil, fmt.Errorf("failed to build request: %w", err) } @@ -64,7 +64,7 @@ func (s *ActionsService) List(ctx context.Context, channelID string, opts Channe // Update an existing action. func (s *ActionsService) Update(ctx context.Context, action GenericChannelAction) error { updateURL := fmt.Sprintf("actions/channels/%s/%s", action.ChannelID, action.ID) - req, err := s.client.newRequest(http.MethodPut, updateURL, action) + req, err := s.client.newAPIRequest(http.MethodPut, updateURL, action) if err != nil { return err } diff --git a/client/client.go b/client/client.go index b9c0ed970d..320833be20 100644 --- a/client/client.go +++ b/client/client.go @@ -50,6 +50,8 @@ type Client struct { Reminders *RemindersService // Telemetry is a collection of methods used to interact with telemetry. Telemetry *TelemetryService + // TabApp is a collection of methods used to interact with playbooks from the tabapp. + TabApp *TabAppService } // New creates a new instance of Client using the configuration from the given Mattermost Client. @@ -77,12 +79,13 @@ func newClient(mattermostSiteURL string, httpClient *http.Client) (*Client, erro c.Stats = &StatsService{c} c.Reminders = &RemindersService{c} c.Telemetry = &TelemetryService{c} + c.TabApp = &TabAppService{c} return c, nil } // newRequest creates an API request, JSON-encoding any given body parameter. func (c *Client) newRequest(method, endpoint string, body interface{}) (*http.Request, error) { - u, err := c.BaseURL.Parse(buildAPIURL(endpoint)) + u, err := c.BaseURL.Parse(endpoint) if err != nil { return nil, errors.Wrapf(err, "invalid endpoint %s", endpoint) } @@ -112,6 +115,11 @@ func (c *Client) newRequest(method, endpoint string, body interface{}) (*http.Re return req, nil } +// newAPIRequest creates an API request, JSON-encoding any given body parameter. +func (c *Client) newAPIRequest(method, endpoint string, body interface{}) (*http.Request, error) { + return c.newRequest(method, buildAPIURL(endpoint), body) +} + // buildAPIURL constructs the path to the given endpoint. func buildAPIURL(endpoint string) string { return fmt.Sprintf("plugins/%s/api/%s/%s", manifestID, apiVersion, endpoint) @@ -176,7 +184,7 @@ type GraphQLInput struct { func (c *Client) DoGraphql(ctx context.Context, input *GraphQLInput, v interface{}) error { url := "query" - req, err := c.newRequest(http.MethodPost, url, input) + req, err := c.newAPIRequest(http.MethodPost, url, input) if err != nil { return err } diff --git a/client/playbook_runs.go b/client/playbook_runs.go index 949a4006f3..ffb06bc4d7 100644 --- a/client/playbook_runs.go +++ b/client/playbook_runs.go @@ -19,7 +19,7 @@ type PlaybookRunService struct { // Get a playbook run. func (s *PlaybookRunService) Get(ctx context.Context, playbookRunID string) (*PlaybookRun, error) { playbookRunURL := fmt.Sprintf("runs/%s", playbookRunID) - req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookRunURL, nil) if err != nil { return nil, err } @@ -37,7 +37,7 @@ func (s *PlaybookRunService) Get(ctx context.Context, playbookRunID string) (*Pl // GetByChannelID gets a playbook run by ChannelID. func (s *PlaybookRunService) GetByChannelID(ctx context.Context, channelID string) (*PlaybookRun, error) { channelURL := fmt.Sprintf("runs/channel/%s", channelID) - req, err := s.client.newRequest(http.MethodGet, channelURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, channelURL, nil) if err != nil { return nil, err } @@ -55,7 +55,7 @@ func (s *PlaybookRunService) GetByChannelID(ctx context.Context, channelID strin // Get a playbook run's metadata. func (s *PlaybookRunService) GetMetadata(ctx context.Context, playbookRunID string) (*Metadata, error) { playbookRunURL := fmt.Sprintf("runs/%s/metadata", playbookRunID) - req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookRunURL, nil) if err != nil { return nil, err } @@ -73,7 +73,7 @@ func (s *PlaybookRunService) GetMetadata(ctx context.Context, playbookRunID stri // Get all playbook status updates. func (s *PlaybookRunService) GetStatusUpdates(ctx context.Context, playbookRunID string) ([]StatusPostComplete, error) { playbookRunURL := fmt.Sprintf("runs/%s/status-updates", playbookRunID) - req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookRunURL, nil) if err != nil { return nil, err } @@ -100,7 +100,7 @@ func (s *PlaybookRunService) List(ctx context.Context, page, perPage int, opts P return nil, fmt.Errorf("failed to build pagination options: %w", err) } - req, err := s.client.newRequest(http.MethodGet, playbookRunURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookRunURL, nil) if err != nil { return nil, fmt.Errorf("failed to build request: %w", err) } @@ -118,7 +118,7 @@ func (s *PlaybookRunService) List(ctx context.Context, page, perPage int, opts P // Create a playbook run. func (s *PlaybookRunService) Create(ctx context.Context, opts PlaybookRunCreateOptions) (*PlaybookRun, error) { playbookRunURL := "runs" - req, err := s.client.newRequest(http.MethodPost, playbookRunURL, opts) + req, err := s.client.newAPIRequest(http.MethodPost, playbookRunURL, opts) if err != nil { return nil, err } @@ -143,7 +143,7 @@ func (s *PlaybookRunService) UpdateStatus(ctx context.Context, playbookRunID str Message: message, Reminder: time.Duration(reminderInSeconds), } - req, err := s.client.newRequest(http.MethodPost, updateURL, opts) + req, err := s.client.newAPIRequest(http.MethodPost, updateURL, opts) if err != nil { return err } @@ -162,7 +162,7 @@ func (s *PlaybookRunService) UpdateStatus(ctx context.Context, playbookRunID str func (s *PlaybookRunService) RequestUpdate(ctx context.Context, playbookRunID, userID string) error { requestURL := fmt.Sprintf("runs/%s/request-update", playbookRunID) - req, err := s.client.newRequest(http.MethodPost, requestURL, nil) + req, err := s.client.newAPIRequest(http.MethodPost, requestURL, nil) if err != nil { return err } @@ -177,7 +177,7 @@ func (s *PlaybookRunService) RequestUpdate(ctx context.Context, playbookRunID, u func (s *PlaybookRunService) Finish(ctx context.Context, playbookRunID string) error { finishURL := fmt.Sprintf("runs/%s/finish", playbookRunID) - req, err := s.client.newRequest(http.MethodPut, finishURL, nil) + req, err := s.client.newAPIRequest(http.MethodPut, finishURL, nil) if err != nil { return err } @@ -192,7 +192,7 @@ func (s *PlaybookRunService) Finish(ctx context.Context, playbookRunID string) e func (s *PlaybookRunService) CreateChecklist(ctx context.Context, playbookRunID string, checklist Checklist) error { createURL := fmt.Sprintf("runs/%s/checklists", playbookRunID) - req, err := s.client.newRequest(http.MethodPost, createURL, checklist) + req, err := s.client.newAPIRequest(http.MethodPost, createURL, checklist) if err != nil { return err } @@ -203,7 +203,7 @@ func (s *PlaybookRunService) CreateChecklist(ctx context.Context, playbookRunID func (s *PlaybookRunService) RemoveChecklist(ctx context.Context, playbookRunID string, checklistNumber int) error { createURL := fmt.Sprintf("runs/%s/checklists/%d", playbookRunID, checklistNumber) - req, err := s.client.newRequest(http.MethodDelete, createURL, nil) + req, err := s.client.newAPIRequest(http.MethodDelete, createURL, nil) if err != nil { return err } @@ -214,7 +214,7 @@ func (s *PlaybookRunService) RemoveChecklist(ctx context.Context, playbookRunID func (s *PlaybookRunService) RenameChecklist(ctx context.Context, playbookRunID string, checklistNumber int, newTitle string) error { createURL := fmt.Sprintf("runs/%s/checklists/%d/rename", playbookRunID, checklistNumber) - req, err := s.client.newRequest(http.MethodPut, createURL, struct{ Title string }{newTitle}) + req, err := s.client.newAPIRequest(http.MethodPut, createURL, struct{ Title string }{newTitle}) if err != nil { return err } @@ -225,7 +225,7 @@ func (s *PlaybookRunService) RenameChecklist(ctx context.Context, playbookRunID func (s *PlaybookRunService) AddChecklistItem(ctx context.Context, playbookRunID string, checklistNumber int, checklistItem ChecklistItem) error { addURL := fmt.Sprintf("runs/%s/checklists/%d/add", playbookRunID, checklistNumber) - req, err := s.client.newRequest(http.MethodPost, addURL, checklistItem) + req, err := s.client.newAPIRequest(http.MethodPost, addURL, checklistItem) if err != nil { return err } @@ -241,7 +241,7 @@ func (s *PlaybookRunService) MoveChecklist(ctx context.Context, playbookRunID st DestChecklistIdx int `json:"dest_checklist_idx"` }{sourceChecklistIdx, destChecklistIdx} - req, err := s.client.newRequest(http.MethodPost, createURL, body) + req, err := s.client.newAPIRequest(http.MethodPost, createURL, body) if err != nil { return err } @@ -259,7 +259,7 @@ func (s *PlaybookRunService) MoveChecklistItem(ctx context.Context, playbookRunI DestItemIdx int `json:"dest_item_idx"` }{sourceChecklistIdx, sourceItemIdx, destChecklistIdx, destItemIdx} - req, err := s.client.newRequest(http.MethodPost, createURL, body) + req, err := s.client.newAPIRequest(http.MethodPost, createURL, body) if err != nil { return err } @@ -271,7 +271,7 @@ func (s *PlaybookRunService) MoveChecklistItem(ctx context.Context, playbookRunI // UpdateRetrospective updates the run's retrospective info func (s *PlaybookRunService) UpdateRetrospective(ctx context.Context, playbookRunID, userID string, retroUpdate RetrospectiveUpdate) error { createURL := fmt.Sprintf("runs/%s/retrospective", playbookRunID) - req, err := s.client.newRequest(http.MethodPost, createURL, retroUpdate) + req, err := s.client.newAPIRequest(http.MethodPost, createURL, retroUpdate) if err != nil { return err } @@ -287,7 +287,7 @@ func (s *PlaybookRunService) UpdateRetrospective(ctx context.Context, playbookRu // PublishRetrospective publishes the run's retrospective func (s *PlaybookRunService) PublishRetrospective(ctx context.Context, playbookRunID, userID string, retroUpdate RetrospectiveUpdate) error { createURL := fmt.Sprintf("runs/%s/retrospective/publish", playbookRunID) - req, err := s.client.newRequest(http.MethodPost, createURL, retroUpdate) + req, err := s.client.newAPIRequest(http.MethodPost, createURL, retroUpdate) if err != nil { return err } @@ -306,7 +306,7 @@ func (s *PlaybookRunService) SetItemAssignee(ctx context.Context, playbookRunID AssigneeID string `json:"assignee_id"` }{assigneeID} - req, err := s.client.newRequest(http.MethodPut, createURL, body) + req, err := s.client.newAPIRequest(http.MethodPut, createURL, body) if err != nil { return err } @@ -321,7 +321,7 @@ func (s *PlaybookRunService) SetItemCommand(ctx context.Context, playbookRunID s Command string `json:"command"` }{newCommand} - req, err := s.client.newRequest(http.MethodPut, createURL, body) + req, err := s.client.newAPIRequest(http.MethodPut, createURL, body) if err != nil { return err } @@ -333,7 +333,7 @@ func (s *PlaybookRunService) SetItemCommand(ctx context.Context, playbookRunID s func (s *PlaybookRunService) RunItemCommand(ctx context.Context, playbookRunID string, checklistIdx int, itemIdx int) error { createURL := fmt.Sprintf("runs/%s/checklists/%d/item/%d/run", playbookRunID, checklistIdx, itemIdx) - req, err := s.client.newRequest(http.MethodPost, createURL, nil) + req, err := s.client.newAPIRequest(http.MethodPost, createURL, nil) if err != nil { return err } @@ -348,7 +348,7 @@ func (s *PlaybookRunService) SetItemDueDate(ctx context.Context, playbookRunID s DueDate int64 `json:"due_date"` }{duedate} - req, err := s.client.newRequest(http.MethodPut, createURL, body) + req, err := s.client.newAPIRequest(http.MethodPut, createURL, body) if err != nil { return err } @@ -359,7 +359,7 @@ func (s *PlaybookRunService) SetItemDueDate(ctx context.Context, playbookRunID s // Get a playbook run. func (s *PlaybookRunService) GetOwners(ctx context.Context) ([]OwnerInfo, error) { - req, err := s.client.newRequest(http.MethodGet, "runs/owners", nil) + req, err := s.client.newAPIRequest(http.MethodGet, "runs/owners", nil) if err != nil { return nil, err } diff --git a/client/playbooks.go b/client/playbooks.go index ffbf52683c..6a96dd9058 100644 --- a/client/playbooks.go +++ b/client/playbooks.go @@ -22,7 +22,7 @@ type PlaybooksService struct { // Get a playbook. func (s *PlaybooksService) Get(ctx context.Context, playbookID string) (*Playbook, error) { playbookURL := fmt.Sprintf("playbooks/%s", playbookID) - req, err := s.client.newRequest(http.MethodGet, playbookURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookURL, nil) if err != nil { return nil, err } @@ -55,7 +55,7 @@ func (s *PlaybooksService) List(ctx context.Context, teamId string, page, perPag return nil, fmt.Errorf("failed to build options: %w", err) } - req, err := s.client.newRequest(http.MethodGet, playbookURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookURL, nil) if err != nil { return nil, fmt.Errorf("failed to build request: %w", err) } @@ -78,7 +78,7 @@ func (s *PlaybooksService) Create(ctx context.Context, opts PlaybookCreateOption } playbookURL := "playbooks" - req, err := s.client.newRequest(http.MethodPost, playbookURL, opts) + req, err := s.client.newAPIRequest(http.MethodPost, playbookURL, opts) if err != nil { return "", err } @@ -101,7 +101,7 @@ func (s *PlaybooksService) Create(ctx context.Context, opts PlaybookCreateOption func (s *PlaybooksService) Update(ctx context.Context, playbook Playbook) error { updateURL := fmt.Sprintf("playbooks/%s", playbook.ID) - req, err := s.client.newRequest(http.MethodPut, updateURL, playbook) + req, err := s.client.newAPIRequest(http.MethodPut, updateURL, playbook) if err != nil { return err } @@ -116,7 +116,7 @@ func (s *PlaybooksService) Update(ctx context.Context, playbook Playbook) error func (s *PlaybooksService) Archive(ctx context.Context, playbookID string) error { updateURL := fmt.Sprintf("playbooks/%s", playbookID) - req, err := s.client.newRequest(http.MethodDelete, updateURL, nil) + req, err := s.client.newAPIRequest(http.MethodDelete, updateURL, nil) if err != nil { return err } @@ -131,7 +131,7 @@ func (s *PlaybooksService) Archive(ctx context.Context, playbookID string) error func (s *PlaybooksService) Export(ctx context.Context, playbookID string) ([]byte, error) { url := fmt.Sprintf("playbooks/%s/export", playbookID) - req, err := s.client.newRequest(http.MethodGet, url, nil) + req, err := s.client.newAPIRequest(http.MethodGet, url, nil) if err != nil { return nil, err } @@ -156,7 +156,7 @@ func (s *PlaybooksService) Export(ctx context.Context, playbookID string) ([]byt // Duplicate a playbook. Returns the id of the newly created playbook func (s *PlaybooksService) Duplicate(ctx context.Context, playbookID string) (string, error) { url := fmt.Sprintf("playbooks/%s/duplicate", playbookID) - req, err := s.client.newRequest(http.MethodPost, url, nil) + req, err := s.client.newAPIRequest(http.MethodPost, url, nil) if err != nil { return "", err } @@ -208,7 +208,7 @@ func (s *PlaybooksService) Import(ctx context.Context, toImport []byte, team str func (s *PlaybooksService) Stats(ctx context.Context, playbookID string) (*PlaybookStats, error) { playbookStatsURL := fmt.Sprintf("stats/playbook?playbook_id=%s", playbookID) - req, err := s.client.newRequest(http.MethodGet, playbookStatsURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, playbookStatsURL, nil) if err != nil { return nil, err } @@ -225,7 +225,7 @@ func (s *PlaybooksService) Stats(ctx context.Context, playbookID string) (*Playb func (s *PlaybooksService) AutoFollow(ctx context.Context, playbookID string, userID string) error { followsURL := fmt.Sprintf("playbooks/%s/autofollows/%s", playbookID, userID) - req, err := s.client.newRequest(http.MethodPut, followsURL, nil) + req, err := s.client.newAPIRequest(http.MethodPut, followsURL, nil) if err != nil { return err } @@ -239,7 +239,7 @@ func (s *PlaybooksService) AutoFollow(ctx context.Context, playbookID string, us func (s *PlaybooksService) AutoUnfollow(ctx context.Context, playbookID string, userID string) error { followsURL := fmt.Sprintf("playbooks/%s/autofollows/%s", playbookID, userID) - req, err := s.client.newRequest(http.MethodDelete, followsURL, nil) + req, err := s.client.newAPIRequest(http.MethodDelete, followsURL, nil) if err != nil { return err } @@ -253,7 +253,7 @@ func (s *PlaybooksService) AutoUnfollow(ctx context.Context, playbookID string, func (s *PlaybooksService) GetAutoFollows(ctx context.Context, playbookID string) ([]string, error) { autofollowsURL := fmt.Sprintf("playbooks/%s/autofollows", playbookID) - req, err := s.client.newRequest(http.MethodGet, autofollowsURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, autofollowsURL, nil) if err != nil { return nil, err } diff --git a/client/reminders.go b/client/reminders.go index ec4b4a00eb..b83547f70d 100644 --- a/client/reminders.go +++ b/client/reminders.go @@ -14,7 +14,7 @@ type RemindersService struct { func (s *RemindersService) Reset(ctx context.Context, playbookRunID string, payload ReminderResetPayload) error { resetURL := fmt.Sprintf("runs/%s/reminder", playbookRunID) - req, err := s.client.newRequest(http.MethodPost, resetURL, payload) + req, err := s.client.newAPIRequest(http.MethodPost, resetURL, payload) if err != nil { return err } diff --git a/client/settings.go b/client/settings.go index f0e0bdbbed..5cf8147fa3 100644 --- a/client/settings.go +++ b/client/settings.go @@ -19,7 +19,7 @@ type SettingsService struct { // Get the configured settings. func (s *SettingsService) Get(ctx context.Context) (*GlobalSettings, error) { settingsURL := "settings" - req, err := s.client.newRequest(http.MethodGet, settingsURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, settingsURL, nil) if err != nil { return nil, err } @@ -37,7 +37,7 @@ func (s *SettingsService) Get(ctx context.Context) (*GlobalSettings, error) { // Update the configured settings. func (s *SettingsService) Update(ctx context.Context, settings GlobalSettings) error { settingsURL := "settings" - req, err := s.client.newRequest(http.MethodPut, settingsURL, settings) + req, err := s.client.newAPIRequest(http.MethodPut, settingsURL, settings) if err != nil { return err } diff --git a/client/stats.go b/client/stats.go index f0cdf889c4..15cd6452ca 100644 --- a/client/stats.go +++ b/client/stats.go @@ -19,7 +19,7 @@ type PlaybookSiteStats struct { // Get the stats that should be displayed in system console. func (s *StatsService) GetSiteStats(ctx context.Context) (*PlaybookSiteStats, error) { statsURL := "stats/site" - req, err := s.client.newRequest(http.MethodGet, statsURL, nil) + req, err := s.client.newAPIRequest(http.MethodGet, statsURL, nil) if err != nil { return nil, err } diff --git a/client/tabapp.go b/client/tabapp.go new file mode 100644 index 0000000000..8e44ef8e8c --- /dev/null +++ b/client/tabapp.go @@ -0,0 +1,65 @@ +package client + +import ( + "context" + "fmt" + "net/http" +) + +// LimitedUser returns the minimum amount of user data needed for the app. +type LimitedUser struct { + UserID string `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +// LimitedUser returns the minimum amount of post data needed for the app. +type LimitedPost struct { + Message string `json:"message"` + CreateAt int64 `json:"create_at"` + UserID string `json:"user_id"` +} + +type TabAppResults struct { + TotalCount int `json:"total_count"` + PageCount int `json:"page_count"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + Items []PlaybookRun `json:"items"` + Users map[string]LimitedUser `json:"users"` + Posts map[string]LimitedPost `json:"posts"` +} + +type TabAppService struct { + client *Client +} + +type TabAppGetRunsOptions struct { + Page int `json:"page"` + PerPage int `json:"per_page"` +} + +func (s *TabAppService) GetRuns(ctx context.Context, token string, options TabAppGetRunsOptions) (*TabAppResults, error) { + url := fmt.Sprintf("plugins/%s/tabapp/runs", manifestID) + + url, err := addOptions(url, options) + if err != nil { + return nil, err + } + + req, err := s.client.newRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", token) + + tabAppResults := new(TabAppResults) + resp, err := s.client.do(ctx, req, tabAppResults) + if err != nil { + return nil, err + } + resp.Body.Close() + + return tabAppResults, nil +} diff --git a/client/telemetry.go b/client/telemetry.go index e9f4e4b77a..5caef1ce34 100644 --- a/client/telemetry.go +++ b/client/telemetry.go @@ -23,7 +23,7 @@ func (s *TelemetryService) CreateEvent(ctx context.Context, name string, eventTy Properties: properties, } - req, err := s.client.newRequest(http.MethodPost, "telemetry", payload) + req, err := s.client.newAPIRequest(http.MethodPost, "telemetry", payload) if err != nil { return err } diff --git a/go.mod b/go.mod index 87953daaa1..1e82b97702 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,13 @@ replace github.com/golang/mock => github.com/golang/mock v1.4.4 require ( github.com/Masterminds/squirrel v1.5.2 + github.com/MicahParks/jwkset v0.5.18 + github.com/MicahParks/keyfunc/v3 v3.3.3 github.com/blang/semver v3.5.1+incompatible github.com/go-sql-driver/mysql v1.6.0 + github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang/mock v1.6.0 + github.com/google/uuid v1.3.0 github.com/gorilla/mux v1.8.0 github.com/graph-gophers/dataloader/v7 v7.1.0 github.com/graph-gophers/graphql-go v1.4.0 @@ -92,7 +96,6 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/go-querystring v1.1.0 // indirect - github.com/google/uuid v1.3.0 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/gorilla/handlers v1.5.1 // indirect @@ -187,6 +190,7 @@ require ( golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect + golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.3.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/genproto v0.0.0-20221114212237-e4508ebdbee1 // indirect diff --git a/go.sum b/go.sum index 4c462c9696..9a7f6ca070 100644 --- a/go.sum +++ b/go.sum @@ -101,6 +101,10 @@ github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030I github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= github.com/Masterminds/squirrel v1.5.2 h1:UiOEi2ZX4RCSkpiNDQN5kro/XIBpSRk9iTqdIRPzUXE= github.com/Masterminds/squirrel v1.5.2/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= +github.com/MicahParks/jwkset v0.5.18 h1:WLdyMngF7rCrnstQxA7mpRoxeaWqGzPM/0z40PJUK4w= +github.com/MicahParks/jwkset v0.5.18/go.mod h1:q8ptTGn/Z9c4MwbcfeCDssADeVQb3Pk7PnVxrvi+2QY= +github.com/MicahParks/keyfunc/v3 v3.3.3 h1:c6j9oSu1YUo0k//KwF1miIQlEMtqNlj7XBFLB8jtEmY= +github.com/MicahParks/keyfunc/v3 v3.3.3/go.mod h1:f/UMyXdKfkZzmBeBFUeYk+zu066J1Fcl48f7Wnl5Z48= github.com/Microsoft/go-winio v0.4.11/go.mod h1:VhR8bwka0BXejwEJY73c50VrPtXAaKcyvVC4A4RozmA= github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= github.com/Microsoft/go-winio v0.4.15-0.20190919025122-fc70bd9a86b5/go.mod h1:tTuCMEN+UleMWgg9dVx4Hu52b1bJo+59jBh3ajtinzw= @@ -690,6 +694,8 @@ github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.1.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-migrate/migrate/v4 v4.15.2 h1:vU+M05vs6jWHKDdmE1Ecwj0BznygFc4QsdRe2E/L7kc= github.com/golang-migrate/migrate/v4 v4.15.2/go.mod h1:f2toGLkYqD3JH+Todi4aZ2ZdbeUNx4sIwiOK96rE9Lw= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -2103,6 +2109,8 @@ golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxb golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/plugin.json b/plugin.json index cfcc513164..f10bde76bd 100644 --- a/plugin.json +++ b/plugin.json @@ -1,33 +1,47 @@ { - "id": "playbooks", - "name": "Playbooks", - "description": "Mattermost Playbooks enable reliable and repeatable processes for your teams using checklists, automation, and retrospectives.", - "homepage_url": "https://github.com/mattermost/mattermost-plugin-playbooks/", - "support_url": "https://github.com/mattermost/mattermost-plugin-playbooks/issues", - "icon_path": "assets/plugin_icon.svg", - "min_server_version": "7.6.0", - "server": { - "executables": { - "linux-amd64": "server/dist/plugin-linux-amd64", - "linux-arm64": "server/dist/plugin-linux-arm64", - "darwin-amd64": "server/dist/plugin-darwin-amd64", - "darwin-arm64": "server/dist/plugin-darwin-arm64", - "windows-amd64": "server/dist/plugin-windows-amd64.exe" - } - }, - "webapp": { - "bundle_path": "webapp/dist/main.js" - }, - "settings_schema": { - "header": "", - "footer": "", - "settings": [ - { - "key": "EnableExperimentalFeatures", - "type": "bool", - "display_name": "Enable Experimental Features:", - "help_text": "Enable experimental features that come with in-progress UI, bugs, and cool stuff." - } - ] + "id": "playbooks", + "name": "Playbooks", + "description": "Mattermost Playbooks enable reliable and repeatable processes for your teams using checklists, automation, and retrospectives.", + "homepage_url": "https://github.com/mattermost/mattermost-plugin-playbooks/", + "support_url": "https://github.com/mattermost/mattermost-plugin-playbooks/issues", + "icon_path": "assets/plugin_icon.svg", + "min_server_version": "7.6.0", + "server": { + "executables": { + "linux-amd64": "server/dist/plugin-linux-amd64", + "linux-arm64": "server/dist/plugin-linux-arm64", + "darwin-amd64": "server/dist/plugin-darwin-amd64", + "darwin-arm64": "server/dist/plugin-darwin-arm64", + "windows-amd64": "server/dist/plugin-windows-amd64.exe" } + }, + "webapp": { + "bundle_path": "webapp/dist/main.js" + }, + "settings_schema": { + "header": "", + "footer": "", + "settings": [ + { + "key": "enableTeamsTabApp", + "display_name": "Enable Teams Tab App", + "type": "bool", + "help_text": "When true, enable a Microsoft Teams Tab app to expose Mattermost Playbook runs.", + "default": false + }, + { + "key": "teamsTabAppTenantIDs", + "display_name": "Authorized Tenant IDs for Teams Tab App", + "type": "text", + "help_text": "A comma separated list of Microsoft Tenant IDs allowed to access Playbook runs.", + "default": "" + }, + { + "key": "EnableExperimentalFeatures", + "type": "bool", + "display_name": "Enable Experimental Features:", + "help_text": "Enable experimental features that come with in-progress UI, bugs, and cool stuff." + } + ] + } } diff --git a/server/api/api.go b/server/api/api.go index 6744005c72..e0d5d41e1d 100644 --- a/server/api/api.go +++ b/server/api/api.go @@ -41,8 +41,8 @@ func NewHandler(pluginAPI *pluginapi.Client, config config.Service) *Handler { } root := mux.NewRouter() + root.Use(LogRequest) api := root.PathPrefix("/api/v0").Subrouter() - api.Use(LogRequest) api.Use(MattermostAuthorizationRequired) api.Handle("{anything:.*}", http.NotFoundHandler()) diff --git a/server/api/permutation_test.go b/server/api/permutation_test.go new file mode 100644 index 0000000000..bf8ed46c1c --- /dev/null +++ b/server/api/permutation_test.go @@ -0,0 +1,91 @@ +package api + +import ( + "fmt" + "reflect" + "testing" +) + +// runPermutations generates permutations from the given params struct and runs the callback as a +// subtest for each permutation. +// +// For now, the given struct must only contain boolean fields. +func runPermutations[T any](t *testing.T, params T, f func(t *testing.T, params T)) { + t.Helper() + + paramsV := reflect.ValueOf(params) + paramsT := reflect.TypeOf(params) + if paramsV.Kind() == reflect.Ptr { + if paramsV.Elem().Kind() != reflect.Struct { + t.Fatal("params should be a struct or a pointer to a struct") + } + paramsV = paramsV.Elem() + } else if paramsV.Kind() != reflect.Struct { + t.Fatal("params should be a struct or a pointer to a struct") + } + + numberOfPermutations := 1 + for i := 0; i < paramsV.NumField(); i++ { + if paramsV.Field(i).Kind() != reflect.Bool { + t.Fatal("unsupported permutation parameter type: " + paramsV.Field(i).Kind().String()) + } + + // If there's a non-empty value tag, we don't permute this field. + if paramsT.Field(i).Tag.Get("value") == "" { + numberOfPermutations *= 2 + } + } + + type run struct { + description string + params T + } + var runs []run + + for i := 0; i < numberOfPermutations; i++ { + var description string + var params T + paramsValue := reflect.ValueOf(¶ms).Elem() + + // Track which bit of i we're using to decide the value of the field. We don't use + // the iterator j directly, since we sometimes skip fields if they have a value tag + // defining a fixed value. + fieldBit := 0 + for j := 0; j < paramsV.NumField(); j++ { + var enabled, fixed bool + switch paramsT.Field(j).Tag.Get("value") { + case "": + enabled = (i & (1 << fieldBit)) > 0 + fieldBit++ + case "true": + enabled = true + fixed = true + case "false": + enabled = false + fixed = true + default: + t.Fatalf("unknown value tag: %s", paramsT.Field(j).Tag.Get("value")) + } + + if len(description) > 0 { + description += "," + } + if fixed { + description += fmt.Sprintf("%s=%v!", paramsV.Type().Field(j).Name, enabled) + } else { + description += fmt.Sprintf("%s=%v", paramsV.Type().Field(j).Name, enabled) + } + + paramsValue.Field(j).SetBool(enabled) + } + + runs = append(runs, run{description, params}) + } + + for _, r := range runs { + t.Run(r.description, func(t *testing.T) { + t.Helper() + f(t, r.params) + }) + } +} diff --git a/server/api/tabapp.go b/server/api/tabapp.go new file mode 100644 index 0000000000..cb1d85c652 --- /dev/null +++ b/server/api/tabapp.go @@ -0,0 +1,430 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" + pluginapi "github.com/mattermost/mattermost-plugin-api" + + "github.com/mattermost/mattermost-plugin-playbooks/server/app" + "github.com/mattermost/mattermost-plugin-playbooks/server/config" + "github.com/sirupsen/logrus" +) + +const ( + MicrosoftTeamsAppDomain = "https://playbooks.integrations.mattermost.com" + ExpectedAudience = "api://playbooks.integrations.mattermost.com/8f7d5beb-ed24-4d95-aa31-c26298d5a982" +) + +// TabAppHandler is the API handler. +type TabAppHandler struct { + *ErrorHandler + config config.Service + playbookRunService app.PlaybookRunService + pluginAPI *pluginapi.Client + getJWTKeyFunc func() keyfunc.Keyfunc +} + +// NewTabAppHandler Creates a new Plugin API handler. +func NewTabAppHandler( + apiHandler *Handler, + playbookRunService app.PlaybookRunService, + api *pluginapi.Client, + configService config.Service, + getJWTKeyFunc func() keyfunc.Keyfunc, +) *TabAppHandler { + handler := &TabAppHandler{ + ErrorHandler: &ErrorHandler{}, + playbookRunService: playbookRunService, + pluginAPI: api, + config: configService, + getJWTKeyFunc: getJWTKeyFunc, + } + + // Regiter the tab app on the root, which doesn't require Mattermost user authentication. + tabAppRouter := apiHandler.root.PathPrefix("/tabapp/").Subrouter() + tabAppRouter.HandleFunc("/runs", withContext(handler.getPlaybookRuns)).Methods(http.MethodOptions, http.MethodGet) + + return handler +} + +// limitedUser returns the minimum amount of user data needed for the app. +type limitedUser struct { + UserID string `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` +} + +// limitedUser returns the minimum amount of post data needed for the app. +type limitedPost struct { + Message string `json:"message"` + CreateAt int64 `json:"create_at"` + UserID string `json:"user_id"` +} + +type tabAppResults struct { + TotalCount int `json:"total_count"` + PageCount int `json:"page_count"` + PerPage int `json:"per_page"` + HasMore bool `json:"has_more"` + Items []app.PlaybookRun `json:"items"` + Users map[string]limitedUser `json:"users"` + Posts map[string]limitedPost `json:"posts"` +} + +func (r tabAppResults) Clone() tabAppResults { + newTabAppResults := r + + newTabAppResults.Items = make([]app.PlaybookRun, 0, len(r.Items)) + for _, i := range r.Items { + newTabAppResults.Items = append(newTabAppResults.Items, *i.Clone()) + } + + return newTabAppResults +} + +func (r tabAppResults) MarshalJSON() ([]byte, error) { + type Alias tabAppResults + + old := Alias(r.Clone()) + + // replace nils with empty slices for the frontend + if old.Items == nil { + old.Items = []app.PlaybookRun{} + } + + return json.Marshal(old) +} + +type validationError struct { + StatusCode int + Message string + Err error +} + +func (ve validationError) Error() string { + return ve.Message +} + +// validateToken validates the token in the given http.Request. +// +// A valid token is one that's been signed by Microsoft, has an `aud` claim that matches +// our known app, and has an `tid` claim that matches one of the configured tenants. +// +// In developer mode, we relax these constraints. First, we skip validation if an empty +// token is provided. This allows the developer to test the user interface and backend +// outside of Teams. Second, we skip checking the `aud` claim, allowing the token to match +// a developer app. If a token is provided, it must always be signed and match the +// configured tenant. +func validateToken(jwtKeyFunc keyfunc.Keyfunc, r *http.Request, expectedTenantIDs []string, enableDeveloper bool) *validationError { + token := r.Header.Get("Authorization") + if token == "" && enableDeveloper { + logrus.Warn("Skipping token validation check for empty token since developer mode enabled") + return nil + } + + if jwtKeyFunc == nil { + return &validationError{ + StatusCode: http.StatusInternalServerError, + Message: "Failed to initialize token validation", + } + } + + options := []jwt.ParserOption{ + // See https://golang-jwt.github.io/jwt/usage/signing_methods/ -- this is effectively all + // asymetric signing methods so that we exclude both the symmetric signing methods as + // well as the "none" algorithm. + // + // In practice, the upstream library already chokes on the HMAC validate method expecting + // a []byte but getting a public key object, but this is more explicit. + jwt.WithValidMethods([]string{ + jwt.SigningMethodES256.Alg(), + jwt.SigningMethodES384.Alg(), + jwt.SigningMethodES512.Alg(), + jwt.SigningMethodRS256.Alg(), + jwt.SigningMethodRS384.Alg(), + jwt.SigningMethodRS512.Alg(), + jwt.SigningMethodPS256.Alg(), + jwt.SigningMethodPS384.Alg(), + jwt.SigningMethodPS512.Alg(), + jwt.SigningMethodEdDSA.Alg(), + }), + // Require iat claim, and verify the token is not used before issue. + jwt.WithIssuedAt(), + // Require the exp claim: the library always verifies if the claim is present. + jwt.WithExpirationRequired(), + // There's no WithNotBefore() helper, but the library always verifies if the claim is present. + } + + // Verify that this token was signed for the expected app, unless developer mode is enabled. + if enableDeveloper { + logrus.Warn("Skipping aud claim check for token since developer mode enabled") + } else { + options = append(options, jwt.WithAudience(ExpectedAudience)) + } + + parsed, err := jwt.Parse( + token, + jwtKeyFunc.Keyfunc, + options..., + ) + if err != nil { + logrus.WithError(err).Warn("Rejected invalid token") + + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Failed to parse token", + Err: err, + } + } + + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + logrus.Warn("Validated token, but failed to parse claims") + + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Unexpected claims", + } + } + + logger := logrus.WithFields(logrus.Fields{ + "aud": claims["aud"], + "tid": claims["tid"], + "oid": claims["oid"], + "expected_tenant_ids": expectedTenantIDs, + }) + + // Verify the iat was present. The library is configured above to check + // its value is not in the future if present, but doesn't enforce its + // presence. + if iat, _ := parsed.Claims.GetIssuedAt(); iat == nil { + logger.Warn("Validated token, but rejected request on missing iat") + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Unexpected claims", + } + } + + // Verify the nbp was present. The library is configured above to check + // its value is not in the future if present, but doesn't enforce its + // presence. + if nbf, _ := parsed.Claims.GetNotBefore(); nbf == nil { + logger.Warn("Validated token, but rejected request on missing nbf") + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Unexpected claims", + } + } + + // Verify the tid is a GUID + if tid, ok := claims["tid"].(string); !ok { + logger.Warn("Validated token, but rejected request on missing tid") + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Unexpected claims", + } + } else if _, err = uuid.Parse(tid); err != nil { + logger.Warn("Validated token, but rejected request on non-GUID tid") + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Unexpected claims", + } + } + + for _, expectedTenantID := range expectedTenantIDs { + if claims["tid"] == expectedTenantID { + logger.Info("Validated token, and authorized request from matching tenant") + return nil + + } else if enableDeveloper && expectedTenantID == "*" { + logger.Warn("Validated token, but authorized request from wildcard tenant since developer mode enabled") + return nil + } + } + + logger.Warn("Validated token, but rejected request on tenant mismatch") + return &validationError{ + StatusCode: http.StatusUnauthorized, + Message: "Unexpected claims", + } +} + +func (h *TabAppHandler) getLimitedUser(userID string, showFullName bool) (limitedUser, error) { + user, err := h.pluginAPI.User.Get(userID) + if err != nil { + return limitedUser{}, err + } + + lUser := limitedUser{ + UserID: user.Id, + } + if showFullName { + lUser.FirstName = user.FirstName + lUser.LastName = user.LastName + } else { + lUser.FirstName = user.Username + } + + return lUser, nil +} + +// getPlaybookRuns handles the GET /tabapp/runs endpoint. +// +// It returns certain runs and associated users and status posts in support of +// a Microsoft Teams app backed by a Mattermost domain. +// +// Only runs with the @msteams as a participant are returned, though this can +// this can be automated by automatically inviting said bot to new runs via the +// playbook configuration. +// +// A Mattermost account is not required: rather the caller must prove +// themselves to belong to the configured Microsoft Teams tenant by passing a +// Microsoft Entra ID token in the Authorization header. The signature of this +// JWT is verified against known Microsoft signing keys, effectively allowing +// anyone with access to that tenant to access this endpoint. +func (h *TabAppHandler) getPlaybookRuns(c *Context, w http.ResponseWriter, r *http.Request) { + // If not enabled, the client won't get this reply since we won't have sent + // the CORS headers yet. This is no different than if Playbooks wasn't + // installed, so the client has to handle this case anyway. + if !h.config.GetConfiguration().EnableTeamsTabApp { + logrus.Warn("Rejecting request for teams tab app since feature not enabled") + handleResponseWithCode(w, http.StatusForbidden, "Tab app not enabled") + return + } + + // In development, allow CORS from any requestor. Specify the host given in the origin and + // not the wildcard '*' to continue to allow exchange of authorization tokens. Otherwise, + // in production, we require the app to originate from the known domain. + enableDeveloper := h.pluginAPI.Configuration.GetConfig().ServiceSettings.EnableDeveloper + if enableDeveloper != nil && *enableDeveloper { + logrus.WithField("origin", r.Header.Get("Origin")).Warn("Setting custom CORS header to match developer origin") + w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) + } else { + w.Header().Set("Access-Control-Allow-Origin", MicrosoftTeamsAppDomain) + } + w.Header().Add("Access-Control-Allow-Headers", "Authorization") + w.Header().Add("Access-Control-Allow-Methods", "OPTIONS,GET") + + // No payload needed to pre-flight the request. + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // Validate the token in the request, handling all errors if invalid. + expectedTenantIDs := strings.Split(h.config.GetConfiguration().TeamsTabAppTenantIDs, ",") + if validationErr := validateToken(h.getJWTKeyFunc(), r, expectedTenantIDs, enableDeveloper != nil && *enableDeveloper); validationErr != nil { + h.HandleErrorWithCode(w, c.logger, validationErr.StatusCode, validationErr.Message, validationErr.Err) + return + } + + teamsTabAppBotUserID := h.config.GetConfiguration().TeamsTabAppBotUserID + + // Parse using the common filter options, but we only support a subset below. + filterOptions, err := parsePlaybookRunsFilterOptions(r.URL, teamsTabAppBotUserID) + if err != nil { + h.HandleError(w, c.logger, err) + return + } + + // We'll only fetch runs of which the teams tab app bot is a participant. + requesterInfo := app.RequesterInfo{ + UserID: teamsTabAppBotUserID, + } + limitedFilterOptions := app.PlaybookRunFilterOptions{ + Page: filterOptions.Page, + PerPage: filterOptions.PerPage, + ParticipantID: teamsTabAppBotUserID, + Statuses: []string{app.StatusInProgress}, + Sort: app.SortByCreateAt, + Direction: app.DirectionDesc, + } + runResults, err := h.playbookRunService.GetPlaybookRuns(requesterInfo, limitedFilterOptions) + if err != nil { + h.HandleError(w, c.logger, err) + return + } + + showFullName := false + if showFullNamePtr := h.pluginAPI.Configuration.GetConfig().PrivacySettings.ShowFullName; showFullNamePtr != nil && *showFullNamePtr { + showFullName = true + } + + // Collect all the users participating in the runs. + users := make(map[string]limitedUser) + for _, run := range runResults.Items { + for _, participantID := range run.ParticipantIDs { + if _, ok := users[participantID]; ok { + continue + } + + user, err := h.getLimitedUser(participantID, showFullName) + if err != nil { + logrus.WithField("user_id", participantID).WithError(err).Warn("Failed to get participant user") + continue + } + + users[participantID] = user + } + } + + // Collect all the status posts for the runs. + posts := make(map[string]limitedPost) + for _, run := range runResults.Items { + for _, statusPost := range run.StatusPosts { + if statusPost.DeleteAt > 0 { + continue + } + + post, err := h.pluginAPI.Post.GetPost(statusPost.ID) + if err != nil { + logrus.WithField("post_id", statusPost.ID).WithError(err).Warn("Failed to get status post") + continue + } + posts[statusPost.ID] = limitedPost{ + Message: post.Message, + CreateAt: post.CreateAt, + UserID: post.UserId, + } + } + } + + // Collect all the authors for the status posts in the runs. + for _, statusPost := range posts { + if _, ok := users[statusPost.UserID]; ok { + continue + } + + // TODO: We don't actually post as the author anymore, so this is really + // only going to look up the single @playbooks user right now. Update this + // to extract the username from the stauts post props and resolve that user + // instead. + user, err := h.getLimitedUser(statusPost.UserID, showFullName) + if err != nil { + logrus.WithField("user_id", statusPost.UserID).WithError(err).Warn("Failed to get status post user") + continue + } + + users[statusPost.UserID] = user + } + + c.logger.WithField("total_count", runResults.TotalCount).Info("Handled request from tabapp client") + + results := tabAppResults{ + TotalCount: runResults.TotalCount, + PageCount: runResults.PageCount, + PerPage: runResults.PerPage, + HasMore: runResults.HasMore, + Items: runResults.Items, + Users: users, + Posts: posts, + } + + ReturnJSON(w, results, http.StatusOK) +} diff --git a/server/api/tabapp_test.go b/server/api/tabapp_test.go new file mode 100644 index 0000000000..58e78aa50d --- /dev/null +++ b/server/api/tabapp_test.go @@ -0,0 +1,502 @@ +package api + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/MicahParks/jwkset" + "github.com/MicahParks/keyfunc/v3" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + keyID = "my-key-id" + keyWithoutAlgID = "SjE4tvzAwAoo6GB32-g1QAdgIck" +) + +// TestValidateToken was inspired by https://github.com/MicahParks/keyfunc/blob/main/keyfunc_test.go. +func TestValidateToken(t *testing.T) { + makeRequest := func(t *testing.T, token *string) *http.Request { + request, err := http.NewRequest("GET", "/test", nil) + require.NoError(t, err) + + if token != nil { + request.Header.Add("Authorization", *token) + } + + return request + } + + makeKeySet := func(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey, keyfunc.Keyfunc) { + serverStore := jwkset.NewMemoryStorage() + + // Make a public/private key that has the alg property set. + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + jwk, err := jwkset.NewJWKFromKey(priv, jwkset.JWKOptions{ + Metadata: jwkset.JWKMetadataOptions{ + KID: keyID, + USE: jwkset.UseSig, + }, + }) + require.NoError(t, err) + + err = serverStore.KeyWrite(context.TODO(), jwk) + require.NoError(t, err) + + // Make a public/private key that is missing the alg property. + jwk2, err := jwkset.NewJWKFromRawJSON( + json.RawMessage(` + { + "kty": "RSA", + "use": "sig", + "kid": "SjE4tvzAwAoo6GB32-g1QAdgIck", + "x5t": "SjE4tvzAwAoo6GB32-g1QAdgIck", + "n": "ul88fCCUH0e4sqPqWOFj9BWGIctw2JJhoBO2aOykMvbjgr3Sn0ZbitaJTi5L8HFISLmwdSGvj76SOe7qNV0Jb0PuOb5DWTB_f4hXXPqZLfh5Bn7uyuTRapbaRczDESR1BuubTodJyhYapb1B19F4EbMbmvce2kXRRWZ5OFJA_FR7ZMU2mwLD5yzuWo_gr_52FwZZSBX1fkPbmDLriJoEIl8IVMMK11hlyK-m0LYsT-Tz_AHX3eT2bct-4xQSZAKsiWj68q4a6ek5LO5oM1MrkoFhErCDMWz-N8v7mM1qyy_kUQ417ZBBNGg5IvoIuM8yYQLMsH7R3i24UpT_kkJE6w", + "e": "AQAB", + "x5c": [ + "MIIC/TCCAeWgAwIBAgIIDlcb6PCgUSgwDQYJKoZIhvcNAQELBQAwLTErMCkGA1UEAxMiYWNjb3VudHMuYWNjZXNzY29udHJvbC53aW5kb3dzLm5ldDAeFw0yNDA4MDQxNjA1NTFaFw0yOTA4MDQxNjA1NTFaMC0xKzApBgNVBAMTImFjY291bnRzLmFjY2Vzc2NvbnRyb2wud2luZG93cy5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC6Xzx8IJQfR7iyo+pY4WP0FYYhy3DYkmGgE7Zo7KQy9uOCvdKfRluK1olOLkvwcUhIubB1Ia+PvpI57uo1XQlvQ+45vkNZMH9/iFdc+pkt+HkGfu7K5NFqltpFzMMRJHUG65tOh0nKFhqlvUHX0XgRsxua9x7aRdFFZnk4UkD8VHtkxTabAsPnLO5aj+Cv/nYXBllIFfV+Q9uYMuuImgQiXwhUwwrXWGXIr6bQtixP5PP8Adfd5PZty37jFBJkAqyJaPryrhrp6Tks7mgzUyuSgWESsIMxbP43y/uYzWrLL+RRDjXtkEE0aDki+gi4zzJhAsywftHeLbhSlP+SQkTrAgMBAAGjITAfMB0GA1UdDgQWBBS+wOJGOC8r3kutKW7UjRnXV2QlBjANBgkqhkiG9w0BAQsFAAOCAQEAtGOU0QsTPGFSteuIf1N9gM+qiONQqgfb66+FT/eXvuacFMa4pgXpUN0/AuKMxBg5kDRcms2PibWzefZ7RrRfLosKtViwVqkkKK+oyuSYXVArz+8u/v+jEgBh3BoMPqB3ukvCpGTB0rHX+QV1zNBac7hVQs/4kEGcr2/Nsa1g/uVRh2N7LQo9YRImmeOk/JrxgaSbkioW1xsQKMv7ZJLSLaSLXhAvA3HUU2kHMJCXE2VkNrs/naA47dWkMa9Af1GeqOe8uH+EJu88xz78kwKk2EiZt41ZaTY57fXYCxlnNQzhRdvm1KmJ8OfMUa/pqtXKWzrPWL/vs2oDsZJz9DzERw==" + ], + "issuer": "https://login.microsoftonline.com/{tenantid}/v2.0" + } + `), + jwkset.JWKMarshalOptions{ + Private: true, + }, + jwkset.JWKValidateOptions{}, + ) + require.NoError(t, err) + + err = serverStore.KeyWrite(context.TODO(), jwk2) + require.NoError(t, err) + + // Finally, setup the keyfunc backed by the above memory store. + options := keyfunc.Options{ + Ctx: context.TODO(), + Storage: serverStore, + UseWhitelist: []jwkset.USE{jwkset.UseSig}, + } + k, err := keyfunc.New(options) + if err != nil { + t.Fatalf("Failed to create Keyfunc. Error: %s", err) + } + + return pub, priv, k + } + + newRawToken := func(token string) *string { + return &token + } + + newToken := func(t *testing.T, priv ed25519.PrivateKey, mapClaims jwt.MapClaims) *string { + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, mapClaims) + token.Header[jwkset.HeaderKID] = keyID + signed, err := token.SignedString(priv) + if err != nil { + t.Fatalf("Failed to sign JWT. Error: %s", err) + } + + return &signed + } + + past := func() int64 { + return time.Now().Add(-60 * time.Second).Unix() + } + + future := func() int64 { + return time.Now().Add(60 * time.Second).Unix() + } + + type parameters struct { + EnableDeveloper bool + } + + runPermutations(t, parameters{}, func(t *testing.T, params parameters) { + t.Run("no authorization header", func(t *testing.T) { + _, _, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, nil) + expectedTenantIDs := []string{} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + if params.EnableDeveloper { + assert.Nil(t, validationErr) + } else { + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + } + }) + + t.Run("empty authorization header", func(t *testing.T) { + _, _, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newRawToken("")) + expectedTenantIDs := []string{} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + if params.EnableDeveloper { + assert.Nil(t, validationErr) + } else { + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + } + }) + + t.Run("nil keyfunc", func(t *testing.T) { + var jwtKeyFunc keyfunc.Keyfunc + r := makeRequest(t, newRawToken("invalid")) + expectedTenantIDs := []string{} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusInternalServerError, validationErr.StatusCode) + }) + + t.Run("failed to parse authorization header", func(t *testing.T) { + _, _, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newRawToken("invalid")) + expectedTenantIDs := []string{} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, missing claims", func(t *testing.T) { + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, nil)) + expectedTenantIDs := []string{} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("hmac key pretending to be rsa", func(t *testing.T) { + tid := uuid.NewString() + + pub, _, jwtKeyFunc := makeKeySet(t) + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": tid, + }) + token.Header[jwkset.HeaderKID] = keyWithoutAlgID + signed, err := token.SignedString([]byte(pub)) + require.NoError(t, err) + + r := makeRequest(t, &signed) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, missing iat claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": tid, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, invalid iat claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": "invalid", + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": tid, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, future iat claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": future(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": tid, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, missing exp claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "nbf": past(), + "tid": tid, + "aud": ExpectedAudience, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, invalid exp claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": "invalid", + "nbf": past(), + "tid": tid, + "aud": ExpectedAudience, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, expired exp claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": past(), + "nbf": past(), + "tid": tid, + "aud": ExpectedAudience, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, missing nbf claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "tid": tid, + "aud": ExpectedAudience, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, invalid nbf claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": "invalid", + "tid": tid, + "aud": ExpectedAudience, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, future nbf claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": future(), + "tid": tid, + "aud": ExpectedAudience, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, wrong aud claim", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "tid": tid, + "aud": "unexpected-app", + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + if params.EnableDeveloper { + assert.Nil(t, validationErr) + } else { + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + } + }) + + t.Run("signed token, no tenants configured", func(t *testing.T) { + wrongTid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": wrongTid, + })) + expectedTenantIDs := []string{} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, not matching single configured tenant", func(t *testing.T) { + wrongTid := uuid.NewString() + expectedTid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": wrongTid, + })) + expectedTenantIDs := []string{expectedTid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, not matching multiple configured tenants", func(t *testing.T) { + wrongTid := uuid.NewString() + expectedTid1 := uuid.NewString() + expectedTid2 := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": wrongTid, + })) + expectedTenantIDs := []string{expectedTid1, expectedTid2} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + }) + + t.Run("signed token, matching single configured tenant", func(t *testing.T) { + tid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": tid, + })) + expectedTenantIDs := []string{tid} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + assert.Nil(t, validationErr) + }) + + t.Run("signed token, matching one of multiple configured tenants", func(t *testing.T) { + expectedTid1 := uuid.NewString() + expectedTid2 := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": expectedTid1, + })) + expectedTenantIDs := []string{expectedTid1, expectedTid2} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + assert.Nil(t, validationErr) + }) + + t.Run("signed token, wildcard tenant", func(t *testing.T) { + developerTid := uuid.NewString() + + _, priv, jwtKeyFunc := makeKeySet(t) + r := makeRequest(t, newToken(t, priv, jwt.MapClaims{ + "iat": past(), + "exp": future(), + "nbf": past(), + "aud": ExpectedAudience, + "tid": developerTid, + })) + expectedTenantIDs := []string{"*"} + + validationErr := validateToken(jwtKeyFunc, r, expectedTenantIDs, params.EnableDeveloper) + if params.EnableDeveloper { + assert.Nil(t, validationErr) + } else { + require.NotNil(t, validationErr) + assert.Equal(t, http.StatusUnauthorized, validationErr.StatusCode) + } + }) + }) +} diff --git a/server/api_tabapp_test.go b/server/api_tabapp_test.go new file mode 100644 index 0000000000..f087ee476c --- /dev/null +++ b/server/api_tabapp_test.go @@ -0,0 +1,234 @@ +package main + +import ( + "context" + "net/http" + "testing" + + "github.com/mattermost/mattermost-plugin-playbooks/client" + "github.com/mattermost/mattermost-plugin-playbooks/server/api" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTabAppGetRuns(t *testing.T) { + e := Setup(t) + e.CreateBasic() + + do := func(t *testing.T, method string, headers map[string]string) (*http.Response, error) { + t.Helper() + + return e.ServerClient.DoAPIRequestReader(method, e.ServerClient.URL+"/plugins/playbooks/tabapp/runs", nil, headers) + } + + setTabApp := func(t *testing.T, enable bool) { + cfg := e.Srv.Config() + cfg.PluginSettings.Plugins["playbooks"]["EnableTeamsTabApp"] = enable + + var patchedConfig model.Config + + // Patching only the plugin config mysteriously doesn't trigger an OnConfigurationChange + // back to the plugin. So mess with an unrelated setting to force this to happen. + patchedConfig.ServiceSettings.GfycatAPIKey = model.NewString(model.NewRandomString(6)) + patchedConfig.PluginSettings.Plugins = map[string]map[string]any{ + "playbooks": cfg.PluginSettings.Plugins["playbooks"], + } + _, _, err := e.ServerAdminClient.PatchConfig(&patchedConfig) + require.NoError(t, err) + } + + setDeveloperMode := func(t *testing.T, enable bool) { + var patchedConfig model.Config + patchedConfig.ServiceSettings.EnableDeveloper = model.NewBool(enable) + _, _, err := e.ServerAdminClient.PatchConfig(&patchedConfig) + require.NoError(t, err) + } + + setShowFullName := func(t *testing.T, enable bool) { + var patchedConfig model.Config + patchedConfig.PrivacySettings.ShowFullName = model.NewBool(enable) + _, _, err := e.ServerAdminClient.PatchConfig(&patchedConfig) + require.NoError(t, err) + } + + assertNoCORS := func(t *testing.T, response *http.Response) { + assert.Empty(t, response.Header.Get("Access-Control-Allow-Origin")) + assert.Empty(t, response.Header.Get("Access-Control-Allow-Headers")) + assert.Empty(t, response.Header.Get("Access-Control-Allow-Methods")) + } + + assertCORS := func(t *testing.T, expectedOrigin string, response *http.Response) { + assert.Equal(t, expectedOrigin, response.Header.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "Authorization", response.Header.Get("Access-Control-Allow-Headers")) + assert.Equal(t, "OPTIONS,GET", response.Header.Get("Access-Control-Allow-Methods")) + } + + t.Run("feature disabled", func(t *testing.T) { + setTabApp(t, false) + setDeveloperMode(t, false) + + response, err := do(t, http.MethodGet, nil) + require.Error(t, err) + require.Equal(t, http.StatusForbidden, response.StatusCode) + assertNoCORS(t, response) + }) + + t.Run("CORS headers, no provided Origin header", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, false) + + response, err := do(t, http.MethodOptions, nil) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + assertCORS(t, api.MicrosoftTeamsAppDomain, response) + }) + + t.Run("CORS headers, matching Origin header", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, false) + + response, err := do(t, http.MethodOptions, map[string]string{ + "Origin": api.MicrosoftTeamsAppDomain, + }) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + assertCORS(t, api.MicrosoftTeamsAppDomain, response) + }) + + t.Run("CORS headers, mis-matched Origin header", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, false) + + response, err := do(t, http.MethodOptions, map[string]string{ + "Origin": "example.com", + }) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + assertCORS(t, api.MicrosoftTeamsAppDomain, response) + }) + + t.Run("CORS headers, mis-matched Origin header, developer mode", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, true) + + response, err := do(t, http.MethodOptions, map[string]string{ + "Origin": "example.com", + }) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, response.StatusCode) + assertCORS(t, "example.com", response) + }) + + t.Run("fetch runs, none to return (no token and developer mode)", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, true) + + response, err := do(t, http.MethodGet, map[string]string{ + "Authorization": "", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + assertCORS(t, "", response) + + tabAppResults, err := e.PlaybooksClient.TabApp.GetRuns(context.Background(), "", client.TabAppGetRunsOptions{Page: 0, PerPage: 100}) + require.NoError(t, err) + + require.Empty(t, tabAppResults.Items) + require.Empty(t, tabAppResults.Users) + require.Empty(t, tabAppResults.Posts) + }) + + t.Run("fetch runs, one to return (no token and developer mode), show full name disabled", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, true) + setShowFullName(t, false) + + run, err := e.PlaybooksClient.PlaybookRuns.Create(context.Background(), client.PlaybookRunCreateOptions{ + Name: "Invite @msteams", + OwnerUserID: e.RegularUser.Id, + TeamID: e.BasicTeam.Id, + PlaybookID: e.BasicPlaybook.ID, + }) + assert.NoError(t, err) + assert.NotNil(t, run) + t.Cleanup(func() { + err = e.PlaybooksClient.PlaybookRuns.Finish(context.Background(), run.ID) + require.NoError(t, err) + }) + + msteamsUser, _, err := e.ServerClient.GetUserByUsername("msteams", "") + require.NoError(t, err) + + _, _, err = e.ServerClient.AddTeamMember(e.BasicTeam.Id, msteamsUser.Id) + require.NoError(t, err) + + _, err = addParticipants(e.PlaybooksClient, run.ID, []string{msteamsUser.Id}) + require.NoError(t, err) + + tabAppResults, err := e.PlaybooksClient.TabApp.GetRuns(context.Background(), "", client.TabAppGetRunsOptions{Page: 0, PerPage: 100}) + require.NoError(t, err) + + require.Len(t, tabAppResults.Items, 1) + require.Len(t, tabAppResults.Users, 2) + for _, user := range tabAppResults.Users { + switch user.UserID { + case msteamsUser.Id: + assert.Equal(t, msteamsUser.Username, user.FirstName) + case e.RegularUser.Id: + assert.Equal(t, e.RegularUser.Username, user.FirstName) + default: + assert.Fail(t, "unexpected user id %s", user.UserID) + } + assert.Empty(t, user.LastName) + } + require.Empty(t, tabAppResults.Posts) + }) + + t.Run("fetch runs, one to return (no token and developer mode), show full name enabled", func(t *testing.T) { + setTabApp(t, true) + setDeveloperMode(t, true) + setShowFullName(t, true) + + run, err := e.PlaybooksClient.PlaybookRuns.Create(context.Background(), client.PlaybookRunCreateOptions{ + Name: "Invite @msteams", + OwnerUserID: e.RegularUser.Id, + TeamID: e.BasicTeam.Id, + PlaybookID: e.BasicPlaybook.ID, + }) + assert.NoError(t, err) + assert.NotNil(t, run) + t.Cleanup(func() { + err = e.PlaybooksClient.PlaybookRuns.Finish(context.Background(), run.ID) + require.NoError(t, err) + }) + + msteamsUser, _, err := e.ServerClient.GetUserByUsername("msteams", "") + require.NoError(t, err) + + _, _, err = e.ServerClient.AddTeamMember(e.BasicTeam.Id, msteamsUser.Id) + require.NoError(t, err) + + _, err = addParticipants(e.PlaybooksClient, run.ID, []string{msteamsUser.Id}) + require.NoError(t, err) + + tabAppResults, err := e.PlaybooksClient.TabApp.GetRuns(context.Background(), "", client.TabAppGetRunsOptions{Page: 0, PerPage: 100}) + require.NoError(t, err) + + require.Len(t, tabAppResults.Items, 1) + require.Len(t, tabAppResults.Users, 2) + for _, user := range tabAppResults.Users { + switch user.UserID { + case msteamsUser.Id: + assert.Equal(t, msteamsUser.FirstName, user.FirstName) + assert.Equal(t, msteamsUser.LastName, user.LastName) + case e.RegularUser.Id: + assert.Equal(t, e.RegularUser.FirstName, user.FirstName) + assert.Equal(t, e.RegularUser.LastName, user.LastName) + default: + assert.Fail(t, "unexpected user id %s", user.UserID) + } + } + require.Empty(t, tabAppResults.Posts) + }) +} diff --git a/server/config/configuration.go b/server/config/configuration.go index 22c34f60c0..e55f80873b 100644 --- a/server/config/configuration.go +++ b/server/config/configuration.go @@ -14,6 +14,10 @@ package config type Configuration struct { // BotUserID used to post messages. BotUserID string + + EnableTeamsTabApp bool `json:"enableteamstabapp"` + TeamsTabAppTenantIDs string `json:"teamstabapptenantids"` + TeamsTabAppBotUserID string } // Clone shallow copies the configuration. Your implementation may require a deep copy if @@ -26,5 +30,8 @@ func (c *Configuration) Clone() *Configuration { func (c *Configuration) serialize() map[string]interface{} { ret := make(map[string]interface{}) ret["BotUserID"] = c.BotUserID + ret["EnableTeamsTabApp"] = c.EnableTeamsTabApp + ret["TeamsTabAppTenantIDs"] = c.TeamsTabAppTenantIDs + ret["TeamsTabAppBotUserID"] = c.TeamsTabAppBotUserID return ret } diff --git a/server/config/service.go b/server/config/service.go index 545ef35561..f3ea175c96 100644 --- a/server/config/service.go +++ b/server/config/service.go @@ -122,6 +122,7 @@ func (c *ServiceImpl) OnConfigurationChange() error { } configuration.BotUserID = c.configuration.BotUserID + configuration.TeamsTabAppBotUserID = c.configuration.TeamsTabAppBotUserID c.setConfiguration(configuration) diff --git a/server/main_test.go b/server/main_test.go index 218c27c7ec..20e4122de1 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -109,6 +109,9 @@ func Setup(t *testing.T) *TestEnvironment { os.Unsetenv("MM_SERVICESETTINGS_SITEURL") os.Unsetenv("MM_SERVICESETTINGS_LISTENADDRESS") + // Ignore developer mode and configure it ourselves during testing. + os.Unsetenv("MM_SERVICESETTINGS_ENABLEDEVELOPER") + // Environment Settings driverName := getEnvWithDefault("TEST_DATABASE_DRIVERNAME", "postgres") diff --git a/server/plugin.go b/server/plugin.go index 3261a3ee87..6baa99e77a 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -1,11 +1,14 @@ package main import ( + "context" "net/http" "os" "path/filepath" + "sync" "time" + "github.com/MicahParks/keyfunc/v3" "github.com/mattermost/mattermost-plugin-playbooks/server/api" "github.com/mattermost/mattermost-plugin-playbooks/server/app" "github.com/mattermost/mattermost-plugin-playbooks/server/bot" @@ -69,6 +72,10 @@ type Plugin struct { telemetryClient TelemetryClient licenseChecker app.LicenseChecker metricsService *metrics.Metrics + + cancelRunning context.CancelFunc + cancelRunningLock sync.Mutex + tabAppJWTKeyFunc keyfunc.Keyfunc } type StatusRecorder struct { @@ -163,6 +170,19 @@ func (p *Plugin) OnActivate() error { toggleTelemetry() p.config.RegisterConfigChangeListener(toggleTelemetry) + setupTeamsTabApp := func() { + err := p.setupTeamsTabApp() + if err != nil { + logrus.WithError(err).Error("failed to setup teams tab app") + } + } + + setupTeamsTabApp() + p.config.RegisterConfigChangeListener(func() { + // Run this asynchronously, since we may update the config when saving the bot. + go setupTeamsTabApp() + }) + apiClient := sqlstore.NewClient(pluginAPIClient) p.bot = bot.New(pluginAPIClient, p.config.GetConfiguration().BotUserID, p.config, p.telemetryClient) scheduler := cluster.GetJobOnceScheduler(p.API) @@ -261,6 +281,15 @@ func (p *Plugin) OnActivate() error { api.NewSettingsHandler(p.handler.APIRouter, pluginAPIClient, p.config) api.NewActionsHandler(p.handler.APIRouter, p.channelActionService, p.pluginAPI, p.permissions) api.NewCategoryHandler(p.handler.APIRouter, pluginAPIClient, p.categoryService, p.playbookService, p.playbookRunService) + api.NewTabAppHandler( + p.handler, + p.playbookRunService, + pluginAPIClient, + p.config, + func() keyfunc.Keyfunc { + return p.tabAppJWTKeyFunc + }, + ) isTestingEnabled := false flag := p.API.GetConfig().ServiceSettings.EnableTesting @@ -405,6 +434,13 @@ func (p *Plugin) getErrorCounterHandler() func(next http.Handler) http.Handler { } func (p *Plugin) OnDeactivate() error { + p.cancelRunningLock.Lock() + if p.cancelRunning != nil { + p.cancelRunning() + p.cancelRunning = nil + } + p.cancelRunningLock.Unlock() + logrus.Info("Shutting down store..") return p.pluginAPI.Store.Close() } diff --git a/server/tabapp.go b/server/tabapp.go new file mode 100644 index 0000000000..dcb67eabe6 --- /dev/null +++ b/server/tabapp.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "os" + "path/filepath" + + "github.com/pkg/errors" + + "github.com/MicahParks/keyfunc/v3" + pluginapi "github.com/mattermost/mattermost-plugin-api" + "github.com/mattermost/mattermost-server/v6/model" + "github.com/sirupsen/logrus" + + "github.com/mattermost/mattermost-plugin-playbooks/server/config" +) + +const ( + MicrosoftOnlineJWKSURL = "https://login.microsoftonline.com/common/discovery/v2.0/keys" +) + +func (p *Plugin) setupTeamsTabApp() error { + if p.config.GetConfiguration().EnableTeamsTabApp { + return p.startTeamsTabApp() + } + + return p.stopTeamsTabApp() +} + +func (p *Plugin) startTeamsTabApp() error { + err := p.createTeamsTabAppBot() + if err != nil { + return errors.Wrap(err, "failed to create @msteams bot") + } + + p.cancelRunningLock.Lock() + if p.cancelRunning == nil { + // Setup JWK set to assist in verifying JWTs passed from Microsoft Teams. + ctx, cancelCtx := context.WithCancel(context.Background()) + p.cancelRunning = cancelCtx + + k, err := keyfunc.NewDefaultCtx(ctx, []string{MicrosoftOnlineJWKSURL}) + if err != nil { + logrus.WithError(err).WithField("jwks_url", MicrosoftOnlineJWKSURL).Error("Failed to create a keyfunc.Keyfunc") + } + p.tabAppJWTKeyFunc = k + logrus.Info("Started JWKS monitor") + } + p.cancelRunningLock.Unlock() + + return nil +} + +func (p *Plugin) createTeamsTabAppBot() error { + // If we've previously created or found the bot, nothing to do. + if p.config.GetConfiguration().TeamsTabAppBotUserID != "" { + return nil + } + + botUserID := "" + + // Check for an existing bot, created either by us or the MS Teams plugin. + user, err := p.pluginAPI.User.GetByUsername("msteams") + if err != nil && err != pluginapi.ErrNotFound { + return errors.Wrap(err, "failed to look for @msteams bot") + } else if user != nil { + if user.DeleteAt > 0 { + return errors.Wrap(err, "@msteams is a deleted user") + } + + // Check that the user is actually a bot. + bot, err := p.pluginAPI.Bot.Get(user.Id, true) + if err != nil && err != pluginapi.ErrNotFound { + return errors.Wrap(err, "failed to check if @msteams is a bot") + } else if bot == nil { + return errors.New("@msteams is not a bot user") + } else if bot.DeleteAt > 0 { + return errors.New("@msteams is a deleted bot user") + } + + botUserID = user.Id + } + + // Create the bot, if needed. This will allow the MS Teams plugin to use the + // bot normally as well. + if botUserID == "" { + bot := &model.Bot{ + Username: "msteams", + DisplayName: "MS Teams", + OwnerId: "playbooks", + } + + err := p.pluginAPI.Bot.Create(bot) + if err != nil { + return errors.Wrap(err, "failed to create @msteams bot") + } + + bundlePath, err := p.API.GetBundlePath() + if err != nil { + return errors.Wrapf(err, "unable to get bundle path") + } + + profileImageBytes, err := os.ReadFile(filepath.Join(bundlePath, "assets/msteams_icon.svg")) + if err != nil { + return errors.Wrap(err, "failed to read profile image for @msteams bot") + } + + appErr := p.API.SetProfileImage(botUserID, profileImageBytes) + if appErr != nil { + logrus.WithError(appErr).Warn("failed to set profile image for @msteams bot") + } + + botUserID = bot.UserId + logrus.WithField("bot_user_id", botUserID).Info("created msteams bot") + } + + err = p.config.UpdateConfiguration(func(c *config.Configuration) { + c.TeamsTabAppBotUserID = botUserID + }) + if err != nil { + return errors.Wrap(err, "failed to save msteams bot to config") + } + + logrus.WithField("bot_user_id", botUserID).Info("setup msteams bot") + return nil +} + +func (p *Plugin) stopTeamsTabApp() error { + p.cancelRunningLock.Lock() + if p.cancelRunning != nil { + logrus.Info("Shutdown JWKS monitor") + p.cancelRunning() + p.cancelRunning = nil + } + p.cancelRunningLock.Unlock() + + return nil +}