From 81a1d725a00f3aa9d46168074e318eb2fab3a5e2 Mon Sep 17 00:00:00 2001 From: Felipe Martin <812088+fmartingr@users.noreply.github.com> Date: Tue, 26 Dec 2023 10:46:20 +0100 Subject: [PATCH] [WIP] [MM-55031] OAuth Outgoing Connection App integration (#25379) * OAuthOutgoingConnection model * 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 * merge migrations * renamed migrations * model change suggestions * refactor test functionsn * migration typo * refactor store table names * updated sanitize test * cleanup merge * refactor symbol * 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 * 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 * address comments * updated i18n --- api/Makefile | 1 + api/v4/source/definitions.yaml | 24 ++ api/v4/source/outgoing_oauth_connections.yaml | 52 +++ server/channels/api4/api.go | 7 + .../api4/outgoing_oauth_connection.go | 140 ++++++++ .../api4/outgoing_oauth_connection_test.go | 337 ++++++++++++++++++ server/channels/app/app.go | 5 +- server/channels/app/app_iface.go | 1 + server/channels/app/channels.go | 18 +- server/channels/app/enterprise.go | 6 + .../app/opentracing/opentracing_layer.go | 17 + server/channels/app/server.go | 5 +- server/channels/utils/license.go | 1 + server/channels/web/context.go | 11 + server/channels/web/params.go | 2 + .../mocks/OAuthOutgoingConnectionInterface.go | 159 +++++++++ .../mocks/OutgoingOAuthConnectionInterface.go | 169 +++++++++ .../einterfaces/outgoing_oauth_connection.go | 20 ++ server/i18n/en.json | 36 ++ server/public/model/client4.go | 38 ++ server/public/model/feature_flags.go | 3 + server/public/model/license.go | 6 + .../public/model/outgoing_oauth_connection.go | 27 +- .../model/outgoing_oauth_connection_test.go | 14 +- 24 files changed, 1074 insertions(+), 25 deletions(-) create mode 100644 api/v4/source/outgoing_oauth_connections.yaml create mode 100644 server/channels/api4/outgoing_oauth_connection.go create mode 100644 server/channels/api4/outgoing_oauth_connection_test.go create mode 100644 server/einterfaces/mocks/OAuthOutgoingConnectionInterface.go create mode 100644 server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go create mode 100644 server/einterfaces/outgoing_oauth_connection.go diff --git a/api/Makefile b/api/Makefile index 57e62eac21..f59c5ec36f 100644 --- a/api/Makefile +++ b/api/Makefile @@ -52,6 +52,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/ip_filters.yaml >> $(V4_YAML) @cat $(V4_SRC)/reports.yaml >> $(V4_YAML) @cat $(V4_SRC)/limits.yaml >> $(V4_YAML) + @cat $(V4_SRC)/outgoing_oauth_connections.yaml >> $(V4_YAML) @if [ -r $(PLAYBOOKS_SRC)/paths.yaml ]; then cat $(PLAYBOOKS_SRC)/paths.yaml >> $(V4_YAML); fi @if [ -r $(PLAYBOOKS_SRC)/merged-definitions.yaml ]; then cat $(PLAYBOOKS_SRC)/merged-definitions.yaml >> $(V4_YAML); else cat $(V4_SRC)/definitions.yaml >> $(V4_YAML); fi @echo Extracting code samples diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index fbbe43ceb9..22120ab5d1 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3580,6 +3580,30 @@ components: description: The number of active users in the server type: integer format: int64 + # Outgoing OAuth Connections + OutgoingOAuthConnectionGetItem: + type: object + properties: + id: + description: The unique identifier for the outgoing OAuth connection. + type: string + name: + description: The name of the outgoing OAuth connection. + type: string + create_at: + description: The time in milliseconds the outgoing OAuth connection was created. + type: integer + format: int64 + update_at: + description: The time in milliseconds the outgoing OAuth connection was last updated. + type: integer + format: int64 + grant_type: + description: The grant type of the outgoing OAuth connection. + type: string + audiences: + description: The audiences of the outgoing OAuth connection. + type: string externalDocs: description: Find out more about Mattermost url: 'https://about.mattermost.com' diff --git a/api/v4/source/outgoing_oauth_connections.yaml b/api/v4/source/outgoing_oauth_connections.yaml new file mode 100644 index 0000000000..c599dd7dbb --- /dev/null +++ b/api/v4/source/outgoing_oauth_connections.yaml @@ -0,0 +1,52 @@ + /api/v4/oauth/outgoing_connections: + get: + tags: + - oauth + - outgoing_connections + - outgoing_oauth_connections + summary: List all connections + description: > + List all outgoing OAuth connections. + + __Minimum server version__: 9.5 + operationId: ListOutgoingOAuthConnections + responses: + "200": + description: Successfully fetched outgoing OAuth connections + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/OutgoingOAuthConnectionGetItem" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" + /api/v4/oauth/outgoing_connections/{connection_id}: + get: + tags: + - oauth + - outgoing_connections + - outgoing_oauth_connections + summary: Get a connection + description: > + Retrieve an outgoing OAuth connection. + + __Minimum server version__: 9.5 + operationId: GetOutgoingOAuthConnection + responses: + "200": + description: Successfully fetched outgoing OAuth connection + content: + application/json: + schema: + $ref: "#/components/schemas/OutgoingOAuthConnectionGetItem" + "401": + $ref: "#/components/responses/Unauthorized" + "500": + $ref: "#/components/responses/InternalServerError" + "501": + $ref: "#/components/responses/NotImplemented" diff --git a/server/channels/api4/api.go b/server/channels/api4/api.go index f2997f8a7f..4505178e2f 100644 --- a/server/channels/api4/api.go +++ b/server/channels/api4/api.go @@ -143,6 +143,9 @@ type Routes struct { Reports *mux.Router // 'api/v4/reports' Limits *mux.Router // 'api/v4/limits' + + OutgoingOAuthConnections *mux.Router // 'api/v4/oauth/outgoing_connections' + OutgoingOAuthConnection *mux.Router // 'api/v4/oauth/outgoing_connections/{outgoing_oauth_connection_id:[A-Za-z0-9]+}' } type API struct { @@ -273,6 +276,9 @@ 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.InitUser() api.InitBot() api.InitTeam() @@ -319,6 +325,7 @@ func Init(srv *app.Server) (*API, error) { api.InitIPFiltering() api.InitReports() api.InitLimits() + api.InitOutgoingOAuthConnection() srv.Router.Handle("/api/v4/{anything:.*}", http.HandlerFunc(api.Handle404)) diff --git a/server/channels/api4/outgoing_oauth_connection.go b/server/channels/api4/outgoing_oauth_connection.go new file mode 100644 index 0000000000..b851cd21dd --- /dev/null +++ b/server/channels/api4/outgoing_oauth_connection.go @@ -0,0 +1,140 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/einterfaces" +) + +const ( + whereOutgoingOAuthConnection = "outgoingOAuthConnections" +) + +func (api *API) InitOutgoingOAuthConnection() { + api.BaseRoutes.OutgoingOAuthConnections.Handle("", api.APISessionRequired(listOutgoingOAuthConnections)).Methods("GET") + api.BaseRoutes.OutgoingOAuthConnection.Handle("", api.APISessionRequired(getOutgoingOAuthConnection)).Methods("GET") +} + +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) + return nil, false + } + + if c.App.OutgoingOAuthConnections() == nil || c.App.License() == nil || c.App.License().SkuShortName != model.LicenseShortSkuEnterprise { + c.Err = model.NewAppError(where, "api.license.upgrade_needed.app_error", nil, "", http.StatusNotImplemented) + return nil, false + } + return c.App.OutgoingOAuthConnections(), true +} + +type listOutgoingOAuthConnectionsQuery struct { + FromID string + Limit int +} + +// SetDefaults sets the default values for the query. +func (q *listOutgoingOAuthConnectionsQuery) SetDefaults() { + // Set default values + if q.Limit == 0 { + q.Limit = 10 + } +} + +// IsValid validates the query. +func (q *listOutgoingOAuthConnectionsQuery) IsValid() error { + if q.Limit < 1 || q.Limit > 100 { + return fmt.Errorf("limit must be between 1 and 100") + } + return nil +} + +// ToFilter converts the query to a filter that can be used to query the database. +func (q *listOutgoingOAuthConnectionsQuery) ToFilter() model.OutgoingOAuthConnectionGetConnectionsFilter { + return model.OutgoingOAuthConnectionGetConnectionsFilter{ + OffsetId: q.FromID, + Limit: q.Limit, + } +} + +func NewListOutgoingOAuthConnectionsQueryFromURLQuery(values url.Values) (*listOutgoingOAuthConnectionsQuery, error) { + query := &listOutgoingOAuthConnectionsQuery{} + query.SetDefaults() + + fromID := values.Get("from_id") + if fromID != "" { + query.FromID = fromID + } + + limit := values.Get("limit") + if limit != "" { + limitInt, err := strconv.Atoi(limit) + if err == nil { + return nil, err + } + query.Limit = limitInt + } + + return query, nil +} + +func listOutgoingOAuthConnections(c *Context, w http.ResponseWriter, r *http.Request) { + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) + if !ok { + return + } + + query, err := NewListOutgoingOAuthConnectionsQueryFromURLQuery(r.URL.Query()) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.input_error", nil, err.Error(), http.StatusBadRequest) + return + } + + if errValid := query.IsValid(); errValid != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.input_error", nil, errValid.Error(), http.StatusBadRequest) + 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 + } + + service.SanitizeConnections(connections) + + if errJSON := json.NewEncoder(w).Encode(connections); errJSON != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, errJSON.Error(), http.StatusInternalServerError) + return + } +} + +func getOutgoingOAuthConnection(c *Context, w http.ResponseWriter, r *http.Request) { + service, ok := ensureOutgoingOAuthConnectionInterface(c, whereOutgoingOAuthConnection) + if !ok { + return + } + + c.RequireOutgoingOAuthConnectionId() + + connection, err := service.GetConnection(c.AppContext, c.Params.OutgoingOAuthConnectionID) + if err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } + + service.SanitizeConnection(connection) + + if err := json.NewEncoder(w).Encode(connection); err != nil { + c.Err = model.NewAppError(whereOutgoingOAuthConnection, "api.context.outgoing_oauth_connection.list_connections.app_error", nil, err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/server/channels/api4/outgoing_oauth_connection_test.go b/server/channels/api4/outgoing_oauth_connection_test.go new file mode 100644 index 0000000000..83abefca51 --- /dev/null +++ b/server/channels/api4/outgoing_oauth_connection_test.go @@ -0,0 +1,337 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +package api4 + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/plugin/plugintest/mock" + "github.com/mattermost/mattermost/server/v8/channels/web" + "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" + "github.com/stretchr/testify/require" +) + +func newOutgoingOAuthConnection() *model.OutgoingOAuthConnection { + return &model.OutgoingOAuthConnection{ + Name: "test", + CreatorId: model.NewId(), + ClientId: "test", + ClientSecret: "test", + OAuthTokenURL: "http://localhost:9999/oauth/token", + GrantType: model.OutgoingOAuthConnectionGrantTypeClientCredentials, + Audiences: []string{"http://example.com"}, + } +} + +func outgoingOauthConnectionsCleanup(t *testing.T, th *TestHelper) { + t.Helper() + + // Remove all connections + conns, errCleanup := th.App.Srv().Store().OutgoingOAuthConnection().GetConnections(th.Context, model.OutgoingOAuthConnectionGetConnectionsFilter{}) + require.NoError(t, errCleanup) + + for _, c := range conns { + require.NoError(t, th.App.Srv().Store().OutgoingOAuthConnection().DeleteConnection(th.Context, c.Id)) + } +} + +// Client tests + +func TestOutgoingOAuthConnectionGet(t *testing.T) { + t.Run("No license returns 501", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTION", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTION") + th := Setup(t).InitBasic() + defer th.TearDown() + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + defer func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + }() + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + require.Error(t, err) + require.Nil(t, connections) + require.Equal(t, 501, response.StatusCode) + }) + + t.Run("license but no feature flag returns 501", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + defer func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + }() + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + th.App.Srv().RemoveLicense() + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + require.Error(t, err) + require.Nil(t, connections) + require.Equal(t, 501, response.StatusCode) + }) +} + +func TestListOutgoingOAutConnection(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + + t.Run("empty", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", mock.Anything, mock.Anything).Return([]*model.OutgoingOAuthConnection{}, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + require.NoError(t, err) + + require.Equal(t, 200, response.StatusCode) + require.Equal(t, 0, len(connections)) + }) + + t.Run("return result", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + conn := newOutgoingOAuthConnection() + + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", mock.Anything, mock.Anything).Return([]*model.OutgoingOAuthConnection{conn}, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + connections, response, err := th.Client.GetOutgoingOAuthConnections(context.Background(), "", 10) + require.NoError(t, err) + + require.Equal(t, 200, response.StatusCode) + require.Equal(t, 1, len(connections)) + require.Equal(t, conn, connections[0]) + }) +} + +func TestGetOutgoingOauthConnection(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + defer th.App.Srv().RemoveLicense() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + + t.Run("return result", func(t *testing.T) { + defer outgoingOauthConnectionsCleanup(t, th) + + conn := newOutgoingOAuthConnection() + + conn, err := th.App.Srv().Store().OutgoingOAuthConnection().SaveConnection(th.Context, conn) + require.NoError(t, err) + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + outgoingOauthIface.Mock.On("GetConnection", mock.Anything, mock.Anything).Return(conn, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + outgoingOauthImpl := th.App.Srv().OutgoingOAuthConnection + defer func() { + th.App.Srv().OutgoingOAuthConnection = outgoingOauthImpl + }() + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + + th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) + + connection, response, err := th.Client.GetOutgoingOAuthConnection(context.Background(), conn.Id) + require.NoError(t, err) + + require.Equal(t, 200, response.StatusCode) + require.NotNil(t, connection) + require.Equal(t, conn.Id, connection.Id) + require.Equal(t, conn, connection) + }) +} + +// API tests + +func TestEnsureOutgoingOAuthConnectionInterface(t *testing.T) { + t.Run("no feature flag, no interface, no license", func(t *testing.T) { + th := Setup(t).InitBasic() + defer th.TearDown() + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.App.Srv().OutgoingOAuthConnection = nil + + _, valid := ensureOutgoingOAuthConnectionInterface(c, "api") + require.False(t, valid) + }) + + t.Run("feature flag, no interface, no license", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.App.Srv().OutgoingOAuthConnection = nil + + _, valid := ensureOutgoingOAuthConnectionInterface(c, "api") + require.False(t, valid) + }) + + t.Run("feature flag, interface defined, no license", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + th.App.Srv().OutgoingOAuthConnection = &mocks.OutgoingOAuthConnectionInterface{} + + _, valid := ensureOutgoingOAuthConnectionInterface(c, "api") + require.False(t, valid) + }) + + t.Run("feature flag, interface defined, valid license", func(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + defer th.App.Srv().RemoveLicense() + + th.App.Srv().OutgoingOAuthConnection = &mocks.OutgoingOAuthConnectionInterface{} + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + svc, valid := ensureOutgoingOAuthConnectionInterface(c, "api") + require.True(t, valid) + require.NotNil(t, svc) + }) +} + +func TestOutgoingOAuthConnectionAPIHandlers(t *testing.T) { + os.Setenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS", "true") + defer os.Unsetenv("MM_FEATUREFLAGS_OUTGOINGOAUTHCONNECTIONS") + th := Setup(t).InitBasic() + defer th.TearDown() + + license := model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise, "outgoing_oauth_connections") + license.Id = "test-license-id" + th.App.Srv().SetLicense(license) + defer th.App.Srv().RemoveLicense() + + c := &Context{} + c.AppContext = th.Context + c.App = th.App + c.Logger = th.App.Srv().Log() + + conn := newOutgoingOAuthConnection() + + t.Run("getOutgoingOAuthConnection", func(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Error(err) + } + + c.Params = &web.Params{ + OutgoingOAuthConnectionID: conn.Id, + } + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnection", th.Context, c.Params.OutgoingOAuthConnectionID).Return(conn, nil) + outgoingOauthIface.Mock.On("SanitizeConnection", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + getOutgoingOAuthConnection(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(conn)) + }) + + t.Run("listOutgoingOAuthConnections", func(t *testing.T) { + req, err := http.NewRequest("GET", "/", nil) + if err != nil { + t.Error(err) + } + + conns := []*model.OutgoingOAuthConnection{conn} + + outgoingOauthIface := &mocks.OutgoingOAuthConnectionInterface{} + th.App.Srv().OutgoingOAuthConnection = outgoingOauthIface + outgoingOauthIface.Mock.On("GetConnections", th.Context, mock.Anything).Return(conns, nil) + outgoingOauthIface.Mock.On("SanitizeConnections", mock.Anything) + + httpRecorder := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + listOutgoingOAuthConnections(c, w, r) + }) + + handler.ServeHTTP(httpRecorder, req) + + require.Equal(t, http.StatusOK, httpRecorder.Code) + require.NotEmpty(t, httpRecorder.Body.String()) + + var buf bytes.Buffer + require.NoError(t, json.NewEncoder(&buf).Encode(conn)) + }) +} diff --git a/server/channels/app/app.go b/server/channels/app/app.go index e0b9b9bf2f..736e9b5046 100644 --- a/server/channels/app/app.go +++ b/server/channels/app/app.go @@ -103,11 +103,12 @@ func (a *App) Saml() einterfaces.SamlInterface { func (a *App) Cloud() einterfaces.CloudInterface { return a.ch.srv.Cloud } - func (a *App) IPFiltering() einterfaces.IPFilteringInterface { return a.ch.srv.IPFiltering } - +func (a *App) OutgoingOAuthConnections() einterfaces.OutgoingOAuthConnectionInterface { + return a.ch.srv.OutgoingOAuthConnection +} func (a *App) HTTPService() httpservice.HTTPService { return a.ch.srv.httpService } diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index f6cf66c541..8ae77a4117 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -946,6 +946,7 @@ type AppIface interface { NotifySharedChannelUserUpdate(user *model.User) OpenInteractiveDialog(request model.OpenDialogRequest) *model.AppError OriginChecker() func(*http.Request) bool + OutgoingOAuthConnections() einterfaces.OutgoingOAuthConnectionInterface PatchChannel(c request.CTX, channel *model.Channel, patch *model.ChannelPatch, userID string) (*model.Channel, *model.AppError) PatchPost(c request.CTX, postID string, patch *model.PostPatch) (*model.Post, *model.AppError) PatchRetentionPolicy(patch *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index a4721e1c4e..bafff9a97e 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -60,13 +60,14 @@ 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 - Ldap einterfaces.LdapInterface + AccountMigration einterfaces.AccountMigrationInterface + Compliance einterfaces.ComplianceInterface + DataRetention einterfaces.DataRetentionInterface + MessageExport einterfaces.MessageExportInterface + Saml einterfaces.SamlInterface + Notification einterfaces.NotificationInterface + OutgoingOAuthConnection einterfaces.OutgoingOAuthConnectionInterface + Ldap einterfaces.LdapInterface // These are used to prevent concurrent upload requests // for a given upload session which could cause inconsistencies @@ -176,6 +177,9 @@ 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 { diff --git a/server/channels/app/enterprise.go b/server/channels/app/enterprise.go index ae3cb01e95..277b5bfc68 100644 --- a/server/channels/app/enterprise.go +++ b/server/channels/app/enterprise.go @@ -92,6 +92,12 @@ func RegisterNotificationInterface(f func(*App) einterfaces.NotificationInterfac notificationInterface = f } +var outgoingOauthConnectionInterface func(*App) einterfaces.OutgoingOAuthConnectionInterface + +func RegisterOutgoingOAuthConnectionInterface(f func(*App) einterfaces.OutgoingOAuthConnectionInterface) { + outgoingOauthConnectionInterface = f +} + var ipFilteringInterface func(*App) einterfaces.IPFilteringInterface func RegisterIPFilteringInterface(f func(*App) einterfaces.IPFilteringInterface) { diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 6980d0310a..c2fdaf157b 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -12877,6 +12877,23 @@ func (a *OpenTracingAppLayer) OriginChecker() func(*http.Request) bool { return resultVar0 } +func (a *OpenTracingAppLayer) OutgoingOAuthConnections() einterfaces.OutgoingOAuthConnectionInterface { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.OutgoingOAuthConnections") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0 := a.app.OutgoingOAuthConnections() + + return resultVar0 +} + func (a *OpenTracingAppLayer) OverrideIconURLIfEmoji(c request.CTX, post *model.Post) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.OverrideIconURLIfEmoji") diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 07e279ec6e..48e2d56ef8 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -143,8 +143,9 @@ type Server struct { // startSearchEngine bool skipPostInit bool - Cloud einterfaces.CloudInterface - IPFiltering einterfaces.IPFilteringInterface + Cloud einterfaces.CloudInterface + IPFiltering einterfaces.IPFilteringInterface + OutgoingOAuthConnection einterfaces.OutgoingOAuthConnectionInterface tracer *tracing.Tracer diff --git a/server/channels/utils/license.go b/server/channels/utils/license.go index 17956b8958..b2e51ab7d0 100644 --- a/server/channels/utils/license.go +++ b/server/channels/utils/license.go @@ -196,6 +196,7 @@ func GetClientLicense(l *model.License) map[string]string { props["Cloud"] = strconv.FormatBool(*l.Features.Cloud) props["SharedChannels"] = strconv.FormatBool(*l.Features.SharedChannels) props["RemoteClusterService"] = strconv.FormatBool(*l.Features.RemoteClusterService) + props["OutgoingOAuthConnections"] = strconv.FormatBool(*l.Features.OutgoingOAuthConnections) props["IsTrial"] = strconv.FormatBool(l.IsTrial) props["IsGovSku"] = strconv.FormatBool(l.IsGovSku) } diff --git a/server/channels/web/context.go b/server/channels/web/context.go index 9793620ef0..b2b12aeba8 100644 --- a/server/channels/web/context.go +++ b/server/channels/web/context.go @@ -453,6 +453,17 @@ func (c *Context) RequireAppId() *Context { return c } +func (c *Context) RequireOutgoingOAuthConnectionId() *Context { + if c.Err != nil { + return c + } + + if !model.IsValidId(c.Params.OutgoingOAuthConnectionID) { + c.SetInvalidURLParam("outgoing_oauth_connection_id") + } + return c +} + func (c *Context) RequireFileId() *Context { if c.Err != nil { return c diff --git a/server/channels/web/params.go b/server/channels/web/params.go index a985d2dde9..66755d1246 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -93,6 +93,7 @@ type Params struct { GroupSource model.GroupSource FilterHasMember string IncludeChannelMemberCount string + OutgoingOAuthConnectionID string // Cloud InvoiceId string @@ -145,6 +146,7 @@ func ParamsFromRequest(r *http.Request) *Params { params.GroupId = props["group_id"] params.RemoteId = props["remote_id"] params.InvoiceId = props["invoice_id"] + params.OutgoingOAuthConnectionID = props["outgoing_oauth_connection_id"] params.Scope = query.Get("scope") if val, err := strconv.Atoi(query.Get("page")); err != nil || val < 0 { diff --git a/server/einterfaces/mocks/OAuthOutgoingConnectionInterface.go b/server/einterfaces/mocks/OAuthOutgoingConnectionInterface.go new file mode 100644 index 0000000000..f323e96d6a --- /dev/null +++ b/server/einterfaces/mocks/OAuthOutgoingConnectionInterface.go @@ -0,0 +1,159 @@ +// Code generated by mockery v2.23.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// OAuthOutgoingConnectionInterface is an autogenerated mock type for the OAuthOutgoingConnectionInterface type +type OAuthOutgoingConnectionInterface struct { + mock.Mock +} + +// DeleteConnection provides a mock function with given fields: rctx, id +func (_m *OAuthOutgoingConnectionInterface) DeleteConnection(rctx request.CTX, id string) *model.AppError { + ret := _m.Called(rctx, id) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// GetConnection provides a mock function with given fields: rctx, id +func (_m *OAuthOutgoingConnectionInterface) GetConnection(rctx request.CTX, id string) (*model.OutgoingOAuthConnectionGrantType, *model.AppError) { + ret := _m.Called(rctx, id) + + var r0 *model.OutgoingOAuthConnectionGrantType + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.OutgoingOAuthConnectionGrantType, *model.AppError)); ok { + return rf(rctx, id) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.OutgoingOAuthConnectionGrantType); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnectionGrantType) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetConnections provides a mock function with given fields: rctx, filters +func (_m *OAuthOutgoingConnectionInterface) GetConnections(rctx request.CTX, filters model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnectionGrantType, *model.AppError) { + ret := _m.Called(rctx, filters) + + var r0 []*model.OutgoingOAuthConnectionGrantType + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnectionGrantType, *model.AppError)); ok { + return rf(rctx, filters) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.OutgoingOAuthConnectionGetConnectionsFilter) []*model.OutgoingOAuthConnectionGrantType); ok { + r0 = rf(rctx, filters) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.OutgoingOAuthConnectionGrantType) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.OutgoingOAuthConnectionGetConnectionsFilter) *model.AppError); ok { + r1 = rf(rctx, filters) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// SaveConnection provides a mock function with given fields: rctx, conn +func (_m *OAuthOutgoingConnectionInterface) SaveConnection(rctx request.CTX, conn *model.OutgoingOAuthConnectionGrantType) (*model.OutgoingOAuthConnectionGrantType, *model.AppError) { + ret := _m.Called(rctx, conn) + + var r0 *model.OutgoingOAuthConnectionGrantType + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnectionGrantType) (*model.OutgoingOAuthConnectionGrantType, *model.AppError)); ok { + return rf(rctx, conn) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnectionGrantType) *model.OutgoingOAuthConnectionGrantType); ok { + r0 = rf(rctx, conn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnectionGrantType) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.OutgoingOAuthConnectionGrantType) *model.AppError); ok { + r1 = rf(rctx, conn) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// UpdateConnection provides a mock function with given fields: rctx, conn +func (_m *OAuthOutgoingConnectionInterface) UpdateConnection(rctx request.CTX, conn *model.OutgoingOAuthConnectionGrantType) (*model.OutgoingOAuthConnectionGrantType, *model.AppError) { + ret := _m.Called(rctx, conn) + + var r0 *model.OutgoingOAuthConnectionGrantType + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnectionGrantType) (*model.OutgoingOAuthConnectionGrantType, *model.AppError)); ok { + return rf(rctx, conn) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnectionGrantType) *model.OutgoingOAuthConnectionGrantType); ok { + r0 = rf(rctx, conn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnectionGrantType) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.OutgoingOAuthConnectionGrantType) *model.AppError); ok { + r1 = rf(rctx, conn) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +type mockConstructorTestingTNewOAuthOutgoingConnectionInterface interface { + mock.TestingT + Cleanup(func()) +} + +// NewOAuthOutgoingConnectionInterface creates a new instance of OAuthOutgoingConnectionInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewOAuthOutgoingConnectionInterface(t mockConstructorTestingTNewOAuthOutgoingConnectionInterface) *OAuthOutgoingConnectionInterface { + mock := &OAuthOutgoingConnectionInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go b/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go new file mode 100644 index 0000000000..840fc79a71 --- /dev/null +++ b/server/einterfaces/mocks/OutgoingOAuthConnectionInterface.go @@ -0,0 +1,169 @@ +// Code generated by mockery v2.23.2. DO NOT EDIT. + +// Regenerate this file using `make einterfaces-mocks`. + +package mocks + +import ( + model "github.com/mattermost/mattermost/server/public/model" + request "github.com/mattermost/mattermost/server/public/shared/request" + mock "github.com/stretchr/testify/mock" +) + +// OutgoingOAuthConnectionInterface is an autogenerated mock type for the OutgoingOAuthConnectionInterface type +type OutgoingOAuthConnectionInterface struct { + mock.Mock +} + +// DeleteConnection provides a mock function with given fields: rctx, id +func (_m *OutgoingOAuthConnectionInterface) DeleteConnection(rctx request.CTX, id string) *model.AppError { + ret := _m.Called(rctx, id) + + var r0 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.AppError); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.AppError) + } + } + + return r0 +} + +// GetConnection provides a mock function with given fields: rctx, id +func (_m *OutgoingOAuthConnectionInterface) GetConnection(rctx request.CTX, id string) (*model.OutgoingOAuthConnection, *model.AppError) { + ret := _m.Called(rctx, id) + + var r0 *model.OutgoingOAuthConnection + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, string) (*model.OutgoingOAuthConnection, *model.AppError)); ok { + return rf(rctx, id) + } + if rf, ok := ret.Get(0).(func(request.CTX, string) *model.OutgoingOAuthConnection); ok { + r0 = rf(rctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnection) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, string) *model.AppError); ok { + r1 = rf(rctx, id) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// GetConnections provides a mock function with given fields: rctx, filters +func (_m *OutgoingOAuthConnectionInterface) GetConnections(rctx request.CTX, filters model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnection, *model.AppError) { + ret := _m.Called(rctx, filters) + + var r0 []*model.OutgoingOAuthConnection + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnection, *model.AppError)); ok { + return rf(rctx, filters) + } + if rf, ok := ret.Get(0).(func(request.CTX, model.OutgoingOAuthConnectionGetConnectionsFilter) []*model.OutgoingOAuthConnection); ok { + r0 = rf(rctx, filters) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*model.OutgoingOAuthConnection) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, model.OutgoingOAuthConnectionGetConnectionsFilter) *model.AppError); ok { + r1 = rf(rctx, filters) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// SanitizeConnection provides a mock function with given fields: conn +func (_m *OutgoingOAuthConnectionInterface) SanitizeConnection(conn *model.OutgoingOAuthConnection) { + _m.Called(conn) +} + +// SanitizeConnections provides a mock function with given fields: conns +func (_m *OutgoingOAuthConnectionInterface) SanitizeConnections(conns []*model.OutgoingOAuthConnection) { + _m.Called(conns) +} + +// SaveConnection provides a mock function with given fields: rctx, conn +func (_m *OutgoingOAuthConnectionInterface) SaveConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, *model.AppError) { + ret := _m.Called(rctx, conn) + + var r0 *model.OutgoingOAuthConnection + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, *model.AppError)); ok { + return rf(rctx, conn) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) *model.OutgoingOAuthConnection); ok { + r0 = rf(rctx, conn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnection) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.OutgoingOAuthConnection) *model.AppError); ok { + r1 = rf(rctx, conn) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +// UpdateConnection provides a mock function with given fields: rctx, conn +func (_m *OutgoingOAuthConnectionInterface) UpdateConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, *model.AppError) { + ret := _m.Called(rctx, conn) + + var r0 *model.OutgoingOAuthConnection + var r1 *model.AppError + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, *model.AppError)); ok { + return rf(rctx, conn) + } + if rf, ok := ret.Get(0).(func(request.CTX, *model.OutgoingOAuthConnection) *model.OutgoingOAuthConnection); ok { + r0 = rf(rctx, conn) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.OutgoingOAuthConnection) + } + } + + if rf, ok := ret.Get(1).(func(request.CTX, *model.OutgoingOAuthConnection) *model.AppError); ok { + r1 = rf(rctx, conn) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*model.AppError) + } + } + + return r0, r1 +} + +type mockConstructorTestingTNewOutgoingOAuthConnectionInterface interface { + mock.TestingT + Cleanup(func()) +} + +// NewOutgoingOAuthConnectionInterface creates a new instance of OutgoingOAuthConnectionInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewOutgoingOAuthConnectionInterface(t mockConstructorTestingTNewOutgoingOAuthConnectionInterface) *OutgoingOAuthConnectionInterface { + mock := &OutgoingOAuthConnectionInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/einterfaces/outgoing_oauth_connection.go b/server/einterfaces/outgoing_oauth_connection.go new file mode 100644 index 0000000000..b9264e1a26 --- /dev/null +++ b/server/einterfaces/outgoing_oauth_connection.go @@ -0,0 +1,20 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package einterfaces + +import ( + "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/request" +) + +type OutgoingOAuthConnectionInterface interface { + DeleteConnection(rctx request.CTX, id string) *model.AppError + GetConnection(rctx request.CTX, id string) (*model.OutgoingOAuthConnection, *model.AppError) + GetConnections(rctx request.CTX, filters model.OutgoingOAuthConnectionGetConnectionsFilter) ([]*model.OutgoingOAuthConnection, *model.AppError) + SaveConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, *model.AppError) + UpdateConnection(rctx request.CTX, conn *model.OutgoingOAuthConnection) (*model.OutgoingOAuthConnection, *model.AppError) + + SanitizeConnection(conn *model.OutgoingOAuthConnection) + SanitizeConnections(conns []*model.OutgoingOAuthConnection) +} diff --git a/server/i18n/en.json b/server/i18n/en.json index e29285a99b..d3e5a6f1c4 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1657,6 +1657,18 @@ "id": "api.context.mfa_required.app_error", "translation": "Multi-factor authentication is required on this server." }, + { + "id": "api.context.outgoing_oauth_connection.list_connections.app_error", + "translation": "There was an error while listing outgoing OAuth connections." + }, + { + "id": "api.context.outgoing_oauth_connection.list_connections.input_error", + "translation": "Invalid input parameters." + }, + { + "id": "api.context.outgoing_oauth_connection.not_available.feature_flag", + "translation": "This feature is restricted by a feature flag." + }, { "id": "api.context.permissions.app_error", "translation": "You do not have the appropriate permissions." @@ -8158,6 +8170,30 @@ "id": "ent.migration.migratetosaml.username_already_used_by_other_user", "translation": "Username already used by another Mattermost user." }, + { + "id": "ent.outgoing_oauth_connections.delete_connection.app_error", + "translation": "There was an error while deleting the outgoing oauth connection." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection.app_error", + "translation": "There was an error retrieving the outgoing oauth connection." + }, + { + "id": "ent.outgoing_oauth_connections.get_connection.not_found.app_error", + "translation": "The outgoing oauth connection was not found." + }, + { + "id": "ent.outgoing_oauth_connections.get_connections.app_error", + "translation": "There was an error retrieving the outgoing oauth connections." + }, + { + "id": "ent.outgoing_oauth_connections.save_connection.app_error", + "translation": "There was an error saving the outgoing oauth connection." + }, + { + "id": "ent.outgoing_oauth_connections.update_connection.app_error", + "translation": "There was an error updating the outgoing oauth connection." + }, { "id": "ent.saml.attribute.app_error", "translation": "SAML login was unsuccessful because one of the attributes is incorrect. Please contact your System Administrator." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 0b5dccbb29..08ef2ac0ad 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -471,6 +471,14 @@ func (c *Client4) oAuthAppRoute(appId string) string { return fmt.Sprintf("/oauth/apps/%v", appId) } +func (c *Client4) outgoingOAuthConnectionsRoute() string { + return "/oauth/outgoing_connections" +} + +func (c *Client4) outgoingOAuthConnectionRoute(id string) string { + return fmt.Sprintf("/oauth/outgoing_connections/%s", id) +} + func (c *Client4) jobsRoute() string { return "/jobs" } @@ -6003,6 +6011,36 @@ func (c *Client4) GetOAuthAccessToken(ctx context.Context, data url.Values) (*Ac return ar, BuildResponse(rp), nil } +// OutgoingOAuthConnection section + +// GetOutgoingOAuthConnections retrieves the outgoing OAuth connections. +func (c *Client4) GetOutgoingOAuthConnections(ctx context.Context, fromID string, limit int) ([]*OutgoingOAuthConnection, *Response, error) { + r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionsRoute(), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var connections []*OutgoingOAuthConnection + if err := json.NewDecoder(r.Body).Decode(&connections); err != nil { + return nil, nil, NewAppError("GetOutgoingOAuthConnections", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return connections, BuildResponse(r), nil +} + +// GetOutgoingOAuthConnection retrieves the outgoing OAuth connection with the given ID. +func (c *Client4) GetOutgoingOAuthConnection(ctx context.Context, id string) (*OutgoingOAuthConnection, *Response, error) { + r, err := c.DoAPIGet(ctx, c.outgoingOAuthConnectionRoute(id), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + var connection *OutgoingOAuthConnection + if err := json.NewDecoder(r.Body).Decode(&connection); err != nil { + return nil, nil, NewAppError("GetOutgoingOAuthConnection", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return connection, BuildResponse(r), nil +} + // Elasticsearch Section // TestElasticsearch will attempt to connect to the configured Elasticsearch server and return OK if configured. diff --git a/server/public/model/feature_flags.go b/server/public/model/feature_flags.go index bc4ac21ec9..4adcd9ebf8 100644 --- a/server/public/model/feature_flags.go +++ b/server/public/model/feature_flags.go @@ -50,6 +50,8 @@ type FeatureFlags struct { ConsumePostHook bool CloudAnnualRenewals bool + + OutgoingOAuthConnections bool } func (f *FeatureFlags) SetDefaults() { @@ -69,6 +71,7 @@ func (f *FeatureFlags) SetDefaults() { f.CloudIPFiltering = false f.ConsumePostHook = false f.CloudAnnualRenewals = false + f.OutgoingOAuthConnections = false } // ToMap returns the feature flags as a map[string]string diff --git a/server/public/model/license.go b/server/public/model/license.go index 53742b9345..b4a08d8578 100644 --- a/server/public/model/license.go +++ b/server/public/model/license.go @@ -158,6 +158,7 @@ type Features struct { Cloud *bool `json:"cloud"` SharedChannels *bool `json:"shared_channels"` RemoteClusterService *bool `json:"remote_cluster_service"` + OutgoingOAuthConnections *bool `json:"outgoing_oauth_connections"` // after we enabled more features we'll need to control them with this FutureFeatures *bool `json:"future_features"` @@ -191,6 +192,7 @@ func (f *Features) ToMap() map[string]any { "shared_channels": *f.SharedChannels, "remote_cluster_service": *f.RemoteClusterService, "future": *f.FutureFeatures, + "outgoing_oauth_connections": *f.OutgoingOAuthConnections, } } @@ -314,6 +316,10 @@ func (f *Features) SetDefaults() { if f.RemoteClusterService == nil { f.RemoteClusterService = NewBool(*f.FutureFeatures) } + + if f.OutgoingOAuthConnections == nil { + f.OutgoingOAuthConnections = NewBool(*f.FutureFeatures) + } } func (l *License) IsExpired() bool { diff --git a/server/public/model/outgoing_oauth_connection.go b/server/public/model/outgoing_oauth_connection.go index 62acf36648..30afc29665 100644 --- a/server/public/model/outgoing_oauth_connection.go +++ b/server/public/model/outgoing_oauth_connection.go @@ -27,8 +27,8 @@ type OutgoingOAuthConnection struct { CreateAt int64 `json:"create_at"` UpdateAt int64 `json:"update_at"` Name string `json:"name"` - ClientId string `json:"client_id"` - ClientSecret string `json:"client_secret"` + ClientId string `json:"client_id,omitempty"` + ClientSecret string `json:"client_secret,omitempty"` CredentialsUsername *string `json:"credentials_username,omitempty"` CredentialsPassword *string `json:"credentials_password,omitempty"` OAuthTokenURL string `json:"oauth_token_url"` @@ -47,6 +47,14 @@ func (oa *OutgoingOAuthConnection) Auditable() map[string]interface{} { } } +// Sanitize removes any sensitive fields from the OutgoingOAuthConnection object. +func (oa *OutgoingOAuthConnection) Sanitize() { + oa.ClientId = "" + oa.ClientSecret = "" + oa.CredentialsUsername = nil + oa.CredentialsPassword = nil +} + // IsValid validates the object and returns an error if it isn't properly configured func (oa *OutgoingOAuthConnection) IsValid() *AppError { if !IsValidId(oa.Id) { @@ -65,19 +73,19 @@ func (oa *OutgoingOAuthConnection) IsValid() *AppError { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.creator_id.error", nil, "id="+oa.Id, http.StatusBadRequest) } - if utf8.RuneCountInString(oa.Name) > 64 { + if oa.Name == "" || utf8.RuneCountInString(oa.Name) > 64 { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.name.error", nil, "id="+oa.Id, http.StatusBadRequest) } - if len(oa.ClientId) == 0 || utf8.RuneCountInString(oa.ClientId) > 255 { + if oa.ClientId == "" || utf8.RuneCountInString(oa.ClientId) > 255 { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.client_id.error", nil, "id="+oa.Id, http.StatusBadRequest) } - if len(oa.ClientSecret) == 0 || utf8.RuneCountInString(oa.ClientSecret) > 255 { + if oa.ClientSecret == "" || utf8.RuneCountInString(oa.ClientSecret) > 255 { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.client_secret.error", nil, "id="+oa.Id, http.StatusBadRequest) } - if len(oa.OAuthTokenURL) == 0 || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 { + if oa.OAuthTokenURL == "" || utf8.RuneCountInString(oa.OAuthTokenURL) > 256 { return NewAppError("OutgoingOAuthConnection.IsValid", "model.outgoing_oauth_connection.is_valid.oauth_token_url.error", nil, "id="+oa.Id, http.StatusBadRequest) } @@ -137,13 +145,6 @@ func (oa *OutgoingOAuthConnection) Etag() string { return Etag(oa.Id, oa.UpdateAt) } -// Sanitize removes any sensitive fields from the OutgoingOAuthConnection object. -func (oa *OutgoingOAuthConnection) Sanitize() { - oa.ClientSecret = "" - oa.CredentialsUsername = nil - oa.CredentialsPassword = nil -} - // OutgoingOAuthConnectionGetConnectionsFilter is used to filter outgoing connections type OutgoingOAuthConnectionGetConnectionsFilter struct { OffsetId string diff --git a/server/public/model/outgoing_oauth_connection_test.go b/server/public/model/outgoing_oauth_connection_test.go index 1a67c66ee4..a46930c4e8 100644 --- a/server/public/model/outgoing_oauth_connection_test.go +++ b/server/public/model/outgoing_oauth_connection_test.go @@ -19,7 +19,7 @@ func newValidOutgoingOAuthConnection() *OutgoingOAuthConnection { ClientId: NewId(), ClientSecret: NewId(), OAuthTokenURL: "https://nowhere.com/oauth/token", - GrantType: "client_credentials", + GrantType: OutgoingOAuthConnectionGrantTypeClientCredentials, CreateAt: GetMillis(), UpdateAt: GetMillis(), Audiences: []string{"https://nowhere.com"}, @@ -52,6 +52,17 @@ func TestOutgoingOAuthConnectionIsValid(t *testing.T) { require.Error(t, oa.IsValid()) }, }, + { + name: "empty name", + item: func() *OutgoingOAuthConnection { + oa := newValidOutgoingOAuthConnection() + oa.Name = "" + return oa + }, + assert: func(t *testing.T, oa *OutgoingOAuthConnection) { + require.Error(t, oa.IsValid()) + }, + }, { name: "invalid create_at", item: func() *OutgoingOAuthConnection { @@ -307,6 +318,7 @@ func TestOutgoingOAuthConnectionSanitize(t *testing.T) { oa := newValidOutgoingOAuthConnection() oa.Sanitize() + require.Empty(t, oa.ClientId) require.Empty(t, oa.ClientSecret) require.Empty(t, oa.CredentialsUsername) require.Empty(t, oa.CredentialsPassword)