Skip to content

Commit

Permalink
[MM-1137]: Added Autolink support for cloud-oauth (#1139)
Browse files Browse the repository at this point in the history
* [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 <[email protected]>

* [MM-1137]: Fixed the secrets error found by QA

* [MM-1137]: Fixed the invalid log after successfully installing autolink

---------

Co-authored-by: Raghav Aggarwal <[email protected]>
Co-authored-by: Doug Lauder <[email protected]>
  • Loading branch information
3 people authored Jan 9, 2025
1 parent a8d2403 commit 4bcc7d9
Show file tree
Hide file tree
Showing 6 changed files with 345 additions and 41 deletions.
20 changes: 19 additions & 1 deletion plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": ""
}
Expand Down
49 changes: 48 additions & 1 deletion server/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
22 changes: 13 additions & 9 deletions server/kv_mock_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
134 changes: 104 additions & 30 deletions server/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"sync"
textTemplate "text/template"

"github.com/andygrunwald/go-jira"
"github.com/gorilla/mux"
"github.com/pkg/errors"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -309,41 +335,63 @@ 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()

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 {
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
}

Expand Down
Loading

0 comments on commit 4bcc7d9

Please sign in to comment.