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)
}

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