Webapp - Outgoing OAuth Connections (#25507)

* added store

* make generated

* add missing license headers

* fix receiver name

* i18n

* i18n sorting

* update migrations from master

* make migrations-extract

* update retrylayer tests

* replaced sql query with id pagination

* fixed flaky tests

* missing columns

* missing columns on save/update

* typo

* improved tests

* remove enum from mysql colum

* add password credentials to store

* license changes

* OAuthOutgoingConnectionInterface

* Oauth -> OAuth

* make generated

* copied over installed_oauth_apps component and renamed things to installed_outgoing_oauth_connections

* merge migrations

* renamed migrations

* model change suggestions

* refactor test functionsn

* migration typo

* refactor store table names

* updated sanitize test

* cleanup merge

* refactor symbol

* "installed outgoing oauth connections" page works

* move things into a nested folder

* add and edit page stubs work

* list endpoint

* oauthoutgoingconnection -> outgoingoauthconnection

* signature change

* i18n update

* granttype typo

* naming

* api list

* uppercase typo

* i18n

* missing license header

* fixed path in comments

* updated openapi definitions

* changes to support selecting command request url

* sanitize connections

* make generated

* test license and no feature flag

* removed t.fatal

* updated testhelper calls

* yaml schema fixes

* switched interface name

* suggested translation

* missing i18n translation

* management permission

* moved permission initalization to proper place

* endpoints

* put tests

* error check typo

* fixed specific enttity urls

* tests

* read permission check

* updated openapi definitions

* i18n

* GetConnectionByAudience method

* notes

* replaced GetConnectionsByAudience with a filter

* added custom oauth token object

* updated interface and usage

* properly set enterprise interface

* move retrieval logic to impl

* webhook tests

* translations

* i18n: updates

* address comments

* endpoint and tests

* i18n

* api docs

* fixed endpoint path

* sq.like

* use filter object instead of parameters

* set url values if not empty

* typos

* converted some components to function components, and move around files

* correctly check token url

* restore flag to previous value

* added command oauth handler

* update enterprise imports

* migrate last component to function component

* Added enterprise import

* refactor permissions and add necessary webapp code

* Check correct flag in permission tree

* allow partial updates

* sort i18n webapp

* missing test modification

* fixed webapp i18n sorting

* allow validating stored connections

* added missing translation

* fix finished adding connection link and text on result page

* added missing permission to smoke tests

* missing role in smoke test

* updated translations

* updated translations

* support editing client secret on existing connection

* fix some i18n strings

* updated translations

* better error messages

* progress on using react select for command request url while maintaining typed in value

* remove writeheader, test

* HasValidGrantType

* end early to avoid nil pointer errors

* move slash command request url input box into its own component

* wrap components related to oauth connections in config check

* fix tests

* i18n-extract

* change some i18n strings to say "Outgoing OAuth 2.0 Connections"

* remove debug code

* fixed i18n

* updated i18n file

* feature configuration backend

* typo

* add system console setting

* Revert "typo"

This reverts commit 669da23e8ee47525ccaa6f59cbbd20bf8a121191.

* Revert "updated i18n file"

This reverts commit d0882c0dd7587533f0d0f7a7b7b190684186158a.

* Revert "fixed i18n"

This reverts commit 3108866bc19139182dfd094921c56cdefc4695ea.

* fixed i18n

* updated i18n file

* typo

* updated i18n

* updated i18n

* updated i18n

* updated version to 9.6

* replace feature flag with system console configuration

* i18n

* updated tests

* pr feedback

* fix styling of disabled text box

* fix styling of action links in integration console

* server changes for validation feature

* webapp changes for validation feature

* pencil icon styling

* styling fixes for oauth audience correct configuration message

* fix sanitize test

* remove max lengths from outgoing oauth connection form

* use config var in webapp instead of feature flag

* change asterisks to bullets

* update api docs for validate endpoint

* feedback from ux review

* fix lint, types, tests

* fix stylelint

* implement validation button under the token url input

* support wildcard for matching audience urls

* updates for styling

* update snapshots

* add doc links for the outgoing oauth connections feature

* change doc links to use permalink

* add docs link to system console

* fix: use limitedreader in json decoding

* fix: form error in validation

* management permission can read now

* updated api documentation

* doc typo

* require one permission to read only

* fix api connection list audience filter

* fix audience matching and add loading indicator

* fix team permissions on outgoing oauth connection api calls

* fix api doc and test, for adding team id to query params

* handle read permissions by adding a team in the payload

* missing teamid query parameter in test

* change validate button logic to not require audience urls to be filled out

* fix redux type

---------

Co-authored-by: Felipe Martin <me@fmartingr.com>
Этот коммит содержится в:
Michael Kochell
2024-02-09 14:49:49 -05:00
коммит произвёл GitHub
родитель 3f6c94cfc3
Коммит 4e071e861c
91 изменённых файлов: 10116 добавлений и 201 удалений

Просмотреть файл

@@ -277,7 +277,7 @@ func Init(srv *app.Server) (*API, error) {
api.BaseRoutes.Limits = api.BaseRoutes.APIRoot.PathPrefix("/limits").Subrouter()
api.BaseRoutes.OutgoingOAuthConnections = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections").Subrouter()
api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.APIRoot.PathPrefix("/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter()
api.BaseRoutes.OutgoingOAuthConnection = api.BaseRoutes.OutgoingOAuthConnections.PathPrefix("/{outgoing_oauth_connection_id:[A-Za-z0-9]+}").Subrouter()
api.InitUser()
api.InitBot()

Просмотреть файл

@@ -6,11 +6,14 @@ package api4
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"github.com/mattermost/logr/v2"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/audit"
"github.com/mattermost/mattermost/server/v8/einterfaces"
)
@@ -20,12 +23,44 @@ const (
func (api *API) InitOutgoingOAuthConnection() {
api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(listOutgoingOAuthConnections)).Methods("GET")
api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(createOutgoingOAuthConnection)).Methods("POST")
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(getOutgoingOAuthConnection)).Methods("GET")
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(updateOutgoingOAuthConnection)).Methods("PUT")
api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(deleteOutgoingOAuthConnection)).Methods("DELETE")
api.BaseRoutes.OutgoingOAuthConnections.Handle("/validate", api.APISessionRequired(validateOutgoingOAuthConnectionCredentials)).Methods("POST")
}
// checkOutgoingOAuthConnectionReadPermissions checks if the user has the permissions to read outgoing oauth connections.
// An user with the permissions to manage outgoing oauth connections can read outgoing oauth connections.
// Otherwise the user needs to have the permissions to manage outgoing webhooks or slash commands in order to read outgoing
// oauth connections so that they can use them.
// This is made in this way so only users with the management permission can setup the outgoing oauth connections and then
// other users can use them in their outgoing webhooks and slash commands if they have permissions to manage those.
func checkOutgoingOAuthConnectionReadPermissions(c *Context, teamId string) bool {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) ||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageOutgoingWebhooks) ||
c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), teamId, model.PermissionManageSlashCommands) {
return true
}
c.SetPermissionError(model.PermissionManageOutgoingWebhooks, model.PermissionManageSlashCommands)
return false
}
// checkOutgoingOAuthConnectionWritePermissions checks if the user has the permissions to write outgoing oauth connections.
// This is a more granular permissions intended for system admins to manage (setup) outgoing oauth connections.
func checkOutgoingOAuthConnectionWritePermissions(c *Context) bool {
if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageOutgoingOAuthConnections) {
return true
}
c.SetPermissionError(model.PermissionManageOutgoingOAuthConnections)
return false
}
func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfaces.OutgoingOAuthConnectionInterface, bool) {
if !c.App.Config().FeatureFlags.OutgoingOAuthConnections {
c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.feature_flag", nil, "", http.StatusNotImplemented)
if c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && !*c.App.Config().ServiceSettings.EnableOutgoingOAuthConnections {
c.Err = model.NewAppError(where, "api.context.outgoing_oauth_connection.not_available.configuration_disabled", nil, "", http.StatusNotImplemented)
return nil, false
}
@@ -37,8 +72,9 @@ func ensureOutgoingOAuthConnectionInterface(c *Context, where string) (einterfac
}
type listOutgoingOAuthConnectionsQuery struct {
FromID string
Limit int
FromID string
Limit int
Audience string
}
// SetDefaults sets the default values for the query.
@@ -62,6 +98,7 @@ func (q *listOutgoingOAuthConnectionsQuery) ToFilter() model.OutgoingOAuthConnec
return model.OutgoingOAuthConnectionGetConnectionsFilter{
OffsetId: q.FromID,
Limit: q.Limit,
Audience: q.Audience,
}
}
@@ -77,16 +114,26 @@ func NewListOutgoingOAuthConnectionsQueryFromURLQuery(values url.Values) (*listO
limit := values.Get("limit")
if limit != "" {
limitInt, err := strconv.Atoi(limit)
if err == nil {
if err != nil {
return nil, err
}
query.Limit = limitInt
}
audience := values.Get("audience")
if audience != "" {
query.Audience = audience
}
return query, nil
}
func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Request) {
teamId := r.URL.Query().Get("team_id")
if !checkOutgoingOAuthConnectionReadPermissions(c, teamId) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
@@ -103,10 +150,25 @@ func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Req
return
}
connections, errList := service.GetConnections(c.AppContext, query.ToFilter())
if errList != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errList.Error(), http.StatusInternalServerError)
return
var connections []*model.OutgoingOAuthConnection
if query.Audience != "" {
// If the consumer expects an audience match, use the `GetConnectionByAudience` method to
// retrieve a single connection.
connection, err := service.GetConnectionForAudience(c.AppContext, query.Audience)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
connections = append(connections, connection)
} else {
// If the consumer does not expect an audience match, use the `GetConnections` method to
// retrieve a list of connections that potentially matches the provided audience.
var errList *model.AppError
connections, errList = service.GetConnections(c.AppContext, query.ToFilter())
if errList != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errList.Error(), http.StatusInternalServerError)
return
}
}
service.SanitizeConnections(connections)
@@ -118,6 +180,10 @@ func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Req
}
func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
@@ -138,3 +204,216 @@ func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Reque
return
}
}
func createOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("createOutgoingOauthConnection", audit.Fail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
var inputConnection model.OutgoingOAuthConnection
bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes)
if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.input_error", nil, err.Error(), http.StatusBadRequest)
return
}
audit.AddEventParameterAuditable(auditRec, "outgoing_oauth_connection", &inputConnection)
inputConnection.CreatorId = c.AppContext.Session().UserId
connection, err := service.SaveConnection(c.AppContext, &inputConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.Success()
auditRec.AddEventResultState(connection)
auditRec.AddEventObjectType("outgoing_oauth_connection")
c.LogAudit("client_id=" + connection.ClientId)
service.SanitizeConnection(connection)
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.create_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
}
func updateOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("updateOutgoingOAuthConnection", audit.Fail)
defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
if c.Err != nil {
return
}
var inputConnection model.OutgoingOAuthConnection
bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes)
if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.input_error", nil, err.Error(), http.StatusBadRequest)
return
}
if inputConnection.Id != c.Params.OutgoingOAuthConnectionID {
c.SetInvalidParam("id")
return
}
currentConnection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventPriorState(currentConnection)
currentConnection.Patch(&inputConnection)
connection, err := service.UpdateConnection(c.AppContext, currentConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventObjectType("outgoing_oauth_connection")
auditRec.AddEventResultState(connection)
auditRec.Success()
auditLogExtraInfo := "success"
// Audit log changes to clientID/Client Secret
if connection.ClientId != currentConnection.ClientId {
auditLogExtraInfo += " new_client_id=" + connection.ClientId
}
if connection.ClientSecret != currentConnection.ClientSecret {
auditLogExtraInfo += " new_client_secret"
}
c.LogAudit(auditLogExtraInfo)
service.SanitizeConnection(connection)
if err := json.NewEncoder(w).Encode(connection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.update_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
}
func deleteOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("deleteOutgoingOAuthConnection", audit.Fail)
defer c.LogAuditRec(auditRec)
audit.AddEventParameter(auditRec, "outgoing_oauth_connection_id", c.Params.OutgoingOAuthConnectionID)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
c.RequireOutgoingOAuthConnectionId()
if c.Err != nil {
return
}
connection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventPriorState(connection)
if err := service.DeleteConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.delete_connection.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
auditRec.AddEventObjectType("outgoing_oauth_connection")
auditRec.Success()
ReturnStatusOK(w)
}
// validateOutgoingOAuthConnectionCredentials validates the credentials of an outgoing oauth connection by requesting a token
// with the provided connection configuration. If the credentials are valid, the request will return a 200 status code and
// if the credentials are invalid, the request will return a 400 status code.
func validateOutgoingOAuthConnectionCredentials(c *Context, w http.ResponseWriter, r *http.Request) {
auditRec := c.MakeAuditRecord("validateOutgoingOAuthConnectionCredentials", audit.Fail)
defer c.LogAuditRec(auditRec)
c.LogAudit("attempt")
if !checkOutgoingOAuthConnectionWritePermissions(c) {
return
}
service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection)
if !ok {
return
}
// Allow checking connections sent in the body or by id if coming from an already existing
// connection url.
var inputConnection *model.OutgoingOAuthConnection
bodyReader := io.LimitReader(r.Body, *c.App.Config().ServiceSettings.MaximumPayloadSizeBytes)
if err := json.NewDecoder(bodyReader).Decode(&inputConnection); err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.input_error", nil, err.Error(), http.StatusBadRequest)
w.WriteHeader(c.Err.StatusCode)
return
}
if inputConnection.Id != "" && inputConnection.ClientSecret == "" {
var err *model.AppError
var storedConnection *model.OutgoingOAuthConnection
storedConnection, err = service.GetConnection(c.AppContext, inputConnection.Id)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, err.Error(), http.StatusInternalServerError)
w.WriteHeader(c.Err.StatusCode)
return
}
inputConnection.ClientSecret = storedConnection.ClientSecret
}
audit.AddEventParameterAuditable(auditRec, "outgoing_oauth_connection", inputConnection)
resultStatusCode := http.StatusOK
// Try to retrieve a token with the provided credentials
// do not store the token, just check if the credentials are valid and the request can be made
_, err := service.RetrieveTokenForConnection(c.AppContext, inputConnection)
if err != nil {
c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.validate_connection_credentials.app_error", nil, err.Error(), err.StatusCode)
c.Logger.Error("Failed to retrieve token while validating outgoing oauth connection", logr.Err(err))
resultStatusCode = err.StatusCode
} else {
ReturnStatusOK(w)
}
auditRec.Success()
auditRec.AddEventResultState(inputConnection)
auditRec.AddEventObjectType("outgoing_oauth_connection")
w.WriteHeader(resultStatusCode)
}

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -60,14 +60,13 @@ type Channels struct {
// previously fetched notices
cachedNotices model.ProductNotices
AccountMigration einterfaces.AccountMigrationInterface
Compliance einterfaces.ComplianceInterface
DataRetention einterfaces.DataRetentionInterface
MessageExport einterfaces.MessageExportInterface
Saml einterfaces.SamlInterface
Notification einterfaces.NotificationInterface
OutgoingOAuthConnection einterfaces.OutgoingOAuthConnectionInterface
Ldap einterfaces.LdapInterface
AccountMigration einterfaces.AccountMigrationInterface
Compliance einterfaces.ComplianceInterface
DataRetention einterfaces.DataRetentionInterface
MessageExport einterfaces.MessageExportInterface
Saml einterfaces.SamlInterface
Notification einterfaces.NotificationInterface
Ldap einterfaces.LdapInterface
// These are used to prevent concurrent upload requests
// for a given upload session which could cause inconsistencies
@@ -177,9 +176,6 @@ func NewChannels(services map[product.ServiceKey]any) (*Channels, error) {
if notificationInterface != nil {
ch.Notification = notificationInterface(New(ServerConnector(ch)))
}
if outgoingOauthConnectionInterface != nil {
ch.OutgoingOAuthConnection = outgoingOauthConnectionInterface(New(ServerConnector(ch)))
}
if samlInterfaceNew != nil {
ch.Saml = samlInterfaceNew(New(ServerConnector(ch)))
if err := ch.Saml.ConfigureSP(request.EmptyContext(s.Log())); err != nil {

Просмотреть файл

@@ -485,6 +485,23 @@ func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Value
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
var accessToken *model.OutgoingOAuthConnectionToken
// Retrieve an access token from a connection if one exists to use for the webhook request
if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil {
connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(rctx, cmd.URL)
if err != nil {
a.Log().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err))
}
if connection != nil {
accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(rctx, connection)
if err != nil {
a.Log().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err))
}
}
}
// Prepare the request
var req *http.Request
var err error
@@ -506,7 +523,14 @@ func (a *App) DoCommandRequest(rctx request.CTX, cmd *model.Command, p url.Value
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Token "+cmd.Token)
if cmd.Token != "" {
req.Header.Set("Authorization", "Token "+cmd.Token)
}
if accessToken != nil {
req.Header.Set("Authorization", accessToken.AsHeaderValue())
}
if cmd.Method == model.CommandMethodPost {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}

Просмотреть файл

@@ -1141,6 +1141,21 @@ func (a *App) getAddIPFilterPermissionsMigration() (permissionsMap, error) {
return t, nil
}
func (a *App) getAddOutgoingOAuthConnectionsPermissions() (permissionsMap, error) {
t := []permissionTransformation{}
permissionManageOutgoingOAuthConnections := []string{
model.PermissionManageOutgoingOAuthConnections.Id,
}
t = append(t, permissionTransformation{
On: permissionOr(isExactRole(model.SystemAdminRoleId)),
Add: permissionManageOutgoingOAuthConnections,
})
return t, nil
}
// DoPermissionsMigrations execute all the permissions migrations need by the current version.
func (a *App) DoPermissionsMigrations() error {
return a.Srv().doPermissionsMigrations()
@@ -1186,6 +1201,7 @@ func (s *Server) doPermissionsMigrations() error {
{Key: model.MigrationKeyAddCustomUserGroupsPermissionRestore, Migration: a.getAddCustomUserGroupsPermissionRestore},
{Key: model.MigrationKeyAddReadChannelContentPermissions, Migration: a.getAddChannelReadContentPermissions},
{Key: model.MigrationKeyAddIPFilteringPermissions, Migration: a.getAddIPFilterPermissionsMigration},
{Key: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Migration: a.getAddOutgoingOAuthConnectionsPermissions},
}
roles, err := s.Store().Role().GetAll()

Просмотреть файл

@@ -402,6 +402,10 @@ func NewServer(options ...Option) (*Server, error) {
s.IPFiltering = ipFilteringInterface(app)
}
if outgoingOauthConnectionInterface != nil {
s.OutgoingOAuthConnection = outgoingOauthConnectionInterface(app)
}
s.clusterLeaderListenerId = s.AddClusterLeaderChangedListener(func() {
mlog.Info("Cluster leader changed. Determining if job schedulers should be running:", mlog.Bool("isLeader", s.IsLeader()))
if s.Jobs != nil {

Просмотреть файл

@@ -17,6 +17,8 @@ import (
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/plugin/plugintest/mock"
"github.com/mattermost/mattermost/server/v8/einterfaces/mocks"
)
type InfiniteReader struct {
@@ -459,6 +461,51 @@ func TestDoCommandRequest(t *testing.T) {
require.NotNil(t, resp)
assert.Equal(t, "Hello, World!", resp.Text)
})
t.Run("with a url that matches an outgoing oauth connection", func(t *testing.T) {
outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{}
outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection
outgoingOAuthConnectionConfig := th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections
th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = model.NewBool(true)
t.Cleanup(func() {
th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl
th.App.Config().ServiceSettings.EnableOutgoingOAuthConnections = outgoingOAuthConnectionConfig
})
th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface
serverCommand := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, strings.NewReader(r.Header.Get("Authorization")))
}))
defer serverCommand.Close()
connection := &model.OutgoingOAuthConnection{
Id: model.NewId(),
Name: "test",
ClientId: "test",
ClientSecret: "test",
CreatorId: model.NewId(),
OAuthTokenURL: "fake",
GrantType: model.OutgoingOAuthConnectionGrantTypeClientCredentials,
Audiences: model.StringArray{
serverCommand.URL,
},
}
outgoingOauthIface.Mock.On("GetConnectionForAudience", mock.Anything, serverCommand.URL).Return(connection, nil)
outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything)
outgoingOauthIface.Mock.On("RetrieveTokenForConnection", mock.Anything, connection).Return(&model.OutgoingOAuthConnectionToken{
AccessToken: "token",
TokenType: "type",
}, nil)
_, resp, err := th.App.DoCommandRequest(th.Context, &model.Command{URL: serverCommand.URL}, url.Values{})
require.Nil(t, err)
require.NotNil(t, resp)
// Ensure that the Authorization header was set correctly by reading the body from the command response
// which was set to the Authorization header by the command handler.
assert.Equal(t, "type token", resp.Text)
})
}
func TestMentionsToTeamMembers(t *testing.T) {

Просмотреть файл

@@ -117,7 +117,27 @@ func (a *App) TriggerWebhook(c request.CTX, payload *model.OutgoingWebhookPayloa
go func() {
defer wg.Done()
webhookResp, err := a.doOutgoingWebhookRequest(url, body, contentType)
var accessToken *model.OutgoingOAuthConnectionToken
// Retrieve an access token from a connection if one exists to use for the webhook request
if a.Config().ServiceSettings.EnableOutgoingOAuthConnections != nil && *a.Config().ServiceSettings.EnableOutgoingOAuthConnections && a.OutgoingOAuthConnections() != nil {
connection, err := a.OutgoingOAuthConnections().GetConnectionForAudience(c, url)
if err != nil {
c.Logger().Error("Failed to find an outgoing oauth connection for the webhook", mlog.Err(err))
return
}
if connection != nil {
accessToken, err = a.OutgoingOAuthConnections().RetrieveTokenForConnection(c, connection)
if err != nil {
c.Logger().Error("Failed to retrieve token for outgoing oauth connection", mlog.Err(err))
return
}
}
}
webhookResp, err := a.doOutgoingWebhookRequest(url, body, contentType, accessToken)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
c.Logger().Error("Outgoing Webhook POST timed out. Consider increasing ServiceSettings.OutgoingIntegrationRequestsTimeout.", mlog.Err(err))
@@ -162,7 +182,7 @@ func (a *App) TriggerWebhook(c request.CTX, payload *model.OutgoingWebhookPayloa
wg.Wait()
}
func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType string) (*model.OutgoingWebhookResponse, error) {
func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType string, accessToken *model.OutgoingOAuthConnectionToken) (*model.OutgoingWebhookResponse, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(*a.Config().ServiceSettings.OutgoingIntegrationRequestsTimeout)*time.Second)
defer cancel()
@@ -174,6 +194,10 @@ func (a *App) doOutgoingWebhookRequest(url string, body io.Reader, contentType s
req.Header.Set("Content-Type", contentType)
req.Header.Set("Accept", "application/json")
if accessToken != nil {
req.Header.Add("Authorization", accessToken.AsHeaderValue())
}
resp, err := a.Srv().outgoingWebhookClient.Do(req)
if err != nil {
return nil, err

Просмотреть файл

@@ -6,6 +6,7 @@ package app
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -783,7 +784,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.NoError(t, err)
require.NotNil(t, resp)
@@ -797,7 +798,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id)
})
@@ -808,7 +809,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id)
})
@@ -819,7 +820,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.Equal(t, "api.unmarshal_error", err.(*model.AppError).Id)
})
@@ -838,7 +839,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewInt64(1)
})
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
_, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.Error(t, err)
require.IsType(t, &url.Error{}, err)
})
@@ -855,7 +856,7 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
cfg.ServiceSettings.OutgoingIntegrationRequestsTimeout = model.NewInt64(2)
})
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.NoError(t, err)
require.NotNil(t, resp)
assert.NotNil(t, resp.Text)
@@ -867,8 +868,22 @@ func TestDoOutgoingWebhookRequest(t *testing.T) {
}))
defer server.Close()
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json")
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", nil)
require.NoError(t, err)
require.Nil(t, resp)
})
t.Run("with auth token", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
io.Copy(w, strings.NewReader(fmt.Sprintf(`{"text":"%s"}`, r.Header.Get("Authorization"))))
}))
defer server.Close()
resp, err := th.App.doOutgoingWebhookRequest(server.URL, strings.NewReader(""), "application/json", &model.OutgoingOAuthConnectionToken{
AccessToken: "test",
TokenType: "Bearer",
})
require.NoError(t, err)
require.Equal(t, `Bearer test`, *resp.Text)
})
}

