diff --git a/plugin.json b/plugin.json index 7488278f..ec9d290c 100644 --- a/plugin.json +++ b/plugin.json @@ -115,11 +115,27 @@ "placeholder": "", "default": false }, + { + "key": "EncryptionKey", + "display_name": "At Rest Encryption Key:", + "type": "generated", + "help_text": "The encryption key used to encrypt stored API tokens.", + "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 f498d5be..bdcc71e6 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 b5b90e01..fe750bbc 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 02b5c34c..3fbfcbc6 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 := p.getConfig().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,51 @@ 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()) + } + 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()) + } + } + } +} + func (p *Plugin) AddAutolinksForCloudInstance(ci *cloudInstance) error { client, err := ci.getClientForBot() if err != nil { @@ -355,9 +399,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) diff --git a/server/plugin_test.go b/server/plugin_test.go index b268a213..d1155859 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 21ba3908..0bda1211 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 +}