From 4bcc7d9dd28587e0cbf377795a9147372e5fb028 Mon Sep 17 00:00:00 2001 From: kshitij katiyar <90389917+Kshitij-Katiyar@users.noreply.github.com> Date: Thu, 9 Jan 2025 17:15:09 +0530 Subject: [PATCH] [MM-1137]: Added Autolink support for cloud-oauth (#1139) * [MM-1137]: Added autolink support for cloud-oauth * [MM-1137]: Adding encryption key field in plugin config and encrypting admin api token * [MM-1137]: Added testcases for setupAutolink function * [MM-1137]: Updated AdminAPIToken help text * [MM-1137]: Review fixes * [MM-1137]: review fixes * [MM-1137]: log errors * Review fixes Co-authored-by: Doug Lauder * [MM-1137]: Fixed the secrets error found by QA * [MM-1137]: Fixed the invalid log after successfully installing autolink --------- Co-authored-by: Raghav Aggarwal Co-authored-by: Doug Lauder --- plugin.json | 20 +++++- server/issue.go | 49 ++++++++++++++- server/kv_mock_test.go | 22 ++++--- server/plugin.go | 134 ++++++++++++++++++++++++++++++--------- server/plugin_test.go | 138 +++++++++++++++++++++++++++++++++++++++++ server/utils.go | 23 +++++++ 6 files changed, 345 insertions(+), 41 deletions(-) diff --git a/plugin.json b/plugin.json index 7488278fe..3474f71b1 100644 --- a/plugin.json +++ b/plugin.json @@ -115,11 +115,29 @@ "placeholder": "", "default": false }, + { + "key": "EncryptionKey", + "display_name": "At Rest Encryption Key:", + "type": "generated", + "help_text": "The encryption key used to encrypt stored API tokens.", + "placeholder": "", + "default": null, + "secret": true + }, { "key": "AdminAPIToken", "display_name": "Admin API Token", "type": "text", - "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events even if the user triggering the event is not connected to Jira.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for the project the user cannot access.", + "help_text": "Set this [API token](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/) to get notified for comment and issue created events when the user triggering the event is not connected to Jira. This is also used for setting up autolink in the plugin.\n **Note:** API token should be created using an admin Jira account. Otherwise, the notification will not be delivered for projects that the user cannot access and autolink will not work.", + "placeholder": "", + "secret": true, + "default": "" + }, + { + "key": "AdminEmail", + "display_name": "Admin Email", + "type": "text", + "help_text": "**Note** Admin email is necessary to setup autolink for the Jira plugin and to to get notified for comment and issue created events when the user triggering the event is not connected to Jira", "placeholder": "", "default": "" } diff --git a/server/issue.go b/server/issue.go index f498d5bea..bdcc71e64 100644 --- a/server/issue.go +++ b/server/issue.go @@ -1103,7 +1103,10 @@ func (p *Plugin) GetIssueDataWithAPIToken(issueID, instanceID string) (*jira.Iss return nil, errors.Wrapf(err, "failed to create http request for fetching issue data. IssueID: %s", issueID) } - req.Header.Set("Authorization", fmt.Sprintf("Basic %s", p.getConfig().AdminAPIToken)) + err = p.SetAdminAPITokenRequestHeader(req) + if err != nil { + return nil, err + } resp, err := client.Do(req) if err != nil { @@ -1134,3 +1137,47 @@ func (p *Plugin) GetIssueDataWithAPIToken(issueID, instanceID string) (*jira.Iss return issue, nil } + +type ProjectSearchResponse struct { + Self string `json:"self"` + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` + IsLast bool `json:"isLast"` + Values jira.ProjectList `json:"values"` +} + +func (p *Plugin) GetProjectListWithAPIToken(instanceID string) (*jira.ProjectList, error) { + client := &http.Client{} + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/rest/api/3/project/search", instanceID), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to create HTTP request for fetching project list data. InstanceID: %s", instanceID) + } + + err = p.SetAdminAPITokenRequestHeader(req) + if err != nil { + return nil, err + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch project list data. InstanceID: %s", instanceID) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("unexpected status code: %d. InstanceID: %s", resp.StatusCode, instanceID) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + var projectResponse ProjectSearchResponse + if err = json.Unmarshal(body, &projectResponse); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal project list response") + } + + return &projectResponse.Values, nil +} diff --git a/server/kv_mock_test.go b/server/kv_mock_test.go index b5b90e016..fe750bbcd 100644 --- a/server/kv_mock_test.go +++ b/server/kv_mock_test.go @@ -9,6 +9,7 @@ import ( jira "github.com/andygrunwald/go-jira" "github.com/pkg/errors" + "github.com/stretchr/testify/mock" "github.com/mattermost/mattermost-plugin-jira/server/utils/types" ) @@ -98,26 +99,29 @@ func (store mockUserStore) MapUsers(func(*User) error) error { return nil } -type mockInstanceStore struct{} +type mockInstanceStore struct { + mock.Mock +} -func (store mockInstanceStore) CreateInactiveCloudInstance(types.ID, string) error { +func (store *mockInstanceStore) CreateInactiveCloudInstance(types.ID, string) error { return nil } -func (store mockInstanceStore) DeleteInstance(types.ID) error { +func (store *mockInstanceStore) DeleteInstance(types.ID) error { return nil } -func (store mockInstanceStore) LoadInstance(types.ID) (Instance, error) { - return &testInstance{}, nil +func (store *mockInstanceStore) LoadInstance(id types.ID) (Instance, error) { + args := store.Called(id) + return args.Get(0).(Instance), args.Error(1) } -func (store mockInstanceStore) LoadInstanceFullKey(string) (Instance, error) { +func (store *mockInstanceStore) LoadInstanceFullKey(string) (Instance, error) { return &testInstance{}, nil } -func (store mockInstanceStore) LoadInstances() (*Instances, error) { +func (store *mockInstanceStore) LoadInstances() (*Instances, error) { return NewInstances(), nil } -func (store mockInstanceStore) StoreInstance(instance Instance) error { +func (store *mockInstanceStore) StoreInstance(instance Instance) error { return nil } -func (store mockInstanceStore) StoreInstances(*Instances) error { +func (store *mockInstanceStore) StoreInstances(*Instances) error { return nil } diff --git a/server/plugin.go b/server/plugin.go index 02b5c34c5..22766170b 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -18,6 +18,7 @@ import ( "sync" textTemplate "text/template" + "github.com/andygrunwald/go-jira" "github.com/gorilla/mux" "github.com/pkg/errors" @@ -82,8 +83,14 @@ type externalConfig struct { // Display subscription name in notifications DisplaySubscriptionNameInNotifications bool + // The encryption key used to encrypt stored api tokens + EncryptionKey string + // API token from Jira AdminAPIToken string + + // Email of the admin + AdminEmail string } const defaultMaxAttachmentSize = utils.ByteSize(10 * 1024 * 1024) // 10Mb @@ -179,6 +186,25 @@ func (p *Plugin) OnConfigurationChange() error { } } + jsonBytes, err := json.Marshal(ec.AdminAPIToken) + if err != nil { + p.client.Log.Warn("Error marshaling the admin API token", "error", err.Error()) + return err + } + + encryptionKey := ec.EncryptionKey + if encryptionKey == "" { + p.client.Log.Warn("Encryption key required to encrypt admin API token") + return errors.New("failed to encrypt admin token. Encryption key not generated") + } + + encryptedAdminAPIToken, err := encrypt(jsonBytes, []byte(encryptionKey)) + if err != nil { + p.client.Log.Warn("Error encrypting the admin API token", "error", err.Error()) + return err + } + ec.AdminAPIToken = string(encryptedAdminAPIToken) + prev := p.getConfig() p.updateConfig(func(conf *config) { conf.externalConfig = ec @@ -309,34 +335,7 @@ func (p *Plugin) OnActivate() error { p.enterpriseChecker = enterprise.NewEnterpriseChecker(p.API) go func() { - for _, url := range instances.IDs() { - var instance Instance - instance, err = p.instanceStore.LoadInstance(url) - if err != nil { - continue - } - - ci, ok := instance.(*cloudInstance) - if !ok { - p.client.Log.Info("only cloud instances supported for autolink", "err", err) - continue - } - var status *model.PluginStatus - status, err = p.client.Plugin.GetPluginStatus(autolinkPluginID) - if err != nil { - p.client.Log.Warn("OnActivate: Autolink plugin unavailable. API returned error", "error", err.Error()) - continue - } - if status.State != model.PluginStateRunning { - p.client.Log.Warn("OnActivate: Autolink plugin unavailable. Plugin is not running", "status", status) - continue - } - - if err = p.AddAutolinksForCloudInstance(ci); err != nil { - p.client.Log.Info("could not install autolinks for cloud instance", "instance", ci.BaseURL, "err", err) - continue - } - } + p.SetupAutolink(instances) }() p.initializeTelemetry() @@ -344,6 +343,55 @@ func (p *Plugin) OnActivate() error { return nil } +func (p *Plugin) SetupAutolink(instances *Instances) { + for _, url := range instances.IDs() { + var instance Instance + instance, err := p.instanceStore.LoadInstance(url) + if err != nil { + continue + } + + if p.getConfig().AdminAPIToken == "" || p.getConfig().AdminEmail == "" { + p.client.Log.Info("unable to setup autolink due to missing API Token or Admin Email") + continue + } + + switch instance.(type) { + case *cloudInstance, *cloudOAuthInstance: + default: + p.client.Log.Info("only cloud and cloud-oauth instances supported for autolink") + continue + } + + var status *model.PluginStatus + status, err = p.client.Plugin.GetPluginStatus(autolinkPluginID) + if err != nil { + p.client.Log.Warn("OnActivate: Autolink plugin unavailable. API returned error", "error", err.Error()) + continue + } + + if status.State != model.PluginStateRunning { + p.client.Log.Warn("OnActivate: Autolink plugin unavailable. Plugin is not running", "status", status) + continue + } + + switch instance := instance.(type) { + case *cloudInstance: + if err = p.AddAutolinksForCloudInstance(instance); err != nil { + p.client.Log.Info("could not install autolinks for cloud instance", "instance", instance.BaseURL, "error", err.Error()) + } else { + p.client.Log.Info("successfully installed autolinks for cloud instance", "instance", instance.BaseURL) + } + case *cloudOAuthInstance: + if err = p.AddAutolinksForCloudOAuthInstance(instance); err != nil { + p.client.Log.Info("could not install autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL, "error", err.Error()) + } else { + p.client.Log.Info("successfully installed autolinks for cloud-oauth instance", "instance", instance.JiraBaseURL) + } + } + } +} + func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { client, err := ci.getClientForBot() if err != nil { @@ -355,9 +403,23 @@ func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { return fmt.Errorf("unable to get project keys: %w", err) } + return p.AddAutoLinkForProjects(plist, ci.BaseURL) +} + +func (p *Plugin) AddAutolinksForCloudOAuthInstance(coi *cloudOAuthInstance) error { + plist, err := p.GetProjectListWithAPIToken(string(coi.InstanceID)) + if err != nil { + return fmt.Errorf("error getting project list: %w", err) + } + + return p.AddAutoLinkForProjects(*plist, coi.JiraBaseURL) +} + +func (p *Plugin) AddAutoLinkForProjects(plist jira.ProjectList, baseURL string) error { + var err error for _, proj := range plist { key := proj.Key - err = p.AddAutolinks(key, ci.BaseURL) + err = p.AddAutolinks(key, baseURL) } if err != nil { return fmt.Errorf("some keys were not installed: %w", err) @@ -383,7 +445,10 @@ func (p *Plugin) AddAutolinks(key, baseURL string) error { client := autolinkclient.NewClientPlugin(p.API) if err := client.Add(installList...); err != nil { - return fmt.Errorf("unable to add autolinks: %w", err) + // Do not return an error if the status code is 304 (indicating that the autolink for this project is already installed). + if !strings.Contains(err.Error(), `Error: 304, {"status": "OK"}`) { + return fmt.Errorf("unable to add autolinks: %w", err) + } } return nil @@ -482,6 +547,15 @@ func (c *externalConfig) setDefaults() (bool, error) { changed = true } + if c.EncryptionKey == "" { + encryptionKey, err := generateSecret() + if err != nil { + return false, err + } + c.EncryptionKey = encryptionKey + changed = true + } + return changed, nil } diff --git a/server/plugin_test.go b/server/plugin_test.go index b268a213b..d1155859a 100644 --- a/server/plugin_test.go +++ b/server/plugin_test.go @@ -19,6 +19,16 @@ import ( "github.com/stretchr/testify/assert" ) +const ( + MockInstanceID = "mockInstanceID" + MockAPIToken = "mockAPIToken" + MockAdminEmail = "mockadmin@email.com" + MockBaseURL = "mockBaseURL" + MockASCKey = "mockAtlassianSecurityContextKey" + MockASCClientKey = "mockAtlassianSecurityContextClientKey" + MockASCSharedSecret = "mockAtlassianSecurityContextSharedSecret" // #nosec G101: Potential hardcoded credentials - This is a mock for testing purposes +) + func validRequestBody() io.ReadCloser { if f, err := os.Open("testdata/webhook-issue-created.json"); err != nil { panic(err) @@ -144,3 +154,131 @@ func TestPlugin(t *testing.T) { }) } } + +func TestSetupAutolink(t *testing.T) { + tests := []struct { + name string + setup func(*Plugin, *plugintest.API, *mockInstanceStore) + InstanceType InstanceType + }{ + { + name: "Missing API token or Admin email", + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogInfo", "unable to setup autolink due to missing API Token or Admin Email").Return(nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) + + p.updateConfig(func(c *config) { + c.AdminAPIToken = "" + c.AdminEmail = "" + }) + }, + InstanceType: ServerInstanceType, + }, + { + name: "Unsupported instance type", + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogInfo", "only cloud and cloud-oauth instances supported for autolink").Return(nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&serverInstance{}, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: ServerInstanceType, + }, + { + name: "Autolink plugin unavailable API returned error", + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogWarn", "OnActivate: Autolink plugin unavailable. API returned error", "error", mock.Anything).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(nil, &model.AppError{Message: "error getting plugin status"}).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&cloudInstance{}, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudInstanceType, + }, + { + name: "Autolink plugin not running", + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogWarn", "OnActivate: Autolink plugin unavailable. Plugin is not running", "status", &model.PluginStatus{State: model.PluginStateNotRunning}).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateNotRunning}, nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return(&cloudInstance{}, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudInstanceType, + }, + { + name: "Error installing autolinks for cloud instance", + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogInfo", "could not install autolinks for cloud instance", "instance", "mockBaseURL", "error", mock.Anything).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) + dummyInstanceStore.On("LoadInstance", mock.Anything).Return( + &cloudInstance{ + InstanceCommon: &InstanceCommon{ + Plugin: p, + }, + AtlassianSecurityContext: &AtlassianSecurityContext{ + BaseURL: MockBaseURL, + Key: MockASCKey, + ClientKey: MockASCClientKey, + SharedSecret: MockASCSharedSecret, + }, + }, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudInstanceType, + }, + { + name: "Error installing autolinks for cloud-oauth instance", + setup: func(p *Plugin, mockAPI *plugintest.API, dummyInstanceStore *mockInstanceStore) { + mockAPI.On("LogWarn", "Error unmarshalling admin API token", "error", mock.Anything).Times(1) + mockAPI.On("LogInfo", "could not install autolinks for cloud-oauth instance", "instance", "mockBaseURL", "error", mock.Anything).Return(nil).Times(1) + mockAPI.On("GetPluginStatus", autolinkPluginID).Return(&model.PluginStatus{State: model.PluginStateRunning}, nil).Times(1) + + dummyInstanceStore.On("LoadInstance", mock.Anything).Return( + &cloudOAuthInstance{ + InstanceCommon: &InstanceCommon{ + Plugin: p, + }, + JiraBaseURL: MockBaseURL, + }, nil).Times(1) + + p.updateConfig(GetConfigSetterFunction()) + }, + InstanceType: CloudOAuthInstanceType, + }, + } + for _, tt := range tests { + mockAPI := &plugintest.API{} + dummyInstanceStore := new(mockInstanceStore) + mockPluginClient := pluginapi.NewClient(mockAPI, nil) + p := &Plugin{ + client: mockPluginClient, + instanceStore: dummyInstanceStore, + } + + t.Run(tt.name, func(t *testing.T) { + tt.setup(p, mockAPI, dummyInstanceStore) + instances := GetInstancesWithType(tt.InstanceType) + + p.SetupAutolink(instances) + + mockAPI.AssertExpectations(t) + dummyInstanceStore.AssertExpectations(t) + }) + } +} + +func GetConfigSetterFunction() func(*config) { + return func(c *config) { + c.AdminAPIToken = MockAPIToken + c.AdminEmail = MockAdminEmail + } +} + +func GetInstancesWithType(instanceType InstanceType) *Instances { + return NewInstances(&InstanceCommon{ + InstanceID: MockInstanceID, + Type: instanceType, + }) +} diff --git a/server/utils.go b/server/utils.go index 21ba39089..0bda12115 100644 --- a/server/utils.go +++ b/server/utils.go @@ -7,7 +7,9 @@ import ( "crypto/rand" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" + "net/http" "regexp" "strings" @@ -182,3 +184,24 @@ func getS256PKCEParams() (*PKCEParams, error) { CodeVerifier: verifier, }, nil } + +func (p *Plugin) SetAdminAPITokenRequestHeader(req *http.Request) error { + encryptedAdminAPIToken := p.getConfig().AdminAPIToken + jsonBytes, err := decrypt([]byte(encryptedAdminAPIToken), []byte(p.getConfig().EncryptionKey)) + if err != nil { + p.client.Log.Warn("Error decrypting admin API token", "error", err.Error()) + return err + } + var adminAPIToken string + err = json.Unmarshal(jsonBytes, &adminAPIToken) + if err != nil { + p.client.Log.Warn("Error unmarshalling admin API token", "error", err.Error()) + return err + } + + encodedAuth := base64.StdEncoding.EncodeToString([]byte(p.getConfig().AdminEmail + ":" + adminAPIToken)) + req.Header.Set("Authorization", "Basic "+encodedAuth) + req.Header.Set("Accept", "application/json") + + return nil +}