Просмотреть файл

@@ -5,6 +5,9 @@ package sqlstore
import (
"database/sql"
"fmt"
sq "github.com/mattermost/squirrel"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
@@ -49,9 +52,33 @@ func (s *SqlOutgoingOAuthConnectionStore) UpdateConnection(c request.CTX, conn *
return nil, err
}
if _, err := s.GetMasterX().NamedExec(`UPDATE OutgoingOAuthConnections SET
Name=:Name, ClientId=:ClientId, ClientSecret=:ClientSecret, UpdateAt=:UpdateAt, OAuthTokenURL=:OAuthTokenURL, GrantType=:GrantType, Audiences=:Audiences
WHERE Id=:Id`, conn); err != nil {
query := s.getQueryBuilder().Update("OutgoingOAuthConnections").Where(sq.Eq{"Id": conn.Id}).Set("UpdateAt", conn.UpdateAt)
if conn.Name != "" {
query = query.Set("Name", conn.Name)
}
if conn.ClientId != "" {
query = query.Set("ClientId", conn.ClientId)
}
if conn.ClientSecret != "" {
query = query.Set("ClientSecret", conn.ClientSecret)
}
if conn.OAuthTokenURL != "" {
query = query.Set("OAuthTokenURL", conn.OAuthTokenURL)
}
if conn.GrantType != "" {
query = query.Set("GrantType", conn.GrantType)
}
if len(conn.Audiences) > 0 {
query = query.Set("Audiences", conn.Audiences)
}
if conn.CredentialsUsername != nil {
query = query.Set("CredentialsUsername", conn.CredentialsUsername)
}
if conn.CredentialsPassword != nil {
query = query.Set("CredentialsPassword", conn.CredentialsPassword)
}
if _, err := s.GetMasterX().ExecBuilder(query); err != nil {
return nil, errors.Wrap(err, "failed to update OutgoingOAuthConnection")
}
return conn, nil
@@ -82,6 +109,10 @@ func (s *SqlOutgoingOAuthConnectionStore) GetConnections(c request.CTX, filters
query = query.Where("Id > ?", filters.OffsetId)
}
if filters.Audience != "" {
query = query.Where(sq.Like{"Audiences": fmt.Sprint("%", filters.Audience, "%")})
}
if err := s.GetReplicaX().SelectBuilder(&conns, query); err != nil {
return nil, errors.Wrap(err, "failed to get OutgoingOAuthConnections")
}

Просмотреть файл

@@ -52,6 +52,10 @@ func TestOutgoingOAuthConnectionStore(t *testing.T, rctx request.CTX, ss store.S
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
testGetOutgoingOAuthConnection(t, ss)
})
t.Run("GetConnectionsByAudience", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
testGetOutgoingOAuthConnectionByAudience(t, ss)
})
t.Run("GetConnections", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
testGetOutgoingOAuthConnections(t, ss)
@@ -159,6 +163,106 @@ func testUpdateOutgoingOAuthConnection(t *testing.T, ss store.Store) {
require.NoError(t, err)
require.Equal(t, connection, storeConn)
})
t.Run("patch", func(t *testing.T) {
t.Run("name", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.Name = "Updated Name"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("client id", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.ClientId = "Updated ClientId"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("client secret", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.ClientSecret = "Updated ClientSecret"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("oauth token url", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.OAuthTokenURL = "https://nowhere.com/updated"
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("grant type", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.GrantType = model.OutgoingOAuthConnectionGrantTypeClientCredentials
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("audiences", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
connection.Audiences = model.StringArray{"https://nowhere.com/updated"}
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("credentials username", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
username := "updated username"
connection.CredentialsUsername = &username
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
t.Run("credentials password", func(t *testing.T) {
connection := newValidOutgoingOAuthConnection()
_, err := ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
password := "updated password"
connection.CredentialsPassword = &password
updated, err := ss.OutgoingOAuthConnection().UpdateConnection(c, connection)
require.NoError(t, err)
require.Equal(t, connection, updated)
})
})
}
func testGetOutgoingOAuthConnection(t *testing.T, ss store.Store) {
@@ -172,6 +276,74 @@ func testGetOutgoingOAuthConnection(t *testing.T, ss store.Store) {
})
}
func runAudienceTests(t *testing.T, ss store.Store, connection *model.OutgoingOAuthConnection) {
c := request.TestContext(t)
t.Run("find by host only", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "knowhere.com"})
require.NoError(t, err)
require.Len(t, conn, 1)
require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn)
})
t.Run("find by host and path", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "knowhere.com/audience"})
require.NoError(t, err)
require.Len(t, conn, 1)
require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn)
})
t.Run("find by full url", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "https://knowhere.com/audience"})
require.NoError(t, err)
require.Len(t, conn, 1)
require.Equal(t, []*model.OutgoingOAuthConnection{connection}, conn)
})
t.Run("non-existent", func(t *testing.T) {
conn, err := ss.OutgoingOAuthConnection().GetConnections(c, model.OutgoingOAuthConnectionGetConnectionsFilter{Audience: "https://mattermost.com"})
require.NoError(t, err)
require.Empty(t, conn)
})
}
func testGetOutgoingOAuthConnectionByAudience(t *testing.T, ss store.Store) {
t.Run("get non-existing", func(t *testing.T) {
c := request.TestContext(t)
nonExistingId := model.NewId()
var expected *store.ErrNotFound
_, err := ss.OutgoingOAuthConnection().GetConnection(c, nonExistingId)
require.ErrorAs(t, err, &expected)
})
t.Run("get existing (single audience)", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
c := request.TestContext(t)
connection := newValidOutgoingOAuthConnection()
connection.Audiences = []string{"https://knowhere.com/audience"}
var err error
connection, err = ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
runAudienceTests(t, ss, connection)
})
t.Run("get existing (multiple audiences)", func(t *testing.T) {
t.Cleanup(cleanupOutgoingOAuthConnections(t, ss))
c := request.TestContext(t)
connection := newValidOutgoingOAuthConnection()
connection.Audiences = []string{"https://knowhere.com/audience", "https://example.com"}
var err error
connection, err = ss.OutgoingOAuthConnection().SaveConnection(c, connection)
require.NoError(t, err)
runAudienceTests(t, ss, connection)
})
}
func testGetOutgoingOAuthConnections(t *testing.T, ss store.Store) {
c := request.TestContext(t)

Просмотреть файл

@@ -74,6 +74,7 @@ func GetMockStoreForSetupFunctions() *mocks.Store {
systemStore.On("GetByName", model.MigrationKeyDeleteEmptyDrafts).Return(&model.System{Name: model.MigrationKeyDeleteEmptyDrafts, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyDeleteOrphanDrafts).Return(&model.System{Name: model.MigrationKeyDeleteOrphanDrafts, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddIPFilteringPermissions).Return(&model.System{Name: model.MigrationKeyAddIPFilteringPermissions, Value: "true"}, nil)
systemStore.On("GetByName", model.MigrationKeyAddOutgoingOAuthConnectionsPermissions).Return(&model.System{Name: model.MigrationKeyAddOutgoingOAuthConnectionsPermissions, Value: "true"}, nil)
systemStore.On("GetByName", "CustomGroupAdminRoleCreationMigrationComplete").Return(&model.System{Name: model.MigrationKeyAddPlayboosksManageRolesPermissions, Value: "true"}, nil)
systemStore.On("GetByName", "products_boards").Return(&model.System{Name: "products_boards", Value: "true"}, nil)
systemStore.On("GetByName", "elasticsearch_fix_channel_index_migration").Return(&model.System{Name: "elasticsearch_fix_channel_index_migration", Value: "true"}, nil)