Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[GH-626]: Supported filtering on comment visibility for subscriptions #894

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions server/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import (
)

const autocompleteSearchRoute = "2/jql/autocompletedata/suggestions"
const commentVisibilityRoute = "2/user"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking we want to use project roles for this

I have a WIP here that implements the data fetching piece, but not the comment filtering piece master...comment-security

err = client.RESTGet("2/project/"+projectKey, nil, &result)
if err != nil {
return http.StatusInternalServerError,
errors.WithMessage(err, "error fetching comment security levels")
}
roles := result.Roles
out := &AutoCompleteResult{}
for role := range roles {
out.Results = append(out.Results, Result{
Value: role,
DisplayName: role,
})
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mickmister We are getting the user groups here. I don't think the project roles API returns that response

const userSearchRoute = "2/user/assignable/search"
const unrecognizedEndpoint = "_unrecognized"
const visibleToAllUsers = "visible-to-all-users"

// Client is the combined interface for all upstream APIs and convenience methods.
type Client interface {
Expand Down Expand Up @@ -64,6 +66,7 @@ type SearchService interface {
SearchUsersAssignableToIssue(issueKey, query string, maxResults int) ([]jira.User, error)
SearchUsersAssignableInProject(projectKey, query string, maxResults int) ([]jira.User, error)
SearchAutoCompleteFields(params map[string]string) (*AutoCompleteResult, error)
SearchCommentVisibilityFields(params map[string]string) (*CommentVisibilityResult, error)
}

// IssueService is the interface for issue-related APIs.
Expand Down Expand Up @@ -252,6 +255,18 @@ type AutoCompleteResult struct {
Results []Result `json:"results"`
}

type JiraUserGroup struct {
Name string `json:"name"`
}

type JiraUserGroupCollection struct {
JiraUserGroups []*JiraUserGroup `json:"items"`
}

type CommentVisibilityResult struct {
Groups *JiraUserGroupCollection `json:"groups"`
}

// SearchAutoCompleteFields searches fieldValue specified in the params and returns autocomplete suggestions
// for that fieldValue
func (client JiraClient) SearchAutoCompleteFields(params map[string]string) (*AutoCompleteResult, error) {
Expand All @@ -264,6 +279,17 @@ func (client JiraClient) SearchAutoCompleteFields(params map[string]string) (*Au
return result, nil
}

// SearchCommentVisibilityFields searches fieldValue specified in the params and returns the comment visibility suggestions
// for that fieldValue
func (client JiraClient) SearchCommentVisibilityFields(params map[string]string) (*CommentVisibilityResult, error) {
result := &CommentVisibilityResult{}
if err := client.RESTGet(commentVisibilityRoute, params, result); err != nil {
return nil, err
}
result.Groups.JiraUserGroups = append(result.Groups.JiraUserGroups, &JiraUserGroup{visibleToAllUsers})
return result, nil
}

// DoTransition executes a transition on an issue.
func (client JiraClient) DoTransition(issueKey, transitionID string) error {
resp, err := client.Jira.Issue.DoTransition(issueKey, transitionID)
Expand Down
3 changes: 3 additions & 0 deletions server/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
routeAPIGetJiraProjectMetadata = "/api/v2/get-jira-project-metadata"
routeAPIGetSearchIssues = "/api/v2/get-search-issues"
routeAPIGetAutoCompleteFields = "/api/v2/get-search-autocomplete-fields"
routeAPIGetCommentVisibilityFields = "/api/v2/get-comment-visibility-fields"
routeAPIGetSearchUsers = "/api/v2/get-search-users"
routeAPIAttachCommentToIssue = "/api/v2/attach-comment-to-issue"
routeAPIUserInfo = "/api/v2/userinfo"
Expand Down Expand Up @@ -126,6 +127,8 @@ func (p *Plugin) serveHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Req
return p.httpGetSearchIssues(w, r)
case routeAPIGetAutoCompleteFields:
return p.httpGetAutoCompleteFields(w, r)
case routeAPIGetCommentVisibilityFields:
return p.httpGetCommentVisibilityFields(w, r)
case routeAPIGetSearchUsers:
return p.httpGetSearchUsers(w, r)
case routeAPIAttachCommentToIssue:
Expand Down
57 changes: 51 additions & 6 deletions server/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,16 @@ import (
)

const (
labelsField = "labels"
statusField = "status"
reporterField = "reporter"
priorityField = "priority"
descriptionField = "description"
resolutionField = "resolution"
labelsField = "labels"
statusField = "status"
reporterField = "reporter"
priorityField = "priority"
descriptionField = "description"
resolutionField = "resolution"
headerMattermostUserID = "Mattermost-User-ID"
instanceIDQueryParam = "instance_id"
fieldValueQueryParam = "fieldValue"
expandQueryParam = "expand"
)

func makePost(userID, channelID, message string) *model.Post {
Expand Down Expand Up @@ -404,6 +408,47 @@ func (p *Plugin) GetCreateIssueMetadataForProjects(instanceID, mattermostUserID
})
}

func (p *Plugin) httpGetCommentVisibilityFields(w http.ResponseWriter, r *http.Request) (int, error) {
if r.Method != http.MethodGet {
return http.StatusMethodNotAllowed, fmt.Errorf("Request: " + r.Method + " is not allowed, must be GET")
}

mattermostUserID := r.Header.Get(headerMattermostUserID)
if mattermostUserID == "" {
return http.StatusUnauthorized, errors.New("not authorized")
}

instanceID := r.FormValue(instanceIDQueryParam)
client, _, connection, err := p.getClient(types.ID(instanceID), types.ID(mattermostUserID))
if err != nil {
return http.StatusInternalServerError, err
}

params := map[string]string{
"fieldValue": r.FormValue(fieldValueQueryParam),
"expand": r.FormValue(expandQueryParam),
"accountId": connection.AccountID,
}
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
response, err := client.SearchCommentVisibilityFields(params)
if err != nil {
return http.StatusInternalServerError, err
}
if response == nil {
return http.StatusInternalServerError, errors.New("failed to return the response")
}

jsonResponse, err := json.Marshal(response)
if err != nil {
return http.StatusInternalServerError, errors.WithMessage(err, "failed to marshal the response")
}

w.Header().Set("Content-Type", "application/json")
if _, err = w.Write(jsonResponse); err != nil {
return http.StatusInternalServerError, errors.WithMessage(err, "failed to write the response")
}
return http.StatusOK, nil
}

func (p *Plugin) httpGetSearchIssues(w http.ResponseWriter, r *http.Request) (int, error) {
if r.Method != http.MethodGet {
return respondErr(w, http.StatusMethodNotAllowed,
Expand Down
13 changes: 10 additions & 3 deletions server/subscribe.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const (
FilterEmpty = "empty"

MaxSubscriptionNameLength = 100
CommentVisibility = "commentVisibility"
)

type FieldFilter struct {
Expand Down Expand Up @@ -123,7 +124,7 @@ func (p *Plugin) getUserID() string {
return p.getConfig().botUserID
}

func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilters) bool {
func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilters, visibilityAttribute string) bool {
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
webhookEvents := wh.Events()
foundEvent := false
eventTypes := filters.Events
Expand Down Expand Up @@ -159,6 +160,12 @@ func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilt
}

value := getIssueFieldValue(&wh.JiraWebhook.Issue, field.Key)
if visibilityAttribute != "" {
value[visibilityAttribute] = true
} else if field.Key == CommentVisibility {
value[visibleToAllUsers] = true
}

containsAny := value.ContainsAny(field.Values.Elems()...)
containsAll := value.ContainsAll(field.Values.Elems()...)

Expand All @@ -174,7 +181,7 @@ func (p *Plugin) matchesSubsciptionFilters(wh *webhook, filters SubscriptionFilt
return validFilter
}

func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID) ([]ChannelSubscription, error) {
func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID, visibilityAttribute string) ([]ChannelSubscription, error) {
subs, err := p.getSubscriptions(instanceID)
if err != nil {
return nil, err
Expand All @@ -183,7 +190,7 @@ func (p *Plugin) getChannelsSubscribed(wh *webhook, instanceID types.ID) ([]Chan
var channelSubscriptions []ChannelSubscription
subIds := subs.Channel.ByID
for _, sub := range subIds {
if p.matchesSubsciptionFilters(wh, sub.Filters) {
if p.matchesSubsciptionFilters(wh, sub.Filters, visibilityAttribute) {
channelSubscriptions = append(channelSubscriptions, sub)
}
}
Expand Down
2 changes: 1 addition & 1 deletion server/subscribe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1385,7 +1385,7 @@ func TestGetChannelsSubscribed(t *testing.T) {
wh, err := ParseWebhook(bb)
assert.Nil(t, err)

actual, err := p.getChannelsSubscribed(wh.(*webhook), testInstance1.InstanceID)
actual, err := p.getChannelsSubscribed(wh.(*webhook), testInstance1.InstanceID, "")
assert.Nil(t, err)
assert.Equal(t, len(tc.ChannelSubscriptions), len(actual))
actualChannelIDs := NewStringSet()
Expand Down
35 changes: 34 additions & 1 deletion server/webhook_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
package main

import (
jira "github.com/andygrunwald/go-jira"

"github.com/mattermost/mattermost-plugin-jira/server/utils/types"
)

Expand All @@ -27,6 +29,30 @@ func (ww webhookWorker) work() {
}
}

func isCommentRelatedWebhook(wh Webhook) bool {
return wh.Events().Intersection(commentEvents).Len() > 0
}

func (ww webhookWorker) getVisibilityAttribute(msg *webhookMessage, v *webhook) (string, error) {
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
mattermostUserID, err := ww.p.userStore.LoadMattermostUserID(msg.InstanceID, v.JiraWebhook.Comment.Author.AccountID)
if err != nil {
ww.p.API.LogInfo("Commentator is not connected with the mattermost", "Error", err.Error())
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
return "", err
}

client, _, _, err := ww.p.getClient(msg.InstanceID, mattermostUserID)
if err != nil {
return "", err
}

comment := jira.Comment{}
if err = client.RESTGet(v.JiraWebhook.Comment.Self, nil, &comment); err != nil {
return "", err
}

return comment.Visibility.Value, nil
}

func (ww webhookWorker) process(msg *webhookMessage) (err error) {
defer func() {
if err == ErrWebhookIgnored {
Expand All @@ -49,7 +75,14 @@ func (ww webhookWorker) process(msg *webhookMessage) (err error) {
return err
}

channelsSubscribed, err := ww.p.getChannelsSubscribed(v, msg.InstanceID)
visibilityAttribute := ""
if isCommentRelatedWebhook(wh) {
if visibilityAttribute, err = ww.getVisibilityAttribute(msg, v); err != nil {
return err
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the comment author is not connected to Mattermost, we end up not processing the webhook event no matter what. I think there's a design gap in the security model of this feature. This is a difficult problem to solve, the case of a comment author not connected to Mattermost.


Let's say a subscription is set to "comment visibility must be empty". If we can't fetch the comment because the author is not connected to MM, then we can't know the visibility of the comment. And so we would then always be avoiding the webhook events with every comment made by a user that is not connected. That's not really behavior that we want.

Though we can only assume that it is a sensitive comment if we can't fetch its visibility. Otherwise the feature would create posts for sensitive comments.

@esarafianou Do you have any thoughts on this? Essentially, this feature allows subscriptions to filter out sensitive comments, but if the comment author is not connected to MM, then we have no way of knowing whether the comment is sensitive or not. Defaulting to secure avoids unwanted data leakage, but it then requires all comment authors to be connected to MM for any comments to be posted ever.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mickmister Seems I had missed this. Is this still relevant?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mickmister I have removed the extra API call for getting the comment visibility data as in the current implementation of Jira plugin we are always making an API call to get the comment event data. So, the user have to be connected in every case of comment events.

This will be fixed with the API token approach we discussed earlier. So, maybe we can proeed with this for now and it will be fixed later.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API token approch implemented via #1102


channelsSubscribed, err := ww.p.getChannelsSubscribed(v, msg.InstanceID, visibilityAttribute)
if err != nil {
return err
}
Expand Down
7 changes: 7 additions & 0 deletions webapp/src/actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ export const searchAutoCompleteFields = (params) => {
};
};

export const searchCommentVisibilityFields = (params) => {
return async (dispatch, getState) => {
const url = `${getPluginServerRoute(getState())}/api/v2/get-comment-visibility-fields`;
return doFetchWithResponse(`${url}${buildQueryString(params)}`);
};
};

export const searchUsers = (params) => {
return async (dispatch, getState) => {
const url = getPluginServerRoute(getState()) + '/api/v2/get-search-users';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';

import {searchCommentVisibilityFields} from 'actions';

import JiraCommentVisibilitySelector from './jira_commentvisibility_selector';

const mapDispatchToProps = (dispatch) => bindActionCreators({searchCommentVisibilityFields}, dispatch);

export default connect(null, mapDispatchToProps)(JiraCommentVisibilitySelector);
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.

import React from 'react';

import {ReactSelectOption} from 'types/model';

import BackendSelector, {Props as BackendSelectorProps} from '../backend_selector';

const stripHTML = (text: string) => {
if (!text) {
return text;
}

const doc = new DOMParser().parseFromString(text, 'text/html');
return doc.body.textContent || '';
};

type Props = BackendSelectorProps & {
searchCommentVisibilityFields: (params: {fieldValue: string}) => (
Promise<{data: {groups: {items: {name: string}[]}}; error?: Error}>
);
fieldName: string;
};

const JiraCommentVisibilitySelector = (props: Props) => {
const {value, isMulti, instanceID, searchCommentVisibilityFields} = props;
const fetchInitialSelectedValues = async (): Promise<ReactSelectOption[]> =>
((!value || (isMulti && !value.length)) ? [] : commentVisibilityFields(''));
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved

const commentVisibilityFields = async (inputValue: string): Promise<ReactSelectOption[]> => {
const params = {
fieldValue: inputValue,
instance_id: instanceID,
expand: 'groups',
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
};
return searchCommentVisibilityFields(params).then(({data}) => {
return data.groups.items.map((suggestion) => ({
value: suggestion.name,
label: stripHTML(suggestion.name),
}));
});
};

return (
<BackendSelector
{...props}
fetchInitialSelectedValues={fetchInitialSelectedValues}
search={commentVisibilityFields}
/>
);
};

export default JiraCommentVisibilitySelector;
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,34 @@ exports[`components/ChannelSubscriptionFilter should match snapshot 1`] = `
"label": "Affects versions",
"value": "versions",
},
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
raghavaggarwal2308 marked this conversation as resolved.
Show resolved Hide resolved
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
Object {
"label": "Comment Visibility",
"value": "commentVisibility",
},
Object {
"label": "Epic Link",
"value": "customfield_10014",
Expand Down
Loading