diff --git a/api/Makefile b/api/Makefile index ea4a2abc529..ef9aa0a5c32 100644 --- a/api/Makefile +++ b/api/Makefile @@ -40,6 +40,7 @@ build-v4: node_modules playbooks @cat $(V4_SRC)/roles.yaml >> $(V4_YAML) @cat $(V4_SRC)/schemes.yaml >> $(V4_YAML) @cat $(V4_SRC)/service_terms.yaml >> $(V4_YAML) + @cat $(V4_SRC)/remoteclusters.yaml >> $(V4_YAML) @cat $(V4_SRC)/sharedchannels.yaml >> $(V4_YAML) @cat $(V4_SRC)/reactions.yaml >> $(V4_YAML) @cat $(V4_SRC)/actions.yaml >> $(V4_YAML) diff --git a/api/v4/source/definitions.yaml b/api/v4/source/definitions.yaml index 54b8af99900..77c9c6db0da 100644 --- a/api/v4/source/definitions.yaml +++ b/api/v4/source/definitions.yaml @@ -3485,6 +3485,39 @@ components: remote_id: description: Id of the remote cluster where the shared channel is homed type: string + RemoteCluster: + type: object + properties: + remote_id: + type: string + remote_team_id: + type: string + name: + type: string + display_name: + type: string + site_url: + description: URL of the remote cluster + type: string + create_at: + description: Time in milliseconds that the remote cluster was created + type: integer + last_ping_at: + description: Time in milliseconds when the last ping to the remote cluster was run + type: integer + token: + type: string + remote_token: + type: string + topics: + type: string + creator_id: + type: string + plugin_id: + type: string + options: + description: A bitmask with a set of option flags + type: integer RemoteClusterInfo: type: object properties: diff --git a/api/v4/source/introduction.yaml b/api/v4/source/introduction.yaml index f0483662e57..dede6a3867b 100644 --- a/api/v4/source/introduction.yaml +++ b/api/v4/source/introduction.yaml @@ -598,6 +598,7 @@ x-tagGroups: - roles - schemes - integration_actions + - remote clusters - shared channels - terms of service - imports diff --git a/api/v4/source/remoteclusters.yaml b/api/v4/source/remoteclusters.yaml new file mode 100644 index 00000000000..6e0d843fdc6 --- /dev/null +++ b/api/v4/source/remoteclusters.yaml @@ -0,0 +1,282 @@ + "/api/v4/remotecluster": + get: + tags: + - remote clusters + summary: Get a list of remote clusters. + description: | + Get a list of remote clusters. + + ##### Permissions + `manage_secure_connections` + operationId: GetRemoteClusters + parameters: + - name: page + in: query + description: The page to select + schema: + type: integer + - name: per_page + in: query + description: The number of remote clusters per page + schema: + type: integer + - name: exclude_offline + in: query + description: Exclude offline remote clusters + schema: + type: boolean + - name: in_channel + in: query + description: Select remote clusters in channel + schema: + type: string + - name: not_in_channel + in: query + description: Select remote clusters not in this channel + schema: + type: string + - name: only_confirmed + in: query + description: Select only remote clusters already confirmed + schema: + type: boolean + - name: only_plugins + in: query + description: Select only remote clusters that belong to a plugin + schema: + type: boolean + - name: exclude_plugins + in: query + description: Select only remote clusters that don't belong to a plugin + schema: + type: boolean + responses: + "200": + description: Remote clusters fetch successful. Result might be empty. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/RemoteCluster" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + post: + tags: + - remote clusters + summary: Create a new remote cluster. + description: | + Create a new remote cluster and generate an invite code. + + ##### Permissions + `manage_secure_connections` + operationId: CreateRemoteCluster + requestBody: + content: + application/json: + schema: + type: object + required: + - name + - password + properties: + name: + type: string + display_name: + type: string + password: + type: string + description: The password to use in the invite code. + responses: + "201": + description: Remote cluster creation successful + content: + application/json: + schema: + type: object + properties: + remote_cluster: + $ref: "#/components/schemas/RemoteCluster" + invite: + type: string + description: The encrypted invite for the newly created remote cluster + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + "/api/v4/remotecluster/{remote_id}": + get: + tags: + - remote clusters + summary: Get a remote cluster. + description: | + Get the Remote Cluster details from the provided id string. + + ##### Permissions + `manage_secure_connections` + operationId: GetRemoteCluster + parameters: + - name: remote_id + in: path + description: Remote Cluster GUID + required: true + schema: + type: string + responses: + "200": + description: Remote Cluster retrieval successful + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteCluster" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + patch: + tags: + - remote clusters + summary: Patch a remote cluster. + description: | + Partially update a Remote Cluster by providing only the fields you want to update. Ommited fields will not be updated. + + ##### Permissions + `manage_secure_connections` + operationId: PatchRemoteCluster + parameters: + - name: remote_id + in: path + description: Remote Cluster GUID + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + display_name: + type: string + responses: + "200": + description: Remote Cluster patch successful + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteCluster" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + delete: + tags: + - remote clusters + summary: Delete a remote cluster. + description: | + Deletes a Remote Cluster. + + ##### Permissions + `manage_secure_connections` + operationId: DeleteRemoteCluster + parameters: + - name: remote_id + in: path + description: Remote Cluster GUID + required: true + schema: + type: string + responses: + "204": + description: Remote Cluster deletion successful + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + + "/api/v4/remotecluster/{remote_id}/generate_invite": + post: + tags: + - remote clusters + summary: Generate invite code. + description: | + Generates an invite code for a given remote cluster. + + ##### Permissions + `manage_secure_connections` + operationId: GenerateRemoteClusterInvite + requestBody: + content: + application/json: + schema: + type: object + required: + - password + properties: + password: + type: string + description: The password to encrypt the invite code with. + responses: + "201": + description: Invite code generated + content: + application/json: + schema: + type: string + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + + "/api/v4/remotecluster/accept_invite": + post: + tags: + - remote clusters + summary: Accept a remote cluster invite code. + description: | + Accepts a remote cluster invite code. + + ##### Permissions + `manage_secure_connections` + operationId: AcceptRemoteClusterInvite + requestBody: + content: + application/json: + schema: + type: object + required: + - invite + - name + - password + properties: + invite: + type: string + name: + type: string + display_name: + type: string + password: + type: string + description: The password to decrypt the invite code. + responses: + "201": + description: Invite successfully accepted + content: + application/json: + schema: + type: object + $ref: "#/components/schemas/RemoteCluster" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" diff --git a/server/channels/api4/apitestlib.go b/server/channels/api4/apitestlib.go index 72df1aaf735..4915d4e3320 100644 --- a/server/channels/api4/apitestlib.go +++ b/server/channels/api4/apitestlib.go @@ -1086,6 +1086,11 @@ func CheckCreatedStatus(tb testing.TB, resp *model.Response) { checkHTTPStatus(tb, resp, http.StatusCreated) } +func CheckNoContentStatus(tb testing.TB, resp *model.Response) { + tb.Helper() + checkHTTPStatus(tb, resp, http.StatusNoContent) +} + func CheckForbiddenStatus(tb testing.TB, resp *model.Response) { tb.Helper() checkHTTPStatus(tb, resp, http.StatusForbidden) @@ -1106,6 +1111,11 @@ func CheckBadRequestStatus(tb testing.TB, resp *model.Response) { checkHTTPStatus(tb, resp, http.StatusBadRequest) } +func CheckUnprocessableEntityStatus(tb testing.TB, resp *model.Response) { + tb.Helper() + checkHTTPStatus(tb, resp, http.StatusUnprocessableEntity) +} + func CheckNotImplementedStatus(tb testing.TB, resp *model.Response) { tb.Helper() checkHTTPStatus(tb, resp, http.StatusNotImplemented) diff --git a/server/channels/api4/remote_cluster.go b/server/channels/api4/remote_cluster.go index e316d3690f2..ccb6ec7f9e6 100644 --- a/server/channels/api4/remote_cluster.go +++ b/server/channels/api4/remote_cluster.go @@ -22,6 +22,14 @@ func (api *API) InitRemoteCluster() { api.BaseRoutes.RemoteCluster.Handle("/confirm_invite", api.RemoteClusterTokenRequired(remoteClusterConfirmInvite)).Methods("POST") api.BaseRoutes.RemoteCluster.Handle("/upload/{upload_id:[A-Za-z0-9]+}", api.RemoteClusterTokenRequired(uploadRemoteData, handlerParamFileAPI)).Methods("POST") api.BaseRoutes.RemoteCluster.Handle("/{user_id:[A-Za-z0-9]+}/image", api.RemoteClusterTokenRequired(remoteSetProfileImage, handlerParamFileAPI)).Methods("POST") + + api.BaseRoutes.RemoteCluster.Handle("", api.APISessionRequired(getRemoteClusters)).Methods("GET") + api.BaseRoutes.RemoteCluster.Handle("", api.APISessionRequired(createRemoteCluster)).Methods("POST") + api.BaseRoutes.RemoteCluster.Handle("/accept_invite", api.APISessionRequired(remoteClusterAcceptInvite)).Methods("POST") + api.BaseRoutes.RemoteCluster.Handle("/{remote_id:[A-Za-z0-9]+}/generate_invite", api.APISessionRequired(generateRemoteClusterInvite)).Methods("POST") + api.BaseRoutes.RemoteCluster.Handle("/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(getRemoteCluster)).Methods("GET") + api.BaseRoutes.RemoteCluster.Handle("/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(patchRemoteCluster)).Methods("PATCH") + api.BaseRoutes.RemoteCluster.Handle("/{remote_id:[A-Za-z0-9]+}", api.APISessionRequired(deleteRemoteCluster)).Methods("DELETE") } func remoteClusterPing(c *Context, w http.ResponseWriter, r *http.Request) { @@ -293,3 +301,359 @@ func remoteSetProfileImage(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } + +func getRemoteClusters(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + // make sure remote cluster service is enabled. + if _, appErr := c.App.GetRemoteClusterService(); appErr != nil { + c.Err = appErr + return + } + + filter := model.RemoteClusterQueryFilter{ + ExcludeOffline: c.Params.ExcludeOffline, + InChannel: c.Params.InChannel, + NotInChannel: c.Params.NotInChannel, + Topic: c.Params.Topic, + CreatorId: c.Params.CreatorId, + OnlyConfirmed: c.Params.OnlyConfirmed, + PluginID: c.Params.PluginId, + OnlyPlugins: c.Params.OnlyPlugins, + ExcludePlugins: c.Params.ExcludePlugins, + } + + rcs, appErr := c.App.GetAllRemoteClusters(c.Params.Page, c.Params.PerPage, filter) + if appErr != nil { + c.Err = appErr + return + } + + for _, rc := range rcs { + rc.Sanitize() + } + + b, err := json.Marshal(rcs) + if err != nil { + c.Err = model.NewAppError("getRemoteClusters", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.Write(b) +} + +func createRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + // make sure remote cluster service is enabled. + if _, appErr := c.App.GetRemoteClusterService(); appErr != nil { + c.Err = appErr + return + } + + auditRec := c.MakeAuditRecord("createRemoteCluster", audit.Fail) + defer c.LogAuditRec(auditRec) + + var rcWithTeamAndPassword model.RemoteClusterWithPassword + if jsonErr := json.NewDecoder(r.Body).Decode(&rcWithTeamAndPassword); jsonErr != nil { + c.SetInvalidParamWithErr("remoteCluster", jsonErr) + return + } + + if rcWithTeamAndPassword.Password == "" { + c.SetInvalidParam("password") + return + } + + url := c.App.GetSiteURL() + if url == "" { + c.Err = model.NewAppError("createRemoteCluster", "api.get_site_url_error", nil, "", http.StatusUnprocessableEntity) + return + } + + if rcWithTeamAndPassword.DisplayName == "" { + rcWithTeamAndPassword.DisplayName = rcWithTeamAndPassword.Name + } + + rc := &model.RemoteCluster{ + Name: rcWithTeamAndPassword.Name, + DisplayName: rcWithTeamAndPassword.DisplayName, + SiteURL: model.SiteURLPending + model.NewId(), + Token: model.NewId(), + CreatorId: c.AppContext.Session().UserId, + } + + audit.AddEventParameterAuditable(auditRec, "remotecluster", rc) + + rcSaved, appErr := c.App.AddRemoteCluster(rc) + if appErr != nil { + c.Err = appErr + return + } + rcSaved.Sanitize() + + inviteCode, iErr := c.App.CreateRemoteClusterInvite(rcSaved.RemoteId, url, rcSaved.Token, rcWithTeamAndPassword.Password) + if iErr != nil { + c.Err = iErr + return + } + + auditRec.Success() + auditRec.AddEventResultState(rcSaved) + auditRec.AddEventObjectType("remotecluster") + + b, err := json.Marshal(model.RemoteClusterWithInvite{RemoteCluster: rcSaved, Invite: inviteCode}) + if err != nil { + c.Err = model.NewAppError("createRemoteCluster", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.WriteHeader(http.StatusCreated) + w.Write(b) +} + +func remoteClusterAcceptInvite(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + // make sure remote cluster service is enabled. + rcs, appErr := c.App.GetRemoteClusterService() + if appErr != nil { + c.Err = appErr + return + } + + auditRec := c.MakeAuditRecord("remoteClusterAcceptInvite", audit.Fail) + defer c.LogAuditRec(auditRec) + + var rcAcceptInvite model.RemoteClusterAcceptInvite + if jsonErr := json.NewDecoder(r.Body).Decode(&rcAcceptInvite); jsonErr != nil { + c.SetInvalidParamWithErr("remoteCluster", jsonErr) + return + } + + audit.AddEventParameter(auditRec, "name", rcAcceptInvite.Name) + audit.AddEventParameter(auditRec, "display_name", rcAcceptInvite.DisplayName) + + if rcAcceptInvite.DisplayName == "" { + rcAcceptInvite.DisplayName = rcAcceptInvite.Name + } + + invite, dErr := c.App.DecryptRemoteClusterInvite(rcAcceptInvite.Invite, rcAcceptInvite.Password) + if dErr != nil { + c.Err = dErr + return + } + + audit.AddEventParameter(auditRec, "site_url", invite.SiteURL) + + url := c.App.GetSiteURL() + if url == "" { + c.Err = model.NewAppError("remoteClusterAcceptInvite", "api.get_site_url_error", nil, "", http.StatusUnprocessableEntity) + return + } + + rc, aErr := rcs.AcceptInvitation(invite, rcAcceptInvite.Name, rcAcceptInvite.DisplayName, c.AppContext.Session().UserId, url) + if aErr != nil { + c.Err = model.NewAppError("remoteClusterAcceptInvite", "api.remote_cluster.accept_invitation_error", nil, "", http.StatusInternalServerError).Wrap(aErr) + if appErr, ok := aErr.(*model.AppError); ok { + c.Err = appErr + } + return + } + rc.Sanitize() + + auditRec.Success() + auditRec.AddEventResultState(rc) + auditRec.AddEventObjectType("remotecluster") + + b, err := json.Marshal(rc) + if err != nil { + c.Err = model.NewAppError("remoteClusterAcceptInvite", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + return + } + + w.WriteHeader(http.StatusCreated) + w.Write(b) +} + +func generateRemoteClusterInvite(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireRemoteId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + // make sure remote cluster service is enabled. + if _, appErr := c.App.GetRemoteClusterService(); appErr != nil { + c.Err = appErr + return + } + + auditRec := c.MakeAuditRecord("generateRemoteClusterInvite", audit.Fail) + defer c.LogAuditRec(auditRec) + audit.AddEventParameter(auditRec, "remote_id", c.Params.RemoteId) + + props := model.MapFromJSON(r.Body) + password := props["password"] + if password == "" { + c.SetInvalidParam("password") + return + } + + url := c.App.GetSiteURL() + if url == "" { + c.Err = model.NewAppError("generateRemoteClusterInvite", "api.get_site_url_error", nil, "", http.StatusUnprocessableEntity) + return + } + + rc, appErr := c.App.GetRemoteCluster(c.Params.RemoteId) + if appErr != nil { + c.Err = appErr + return + } + + inviteCode, invErr := c.App.CreateRemoteClusterInvite(rc.RemoteId, url, rc.Token, password) + if invErr != nil { + c.Err = invErr + } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte(inviteCode)) +} + +func getRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + c.RequireRemoteId() + if c.Err != nil { + return + } + + // make sure remote cluster service is enabled. + if _, appErr := c.App.GetRemoteClusterService(); appErr != nil { + c.Err = appErr + return + } + + rc, err := c.App.GetRemoteCluster(c.Params.RemoteId) + if err != nil { + c.Err = err + return + } + rc.Sanitize() + + if err := json.NewEncoder(w).Encode(rc); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func patchRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + c.RequireRemoteId() + if c.Err != nil { + return + } + + // make sure remote cluster service is enabled. + if _, appErr := c.App.GetRemoteClusterService(); appErr != nil { + c.Err = appErr + return + } + + var patch model.RemoteClusterPatch + if jsonErr := json.NewDecoder(r.Body).Decode(&patch); jsonErr != nil { + c.SetInvalidParamWithErr("remotecluster", jsonErr) + return + } + + auditRec := c.MakeAuditRecord("patchRemoteCluster", audit.Fail) + audit.AddEventParameter(auditRec, "remote_id", c.Params.RemoteId) + audit.AddEventParameterAuditable(auditRec, "remotecluster_patch", &patch) + defer c.LogAuditRec(auditRec) + + orc, err := c.App.GetRemoteCluster(c.Params.RemoteId) + if err != nil { + c.Err = err + return + } + + auditRec.AddEventPriorState(orc) + auditRec.AddEventObjectType("remotecluster") + + updatedRC, err := c.App.PatchRemoteCluster(c.Params.RemoteId, &patch) + if err != nil { + c.Err = err + return + } + + auditRec.Success() + auditRec.AddEventResultState(updatedRC) + + if err := json.NewEncoder(w).Encode(updatedRC); err != nil { + c.Logger.Warn("Error while writing response", mlog.Err(err)) + } +} + +func deleteRemoteCluster(c *Context, w http.ResponseWriter, r *http.Request) { + c.RequireRemoteId() + if c.Err != nil { + return + } + + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionManageSecureConnections) { + c.SetPermissionError(model.PermissionManageSecureConnections) + return + } + + // make sure remote cluster service is enabled. + if _, appErr := c.App.GetRemoteClusterService(); appErr != nil { + c.Err = appErr + return + } + + auditRec := c.MakeAuditRecord("deleteRemoteCluster", audit.Fail) + audit.AddEventParameter(auditRec, "remote_id", c.Params.RemoteId) + defer c.LogAuditRec(auditRec) + + orc, err := c.App.GetRemoteCluster(c.Params.RemoteId) + if err != nil { + c.Err = err + return + } + + auditRec.AddEventPriorState(orc) + auditRec.AddEventObjectType("remotecluster") + + deleted, err := c.App.DeleteRemoteCluster(c.Params.RemoteId) + if err != nil { + c.Err = err + return + } + if !deleted { + c.Err = model.NewAppError("deleteRemoteCluster", "api.remote_cluster.cluster_not_deleted", nil, "", http.StatusInternalServerError) + return + } + + auditRec.Success() + w.WriteHeader(http.StatusNoContent) +} diff --git a/server/channels/api4/remote_cluster_test.go b/server/channels/api4/remote_cluster_test.go new file mode 100644 index 00000000000..1bf89be6a2c --- /dev/null +++ b/server/channels/api4/remote_cluster_test.go @@ -0,0 +1,559 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package api4 + +import ( + "context" + "database/sql" + "encoding/base64" + "testing" + + "github.com/mattermost/mattermost/server/public/model" + "github.com/stretchr/testify/require" +) + +func TestGetRemoteClusters(t *testing.T) { + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + rcs, resp, err := th.SystemAdminClient.GetRemoteClusters(context.Background(), 0, 999999, model.RemoteClusterQueryFilter{}) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + require.Empty(t, rcs) + }) + + th := setupForSharedChannels(t) + defer th.TearDown() + + newRCs := []*model.RemoteCluster{ + { + RemoteId: model.NewId(), + Name: "remote1", + SiteURL: "http://example1.com", + CreatorId: th.SystemAdminUser.Id, + Token: model.NewId(), + RemoteToken: model.NewId(), + }, + { + RemoteId: model.NewId(), + Name: "remote2", + SiteURL: "http://example2.com", + CreatorId: th.SystemAdminUser.Id, + }, + { + RemoteId: model.NewId(), + Name: "remote3", + SiteURL: "http://example3.com", + CreatorId: th.SystemAdminUser.Id, + PluginID: model.NewId(), + }, + } + + for _, rc := range newRCs { + _, appErr := th.App.AddRemoteCluster(rc) + require.Nil(t, appErr) + } + + t.Run("The returned data should be sanitized", func(t *testing.T) { + rcs, resp, err := th.SystemAdminClient.GetRemoteClusters(context.Background(), 0, 999999, model.RemoteClusterQueryFilter{}) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Contains(t, rcs[0].Name, "remote") + require.Zero(t, rcs[0].Token) + require.Zero(t, rcs[0].RemoteToken) + }) + + testCases := []struct { + Name string + Client *model.Client4 + Page int + PerPage int + Filter model.RemoteClusterQueryFilter + ExpectedStatusCode int + ExpectedError bool + ExpectedNames []string + }{ + { + Name: "Should reject if the user has not sufficient permissions", + Client: th.Client, + Page: 0, + PerPage: 999999, + Filter: model.RemoteClusterQueryFilter{}, + ExpectedStatusCode: 403, + ExpectedError: true, + ExpectedNames: []string{}, + }, + { + Name: "Should return all remote clusters", + Client: th.SystemAdminClient, + Page: 0, + PerPage: 999999, + Filter: model.RemoteClusterQueryFilter{}, + ExpectedStatusCode: 200, + ExpectedError: false, + ExpectedNames: []string{"remote1", "remote2", "remote3"}, + }, + { + Name: "Should return all remote clusters but those belonging to plugins", + Client: th.SystemAdminClient, + Page: 0, + PerPage: 999999, + Filter: model.RemoteClusterQueryFilter{ExcludePlugins: true}, + ExpectedStatusCode: 200, + ExpectedError: false, + ExpectedNames: []string{"remote1", "remote2"}, + }, + { + Name: "Should return only remote clusters belonging to plugins", + Client: th.SystemAdminClient, + Page: 0, + PerPage: 999999, + Filter: model.RemoteClusterQueryFilter{OnlyPlugins: true}, + ExpectedStatusCode: 200, + ExpectedError: false, + ExpectedNames: []string{"remote3"}, + }, + { + Name: "Should work as a paginated endpoint", + Client: th.SystemAdminClient, + Page: 1, + PerPage: 1, + Filter: model.RemoteClusterQueryFilter{}, + ExpectedStatusCode: 200, + ExpectedError: false, + ExpectedNames: []string{"remote2"}, + }, + { + Name: "Should return an empty set with a successful status", + Client: th.SystemAdminClient, + Page: 0, + PerPage: 999999, + Filter: model.RemoteClusterQueryFilter{InChannel: model.NewId()}, + ExpectedStatusCode: 200, + ExpectedError: false, + ExpectedNames: []string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + rcs, resp, err := tc.Client.GetRemoteClusters(context.Background(), tc.Page, tc.PerPage, tc.Filter) + checkHTTPStatus(t, resp, tc.ExpectedStatusCode) + if tc.ExpectedError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + require.Len(t, rcs, len(tc.ExpectedNames)) + names := []string{} + for _, rc := range rcs { + names = append(names, rc.Name) + } + require.ElementsMatch(t, tc.ExpectedNames, names) + }) + } +} + +func TestCreateRemoteCluster(t *testing.T) { + rcWithTeamAndPassword := &model.RemoteClusterWithPassword{ + RemoteCluster: &model.RemoteCluster{ + Name: "remotecluster", + SiteURL: "http://example.com", + Token: model.NewId(), + }, + Password: "mysupersecret", + } + + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + rcWithInvite, resp, err := th.SystemAdminClient.CreateRemoteCluster(context.Background(), rcWithTeamAndPassword) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + require.Empty(t, rcWithInvite) + }) + + th := setupForSharedChannels(t).InitBasic() + defer th.TearDown() + + t.Run("Should not work if the user doesn't have the right permissions", func(t *testing.T) { + rcWithInvite, resp, err := th.Client.CreateRemoteCluster(context.Background(), rcWithTeamAndPassword) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + require.Empty(t, rcWithInvite) + }) + + t.Run("Should not work if the siteURL is not set in the configuration", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "" }) + rcWithInvite, resp, err := th.SystemAdminClient.CreateRemoteCluster(context.Background(), rcWithTeamAndPassword) + CheckUnprocessableEntityStatus(t, resp) + require.Error(t, err) + require.Empty(t, rcWithInvite) + }) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" }) + + t.Run("Should enforce the presence of the password", func(t *testing.T) { + // clean the password and check the response + rcWithTeamAndPassword.Password = "" + + rcWithInvite, resp, err := th.SystemAdminClient.CreateRemoteCluster(context.Background(), rcWithTeamAndPassword) + CheckBadRequestStatus(t, resp) + require.Error(t, err) + require.Empty(t, rcWithInvite) + + // reset password for the next tests + rcWithTeamAndPassword.Password = "mysupersecret" + }) + + t.Run("Should return a sanitized remote cluster and its invite", func(t *testing.T) { + rcWithInvite, resp, err := th.SystemAdminClient.CreateRemoteCluster(context.Background(), rcWithTeamAndPassword) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.Equal(t, rcWithTeamAndPassword.Name, rcWithInvite.RemoteCluster.Name) + require.NotZero(t, rcWithInvite.Invite) + require.Zero(t, rcWithInvite.RemoteCluster.Token) + require.Zero(t, rcWithInvite.RemoteCluster.RemoteToken) + + rc, appErr := th.App.GetRemoteCluster(rcWithInvite.RemoteCluster.RemoteId) + require.Nil(t, appErr) + require.Equal(t, rcWithTeamAndPassword.Name, rc.Name) + + rci, appErr := th.App.DecryptRemoteClusterInvite(rcWithInvite.Invite, rcWithTeamAndPassword.Password) + require.Nil(t, appErr) + require.Equal(t, rc.RemoteId, rci.RemoteId) + require.Equal(t, rc.RemoteToken, rci.Token) + require.Equal(t, th.App.GetSiteURL(), rci.SiteURL) + }) +} + +func TestRemoteClusterAcceptinvite(t *testing.T) { + rcAcceptInvite := &model.RemoteClusterAcceptInvite{ + Name: "remotecluster", + Invite: "myinvitecode", + Password: "mysupersecret", + } + + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + rc, resp, err := th.SystemAdminClient.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + require.Empty(t, rc) + }) + + th := setupForSharedChannels(t).InitBasic() + defer th.TearDown() + + remoteId := model.NewId() + invite := &model.RemoteClusterInvite{ + RemoteId: remoteId, + SiteURL: "http://localhost:8065", + Token: "token", + } + password := "mysupersecret" + encrypted, err := invite.Encrypt(password) + require.NoError(t, err) + encoded := base64.URLEncoding.EncodeToString(encrypted) + rcAcceptInvite.Invite = encoded + + t.Run("Should not work if the siteURL is not set in the configuration", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "" }) + rc, resp, err := th.SystemAdminClient.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite) + CheckUnprocessableEntityStatus(t, resp) + require.Error(t, err) + require.Empty(t, rc) + }) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" }) + + t.Run("should fail if the parameters are not valid", func(t *testing.T) { + rcAcceptInvite.Name = "" + defer func() { rcAcceptInvite.Name = "remotecluster" }() + + rc, resp, err := th.SystemAdminClient.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite) + CheckBadRequestStatus(t, resp) + require.Error(t, err) + require.Empty(t, rc) + }) + + t.Run("should fail with the correct status code if the invite returns an app error", func(t *testing.T) { + rcAcceptInvite.Invite = "malformedinvite" + // reset the invite after + defer func() { rcAcceptInvite.Invite = encoded }() + + rc, resp, err := th.SystemAdminClient.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite) + CheckBadRequestStatus(t, resp) + require.Error(t, err) + require.Empty(t, rc) + }) + + t.Run("should not work if the user doesn't have the right permissions", func(t *testing.T) { + rc, resp, err := th.Client.RemoteClusterAcceptInvite(context.Background(), rcAcceptInvite) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + require.Empty(t, rc) + }) + + t.Run("should return a sanitized remote cluster if the action succeeds", func(t *testing.T) { + t.Skip("Requires server2server communication: ToBeImplemented") + }) +} + +func TestGenerateRemoteClusterInvite(t *testing.T) { + password := "mysupersecret" + + newRC := &model.RemoteCluster{ + Name: "remotecluster", + SiteURL: "http://example.com", + Token: model.NewId(), + } + + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + inviteCode, resp, err := th.SystemAdminClient.GenerateRemoteClusterInvite(context.Background(), rc.RemoteId, password) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + require.Zero(t, inviteCode) + }) + + th := setupForSharedChannels(t).InitBasic() + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + t.Run("Should not work if the siteURL is not set in the configuration", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "" }) + inviteCode, resp, err := th.SystemAdminClient.GenerateRemoteClusterInvite(context.Background(), rc.RemoteId, password) + CheckUnprocessableEntityStatus(t, resp) + require.Error(t, err) + require.Empty(t, inviteCode) + }) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ServiceSettings.SiteURL = "http://localhost:8065" }) + + t.Run("Should not work if the user doesn't have the right permissions", func(t *testing.T) { + inviteCode, resp, err := th.Client.GenerateRemoteClusterInvite(context.Background(), rc.RemoteId, password) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + require.Empty(t, inviteCode) + }) + + t.Run("should not work if the remote cluster doesn't exist", func(t *testing.T) { + inviteCode, resp, err := th.SystemAdminClient.GenerateRemoteClusterInvite(context.Background(), model.NewId(), password) + CheckNotFoundStatus(t, resp) + require.Error(t, err) + require.Empty(t, inviteCode) + }) + + t.Run("should not work if the password has been provided", func(t *testing.T) { + inviteCode, resp, err := th.SystemAdminClient.GenerateRemoteClusterInvite(context.Background(), rc.RemoteId, "") + CheckBadRequestStatus(t, resp) + require.Error(t, err) + require.Empty(t, inviteCode) + }) + + t.Run("should generate a valid invite code", func(t *testing.T) { + inviteCode, resp, err := th.SystemAdminClient.GenerateRemoteClusterInvite(context.Background(), rc.RemoteId, password) + CheckCreatedStatus(t, resp) + require.NoError(t, err) + require.NotEmpty(t, inviteCode) + + invite, appErr := th.App.DecryptRemoteClusterInvite(inviteCode, password) + require.Nil(t, appErr) + require.Equal(t, rc.RemoteId, invite.RemoteId) + require.Equal(t, rc.Token, invite.Token) + }) +} + +func TestGetRemoteCluster(t *testing.T) { + newRC := &model.RemoteCluster{ + Name: "remotecluster", + SiteURL: "http://example.com", + Token: model.NewId(), + } + + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + require.NotZero(t, rc.Token) + + fetchedRC, resp, err := th.SystemAdminClient.GetRemoteCluster(context.Background(), rc.RemoteId) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + require.Empty(t, fetchedRC) + }) + + th := setupForSharedChannels(t).InitBasic() + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + t.Run("Should not work if the user doesn't have the right permissions", func(t *testing.T) { + fetchedRC, resp, err := th.Client.GetRemoteCluster(context.Background(), rc.RemoteId) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + require.Empty(t, fetchedRC) + }) + + t.Run("should return not found if the id doesn't exist", func(t *testing.T) { + fetchedRC, resp, err := th.SystemAdminClient.GetRemoteCluster(context.Background(), model.NewId()) + CheckNotFoundStatus(t, resp) + require.Error(t, err) + require.Empty(t, fetchedRC) + }) + + t.Run("should return a sanitized remote cluster", func(t *testing.T) { + fetchedRC, resp, err := th.SystemAdminClient.GetRemoteCluster(context.Background(), rc.RemoteId) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, rc.RemoteId, fetchedRC.RemoteId) + require.Empty(t, fetchedRC.Token) + }) +} + +func TestPatchRemoteCluster(t *testing.T) { + newRC := &model.RemoteCluster{ + Name: "remotecluster", + DisplayName: "initialvalue", + SiteURL: "http://example.com", + Token: model.NewId(), + } + + rcp := &model.RemoteClusterPatch{DisplayName: model.NewString("different value")} + + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + patchedRC, resp, err := th.SystemAdminClient.PatchRemoteCluster(context.Background(), rc.RemoteId, rcp) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + require.Empty(t, patchedRC) + }) + + th := setupForSharedChannels(t).InitBasic() + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + t.Run("Should not work if the user doesn't have the right permissions", func(t *testing.T) { + patchedRC, resp, err := th.Client.PatchRemoteCluster(context.Background(), rc.RemoteId, rcp) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + require.Empty(t, patchedRC) + }) + + t.Run("should not work if the remote cluster is nonexistent", func(t *testing.T) { + patchedRC, resp, err := th.SystemAdminClient.PatchRemoteCluster(context.Background(), model.NewId(), rcp) + CheckNotFoundStatus(t, resp) + require.Error(t, err) + require.Empty(t, patchedRC) + }) + + t.Run("should correctly patch the remote cluster", func(t *testing.T) { + rcp := &model.RemoteClusterPatch{DisplayName: model.NewString("patched!")} + + patchedRC, resp, err := th.SystemAdminClient.PatchRemoteCluster(context.Background(), rc.RemoteId, rcp) + CheckOKStatus(t, resp) + require.NoError(t, err) + require.Equal(t, "patched!", patchedRC.DisplayName) + }) +} + +func TestDeleteRemoteCluster(t *testing.T) { + newRC := &model.RemoteCluster{ + Name: "remotecluster", + DisplayName: "initialvalue", + SiteURL: "http://example.com", + Token: model.NewId(), + } + + t.Run("Should not work if the remote cluster service is not enabled", func(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + resp, err := th.SystemAdminClient.DeleteRemoteCluster(context.Background(), rc.RemoteId) + CheckNotImplementedStatus(t, resp) + require.Error(t, err) + }) + + th := setupForSharedChannels(t).InitBasic() + defer th.TearDown() + + newRC.CreatorId = th.SystemAdminUser.Id + + rc, appErr := th.App.AddRemoteCluster(newRC) + require.Nil(t, appErr) + require.NotZero(t, rc.RemoteId) + + t.Run("Should not work if the user doesn't have the right permissions", func(t *testing.T) { + resp, err := th.Client.DeleteRemoteCluster(context.Background(), rc.RemoteId) + CheckForbiddenStatus(t, resp) + require.Error(t, err) + }) + + t.Run("should not work if the remote cluster is nonexistent", func(t *testing.T) { + resp, err := th.SystemAdminClient.DeleteRemoteCluster(context.Background(), model.NewId()) + CheckNotFoundStatus(t, resp) + require.Error(t, err) + }) + + t.Run("should correctly delete the remote cluster", func(t *testing.T) { + resp, err := th.SystemAdminClient.DeleteRemoteCluster(context.Background(), rc.RemoteId) + CheckNoContentStatus(t, resp) + require.NoError(t, err) + + deletedRC, err := th.App.GetRemoteCluster(rc.RemoteId) + require.ErrorIs(t, err, sql.ErrNoRows) + require.Empty(t, deletedRC) + }) + + t.Run("should return not found if the remote cluster is already deleted", func(t *testing.T) { + resp, err := th.SystemAdminClient.DeleteRemoteCluster(context.Background(), rc.RemoteId) + CheckNotFoundStatus(t, resp) + require.Error(t, err) + }) +} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index aa34418da1e..23253a91b07 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -533,6 +533,7 @@ type AppIface interface { CreatePost(c request.CTX, post *model.Post, channel *model.Channel, triggerWebhooks, setOnline bool) (savedPost *model.Post, err *model.AppError) CreatePostAsUser(c request.CTX, post *model.Post, currentSessionId string, setOnline bool) (*model.Post, *model.AppError) CreatePostMissingChannel(c request.CTX, post *model.Post, triggerWebhooks bool, setOnline bool) (*model.Post, *model.AppError) + CreateRemoteClusterInvite(remoteId, siteURL, token, password string) (string, *model.AppError) CreateRetentionPolicy(policy *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) CreateRole(role *model.Role) (*model.Role, *model.AppError) CreateSamlRelayToken(extra string) (*model.Token, *model.AppError) @@ -555,6 +556,7 @@ type AppIface interface { DeactivateGuests(c request.CTX) *model.AppError DeactivateMfa(userID string) *model.AppError DeauthorizeOAuthAppForUser(c request.CTX, userID, appID string) *model.AppError + DecryptRemoteClusterInvite(inviteCode, password string) (*model.RemoteClusterInvite, *model.AppError) DeleteAcknowledgementForPost(c request.CTX, postID, userID string) *model.AppError DeleteAllExpiredPluginKeys() *model.AppError DeleteAllKeysForPlugin(pluginID string) *model.AppError @@ -626,7 +628,7 @@ type AppIface interface { GetAllChannelsCount(c request.CTX, opts model.ChannelSearchOpts) (int64, *model.AppError) GetAllPrivateTeams() ([]*model.Team, *model.AppError) GetAllPublicTeams() ([]*model.Team, *model.AppError) - GetAllRemoteClusters(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) + GetAllRemoteClusters(page, perPage int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) GetAllRoles() ([]*model.Role, *model.AppError) GetAllTeams() ([]*model.Team, *model.AppError) GetAllTeamsPage(offset int, limit int, opts *model.TeamSearch) ([]*model.Team, *model.AppError) @@ -973,6 +975,7 @@ type AppIface interface { PatchChannel(c request.CTX, channel *model.Channel, patch *model.ChannelPatch, userID string) (*model.Channel, *model.AppError) PatchChannelMembersNotifyProps(c request.CTX, members []*model.ChannelMemberIdentifier, notifyProps map[string]string) ([]*model.ChannelMember, *model.AppError) PatchPost(c request.CTX, postID string, patch *model.PostPatch) (*model.Post, *model.AppError) + PatchRemoteCluster(rcId string, patch *model.RemoteClusterPatch) (*model.RemoteCluster, *model.AppError) PatchRetentionPolicy(patch *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) PatchRole(role *model.Role, patch *model.RolePatch) (*model.Role, *model.AppError) PatchScheme(scheme *model.Scheme, patch *model.SchemePatch) (*model.Scheme, *model.AppError) diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index 827ff0cc023..5281d0ee67a 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -2544,6 +2544,28 @@ func (a *OpenTracingAppLayer) CreatePostMissingChannel(c request.CTX, post *mode return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) CreateRemoteClusterInvite(remoteId string, siteURL string, token string, password string) (string, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateRemoteClusterInvite") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.CreateRemoteClusterInvite(remoteId, siteURL, token, password) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) CreateRetentionPolicy(policy *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateRetentionPolicy") @@ -3050,6 +3072,28 @@ func (a *OpenTracingAppLayer) DeauthorizeOAuthAppForUser(c request.CTX, userID s return resultVar0 } +func (a *OpenTracingAppLayer) DecryptRemoteClusterInvite(inviteCode string, password string) (*model.RemoteClusterInvite, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DecryptRemoteClusterInvite") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.DecryptRemoteClusterInvite(inviteCode, password) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) DefaultChannelNames(c request.CTX) []string { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DefaultChannelNames") @@ -5023,7 +5067,7 @@ func (a *OpenTracingAppLayer) GetAllPublicTeams() ([]*model.Team, *model.AppErro return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) GetAllRemoteClusters(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) { +func (a *OpenTracingAppLayer) GetAllRemoteClusters(page int, perPage int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.GetAllRemoteClusters") @@ -5035,7 +5079,7 @@ func (a *OpenTracingAppLayer) GetAllRemoteClusters(filter model.RemoteClusterQue }() defer span.Finish() - resultVar0, resultVar1 := a.app.GetAllRemoteClusters(filter) + resultVar0, resultVar1 := a.app.GetAllRemoteClusters(page, perPage, filter) if resultVar1 != nil { span.LogFields(spanlog.Error(resultVar1)) @@ -13376,6 +13420,28 @@ func (a *OpenTracingAppLayer) PatchPost(c request.CTX, postID string, patch *mod return resultVar0, resultVar1 } +func (a *OpenTracingAppLayer) PatchRemoteCluster(rcId string, patch *model.RemoteClusterPatch) (*model.RemoteCluster, *model.AppError) { + origCtx := a.ctx + span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PatchRemoteCluster") + + a.ctx = newCtx + a.app.Srv().Store().SetContext(newCtx) + defer func() { + a.app.Srv().Store().SetContext(origCtx) + a.ctx = origCtx + }() + + defer span.Finish() + resultVar0, resultVar1 := a.app.PatchRemoteCluster(rcId, patch) + + if resultVar1 != nil { + span.LogFields(spanlog.Error(resultVar1)) + ext.Error.Set(span, true) + } + + return resultVar0, resultVar1 +} + func (a *OpenTracingAppLayer) PatchRetentionPolicy(patch *model.RetentionPolicyWithTeamAndChannelIDs) (*model.RetentionPolicyWithTeamAndChannelCounts, *model.AppError) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.PatchRetentionPolicy") diff --git a/server/channels/app/platform/shared_channel_notifier.go b/server/channels/app/platform/shared_channel_notifier.go index 459d815b979..b713055f22c 100644 --- a/server/channels/app/platform/shared_channel_notifier.go +++ b/server/channels/app/platform/shared_channel_notifier.go @@ -88,7 +88,7 @@ func handleContentSync(ps *PlatformService, syncService SharedChannelServiceIFac OnlyConfirmed: true, RequireOptions: model.BitflagOptionAutoShareDMs, } - remotes, err := ps.Store.RemoteCluster().GetAll(filter) // empty list returned if none found, no error + remotes, err := ps.Store.RemoteCluster().GetAll(0, 999999, filter) // empty list returned if none found, no error if err != nil { return fmt.Errorf("cannot fetch remote clusters: %w", err) } diff --git a/server/channels/app/remote_cluster.go b/server/channels/app/remote_cluster.go index dc63307359c..34d9d8b1463 100644 --- a/server/channels/app/remote_cluster.go +++ b/server/channels/app/remote_cluster.go @@ -5,6 +5,7 @@ package app import ( "database/sql" + "encoding/base64" "net/http" "github.com/pkg/errors" @@ -100,6 +101,22 @@ func (a *App) AddRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, * return rc, nil } +func (a *App) PatchRemoteCluster(rcId string, patch *model.RemoteClusterPatch) (*model.RemoteCluster, *model.AppError) { + rc, err := a.GetRemoteCluster(rcId) + if err != nil { + return nil, err + } + + rc.Patch(patch) + + updatedRC, err := a.UpdateRemoteCluster(rc) + if err != nil { + return nil, err + } + + return updatedRC, nil +} + func (a *App) UpdateRemoteCluster(rc *model.RemoteCluster) (*model.RemoteCluster, *model.AppError) { rc, err := a.Srv().Store().RemoteCluster().Update(rc) if err != nil { @@ -123,13 +140,18 @@ func (a *App) DeleteRemoteCluster(remoteClusterId string) (bool, *model.AppError func (a *App) GetRemoteCluster(remoteClusterId string) (*model.RemoteCluster, *model.AppError) { rc, err := a.Srv().Store().RemoteCluster().Get(remoteClusterId) if err != nil { - return nil, model.NewAppError("GetRemoteCluster", "api.remote_cluster.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, model.NewAppError("GetRemoteCluster", "api.remote_cluster.get.not_found", nil, "", http.StatusNotFound).Wrap(err) + default: + return nil, model.NewAppError("GetRemoteCluster", "api.remote_cluster.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) + } } return rc, nil } -func (a *App) GetAllRemoteClusters(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) { - list, err := a.Srv().Store().RemoteCluster().GetAll(filter) +func (a *App) GetAllRemoteClusters(page, perPage int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, *model.AppError) { + list, err := a.Srv().Store().RemoteCluster().GetAll(page*perPage, perPage, filter) if err != nil { return nil, model.NewAppError("GetAllRemoteClusters", "api.remote_cluster.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err) } @@ -159,3 +181,32 @@ func (a *App) GetRemoteClusterService() (remotecluster.RemoteClusterServiceIFace } return service, nil } + +func (a *App) CreateRemoteClusterInvite(remoteId, siteURL, token, password string) (string, *model.AppError) { + invite := &model.RemoteClusterInvite{ + RemoteId: remoteId, + SiteURL: siteURL, + Token: token, + } + + encrypted, err := invite.Encrypt(password) + if err != nil { + return "", model.NewAppError("CreateRemoteClusterInvite", "api.remote_cluster.encrypt_invite_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + return base64.URLEncoding.EncodeToString(encrypted), nil +} + +func (a *App) DecryptRemoteClusterInvite(inviteCode, password string) (*model.RemoteClusterInvite, *model.AppError) { + decoded, err := base64.URLEncoding.DecodeString(inviteCode) + if err != nil { + return nil, model.NewAppError("DecryptRemoteClusterInvite", "api.remote_cluster.base64_decode_error", nil, "", http.StatusBadRequest).Wrap(err) + } + + invite := &model.RemoteClusterInvite{} + if dErr := invite.Decrypt(decoded, password); dErr != nil { + return nil, model.NewAppError("DecryptRemoteClusterInvite", "api.remote_cluster.invite_decrypt_error", nil, "", http.StatusBadRequest).Wrap(dErr) + } + + return invite, nil +} diff --git a/server/channels/app/slashcommands/command_remote.go b/server/channels/app/slashcommands/command_remote.go index bbe55508b7f..cdc729af221 100644 --- a/server/channels/app/slashcommands/command_remote.go +++ b/server/channels/app/slashcommands/command_remote.go @@ -4,7 +4,6 @@ package slashcommands import ( - "encoding/base64" "errors" "fmt" "strings" @@ -144,19 +143,13 @@ func (rp *RemoteProvider) doCreate(a *app.App, args *model.CommandArgs, margs ma } // Display the encrypted invitation - invite := &model.RemoteClusterInvite{ - RemoteId: rcSaved.RemoteId, - SiteURL: url, - Token: rcSaved.Token, - } - encrypted, err := invite.Encrypt(password) + inviteCode, err := a.CreateRemoteClusterInvite(rcSaved.RemoteId, url, rcSaved.Token, password) if err != nil { return responsef(args.T("api.command_remote.encrypt_invitation.error", map[string]any{"Error": err.Error()})) } - encoded := base64.URLEncoding.EncodeToString(encrypted) return responsef("##### " + args.T("api.command_remote.invitation_created") + "\n" + - args.T("api.command_remote.invite_summary", map[string]any{"Command": "/secure-connection accept", "Invitation": encoded, "SiteURL": invite.SiteURL})) + args.T("api.command_remote.invite_summary", map[string]any{"Command": "/secure-connection accept", "Invitation": inviteCode, "SiteURL": url})) } // doAccept accepts an invitation generated by a remote site. @@ -182,14 +175,9 @@ func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs ma } // invite is encoded as base64 and encrypted - decoded, err := base64.URLEncoding.DecodeString(blob) - if err != nil { - return responsef(args.T("api.command_remote.decode_invitation.error", map[string]any{"Error": err.Error()})) - } - invite := &model.RemoteClusterInvite{} - err = invite.Decrypt(decoded, password) - if err != nil { - return responsef(args.T("api.command_remote.incorrect_password.error", map[string]any{"Error": err.Error()})) + invite, dErr := a.DecryptRemoteClusterInvite(blob, password) + if dErr != nil { + return responsef(args.T("api.command_remote.decode_invitation.error", map[string]any{"Error": dErr.Error()})) } rcs, _ := a.GetRemoteClusterService() @@ -202,7 +190,7 @@ func (rp *RemoteProvider) doAccept(a *app.App, args *model.CommandArgs, margs ma return responsef(args.T("api.command_remote.site_url_not_set")) } - rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, args.TeamId, url) + rc, err := rcs.AcceptInvitation(invite, name, displayname, args.UserId, url) if err != nil { return responsef(args.T("api.command_remote.accept_invitation.error", map[string]any{"Error": err.Error()})) } @@ -231,7 +219,7 @@ func (rp *RemoteProvider) doRemove(a *app.App, args *model.CommandArgs, margs ma // doStatus displays connection status for all remote clusters. func (rp *RemoteProvider) doStatus(a *app.App, args *model.CommandArgs, _ map[string]string) *model.CommandResponse { - list, err := a.GetAllRemoteClusters(model.RemoteClusterQueryFilter{}) + list, err := a.GetAllRemoteClusters(0, 999999, model.RemoteClusterQueryFilter{}) if err != nil { responsef(args.T("api.command_remote.fetch_status.error", map[string]any{"Error": err.Error()})) } @@ -263,7 +251,7 @@ func getRemoteClusterAutocompleteListItems(a *app.App, includeOffline bool) ([]m filter := model.RemoteClusterQueryFilter{ ExcludeOffline: !includeOffline, } - clusters, err := a.GetAllRemoteClusters(filter) + clusters, err := a.GetAllRemoteClusters(0, 999999, filter) if err != nil || len(clusters) == 0 { return []model.AutocompleteListItem{}, nil } @@ -284,7 +272,7 @@ func getRemoteClusterAutocompleteListItemsNotInChannel(a *app.App, channelID str ExcludeOffline: !includeOffline, NotInChannel: channelID, } - all, err := a.GetAllRemoteClusters(filter) + all, err := a.GetAllRemoteClusters(0, 999999, filter) if err != nil || len(all) == 0 { return []model.AutocompleteListItem{}, nil } diff --git a/server/channels/store/opentracinglayer/opentracinglayer.go b/server/channels/store/opentracinglayer/opentracinglayer.go index f145f49fea8..8a5f871426f 100644 --- a/server/channels/store/opentracinglayer/opentracinglayer.go +++ b/server/channels/store/opentracinglayer/opentracinglayer.go @@ -7828,7 +7828,7 @@ func (s *OpenTracingLayerRemoteClusterStore) Get(remoteClusterId string) (*model return result, err } -func (s *OpenTracingLayerRemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { +func (s *OpenTracingLayerRemoteClusterStore) GetAll(offset int, limit int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { origCtx := s.Root.Store.Context() span, newCtx := tracing.StartSpanWithParentByContext(s.Root.Store.Context(), "RemoteClusterStore.GetAll") s.Root.Store.SetContext(newCtx) @@ -7837,7 +7837,7 @@ func (s *OpenTracingLayerRemoteClusterStore) GetAll(filter model.RemoteClusterQu }() defer span.Finish() - result, err := s.RemoteClusterStore.GetAll(filter) + result, err := s.RemoteClusterStore.GetAll(offset, limit, filter) if err != nil { span.LogFields(spanlog.Error(err)) ext.Error.Set(span, true) diff --git a/server/channels/store/retrylayer/retrylayer.go b/server/channels/store/retrylayer/retrylayer.go index 8d28f84aa2f..d30b9eee181 100644 --- a/server/channels/store/retrylayer/retrylayer.go +++ b/server/channels/store/retrylayer/retrylayer.go @@ -8909,11 +8909,11 @@ func (s *RetryLayerRemoteClusterStore) Get(remoteClusterId string) (*model.Remot } -func (s *RetryLayerRemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { +func (s *RetryLayerRemoteClusterStore) GetAll(offset int, limit int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { tries := 0 for { - result, err := s.RemoteClusterStore.GetAll(filter) + result, err := s.RemoteClusterStore.GetAll(offset, limit, filter) if err == nil { return result, nil } diff --git a/server/channels/store/sqlstore/remote_cluster_store.go b/server/channels/store/sqlstore/remote_cluster_store.go index 9d6b845df2c..913151bc2f7 100644 --- a/server/channels/store/sqlstore/remote_cluster_store.go +++ b/server/channels/store/sqlstore/remote_cluster_store.go @@ -162,7 +162,14 @@ func (s sqlRemoteClusterStore) GetByPluginID(pluginID string) (*model.RemoteClus return &rc, nil } -func (s sqlRemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { +func (s sqlRemoteClusterStore) GetAll(offset, limit int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { + if offset < 0 { + return nil, errors.New("offset must be a positive integer") + } + if limit < 0 { + return nil, errors.New("limit must be a positive integer") + } + query := s.getQueryBuilder(). Select(remoteClusterFields("rc")...). From("RemoteClusters rc") @@ -195,6 +202,10 @@ func (s sqlRemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([] query = query.Where(sq.NotEq{"rc.PluginID": ""}) } + if filter.ExcludePlugins { + query = query.Where(sq.Eq{"rc.PluginID": ""}) + } + if filter.RequireOptions != 0 { query = query.Where(sq.NotEq{fmt.Sprintf("(rc.Options & %d)", filter.RequireOptions): 0}) } @@ -208,6 +219,8 @@ func (s sqlRemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([] query = query.Where(sq.Or{sq.Like{"rc.Topics": queryTopic}, sq.Eq{"rc.Topics": "*"}}) } + query = query.Offset(uint64(offset)).Limit(uint64(limit)) + queryString, args, err := query.ToSql() if err != nil { return nil, errors.Wrap(err, "remote_cluster_getall_tosql") diff --git a/server/channels/store/store.go b/server/channels/store/store.go index bbb9862a1a7..df3a04227b6 100644 --- a/server/channels/store/store.go +++ b/server/channels/store/store.go @@ -533,7 +533,7 @@ type RemoteClusterStore interface { Delete(remoteClusterId string) (bool, error) Get(remoteClusterId string) (*model.RemoteCluster, error) GetByPluginID(pluginID string) (*model.RemoteCluster, error) - GetAll(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) + GetAll(offset, limit int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) UpdateTopics(remoteClusterId string, topics string) (*model.RemoteCluster, error) SetLastPingAt(remoteClusterId string) error } diff --git a/server/channels/store/storetest/mocks/RemoteClusterStore.go b/server/channels/store/storetest/mocks/RemoteClusterStore.go index c44bebd37af..7ec8531a50c 100644 --- a/server/channels/store/storetest/mocks/RemoteClusterStore.go +++ b/server/channels/store/storetest/mocks/RemoteClusterStore.go @@ -72,9 +72,9 @@ func (_m *RemoteClusterStore) Get(remoteClusterId string) (*model.RemoteCluster, return r0, r1 } -// GetAll provides a mock function with given fields: filter -func (_m *RemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { - ret := _m.Called(filter) +// GetAll provides a mock function with given fields: offset, limit, filter +func (_m *RemoteClusterStore) GetAll(offset int, limit int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { + ret := _m.Called(offset, limit, filter) if len(ret) == 0 { panic("no return value specified for GetAll") @@ -82,19 +82,19 @@ func (_m *RemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([]* var r0 []*model.RemoteCluster var r1 error - if rf, ok := ret.Get(0).(func(model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error)); ok { - return rf(filter) + if rf, ok := ret.Get(0).(func(int, int, model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error)); ok { + return rf(offset, limit, filter) } - if rf, ok := ret.Get(0).(func(model.RemoteClusterQueryFilter) []*model.RemoteCluster); ok { - r0 = rf(filter) + if rf, ok := ret.Get(0).(func(int, int, model.RemoteClusterQueryFilter) []*model.RemoteCluster); ok { + r0 = rf(offset, limit, filter) } else { if ret.Get(0) != nil { r0 = ret.Get(0).([]*model.RemoteCluster) } } - if rf, ok := ret.Get(1).(func(model.RemoteClusterQueryFilter) error); ok { - r1 = rf(filter) + if rf, ok := ret.Get(1).(func(int, int, model.RemoteClusterQueryFilter) error); ok { + r1 = rf(offset, limit, filter) } else { r1 = ret.Error(1) } diff --git a/server/channels/store/storetest/remote_cluster_store.go b/server/channels/store/storetest/remote_cluster_store.go index e2915bcc0d3..2fe2db96056 100644 --- a/server/channels/store/storetest/remote_cluster_store.go +++ b/server/channels/store/storetest/remote_cluster_store.go @@ -238,6 +238,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { idsOnline := make([]string, 0) idsShareTopic := make([]string, 0) idsPlugin := make([]string, 0) + idsNotPlugin := make([]string, 0) idsConfirmed := make([]string, 0) for _, item := range data { @@ -253,6 +254,8 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { } if item.PluginID != "" { idsPlugin = append(idsPlugin, saved.RemoteId) + } else { + idsNotPlugin = append(idsNotPlugin, saved.RemoteId) } if item.SiteURL != "" { idsConfirmed = append(idsConfirmed, saved.RemoteId) @@ -261,7 +264,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { t.Run("GetAll", func(t *testing.T) { filter := model.RemoteClusterQueryFilter{} - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure all the test data remotes were returned. ids := getIds(remotes) @@ -272,7 +275,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { filter := model.RemoteClusterQueryFilter{ ExcludeOffline: true, } - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure all the online remotes were returned. ids := getIds(remotes) @@ -283,7 +286,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { filter := model.RemoteClusterQueryFilter{ Topic: "shared", } - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure only correct topic returned ids := getIds(remotes) @@ -295,7 +298,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { ExcludeOffline: true, Topic: "shared", } - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure only online remotes were returned. ids := getIds(remotes) @@ -309,7 +312,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { filter := model.RemoteClusterQueryFilter{ CreatorId: userId, } - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure only correct creator returned assert.Len(t, remotes, 3) @@ -322,7 +325,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { filter := model.RemoteClusterQueryFilter{ OnlyConfirmed: true, } - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure only confirmed returned for _, rc := range remotes { @@ -337,7 +340,7 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { filter := model.RemoteClusterQueryFilter{ OnlyPlugins: true, } - remotes, err := ss.RemoteCluster().GetAll(filter) + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) // make sure only plugin remotes returned for _, rc := range remotes { @@ -348,6 +351,22 @@ func testRemoteClusterGetAll(t *testing.T, _ request.CTX, ss store.Store) { ids := getIds(remotes) assert.ElementsMatch(t, ids, idsPlugin) }) + + t.Run("GetAll excluding plugins", func(t *testing.T) { + filter := model.RemoteClusterQueryFilter{ + ExcludePlugins: true, + } + remotes, err := ss.RemoteCluster().GetAll(0, 999999, filter) + require.NoError(t, err) + // make sure only non plugin remotes returned + for _, rc := range remotes { + assert.Empty(t, rc.PluginID) + assert.False(t, rc.IsPlugin()) + } + // make sure all of the non plugin remotes were returned. + ids := getIds(remotes) + assert.ElementsMatch(t, ids, idsNotPlugin) + }) } func testRemoteClusterGetAllInChannel(t *testing.T, rctx request.CTX, ss store.Store) { @@ -411,7 +430,7 @@ func testRemoteClusterGetAllInChannel(t *testing.T, rctx request.CTX, ss store.S filter := model.RemoteClusterQueryFilter{ InChannel: channel1.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 2, "channel 1 should have 2 remote clusters") ids := getIds(list) @@ -425,7 +444,7 @@ func testRemoteClusterGetAllInChannel(t *testing.T, rctx request.CTX, ss store.S ExcludeOffline: true, InChannel: channel1.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 1, "channel 1 should have 1 online remote clusters") ids := getIds(list) @@ -436,7 +455,7 @@ func testRemoteClusterGetAllInChannel(t *testing.T, rctx request.CTX, ss store.S filter := model.RemoteClusterQueryFilter{ InChannel: channel2.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 3, "channel 2 should have 3 remote clusters") ids := getIds(list) @@ -448,7 +467,7 @@ func testRemoteClusterGetAllInChannel(t *testing.T, rctx request.CTX, ss store.S ExcludeOffline: true, InChannel: channel2.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 2, "channel 2 should have 2 online remote clusters") ids := getIds(list) @@ -459,7 +478,7 @@ func testRemoteClusterGetAllInChannel(t *testing.T, rctx request.CTX, ss store.S filter := model.RemoteClusterQueryFilter{ InChannel: channel3.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Empty(t, list, "channel 3 should have 0 remote clusters") }) @@ -520,7 +539,7 @@ func testRemoteClusterGetAllNotInChannel(t *testing.T, rctx request.CTX, ss stor filter := model.RemoteClusterQueryFilter{ NotInChannel: channel1.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 3, "channel 1 should have 3 remote clusters that are not already members") ids := getIds(list) @@ -531,7 +550,7 @@ func testRemoteClusterGetAllNotInChannel(t *testing.T, rctx request.CTX, ss stor filter := model.RemoteClusterQueryFilter{ NotInChannel: channel2.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 3, "channel 2 should have 3 remote clusters that are not already members") ids := getIds(list) @@ -542,7 +561,7 @@ func testRemoteClusterGetAllNotInChannel(t *testing.T, rctx request.CTX, ss stor filter := model.RemoteClusterQueryFilter{ NotInChannel: channel3.Id, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 4, "channel 3 should have 4 remote clusters that are not already members") ids := getIds(list) @@ -553,7 +572,7 @@ func testRemoteClusterGetAllNotInChannel(t *testing.T, rctx request.CTX, ss stor filter := model.RemoteClusterQueryFilter{ NotInChannel: model.NewId(), } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) require.NoError(t, err) require.Len(t, list, 5, "should have 5 remote clusters that are not already members") ids := getIds(list) @@ -605,7 +624,7 @@ func testRemoteClusterGetByTopic(t *testing.T, _ request.CTX, ss store.Store) { filter := model.RemoteClusterQueryFilter{ Topic: tt.topic, } - list, err := ss.RemoteCluster().GetAll(filter) + list, err := ss.RemoteCluster().GetAll(0, 999999, filter) if tt.expectError { assert.Errorf(t, err, "expected error for topic=%s", tt.topic) } else { @@ -653,7 +672,7 @@ func testRemoteClusterUpdateTopics(t *testing.T, _ request.CTX, ss store.Store) } func clearRemoteClusters(ss store.Store) error { - list, err := ss.RemoteCluster().GetAll(model.RemoteClusterQueryFilter{}) + list, err := ss.RemoteCluster().GetAll(0, 999999, model.RemoteClusterQueryFilter{}) if err != nil { return err } diff --git a/server/channels/store/timerlayer/timerlayer.go b/server/channels/store/timerlayer/timerlayer.go index 50879eec600..ff6f294573b 100644 --- a/server/channels/store/timerlayer/timerlayer.go +++ b/server/channels/store/timerlayer/timerlayer.go @@ -7065,10 +7065,10 @@ func (s *TimerLayerRemoteClusterStore) Get(remoteClusterId string) (*model.Remot return result, err } -func (s *TimerLayerRemoteClusterStore) GetAll(filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { +func (s *TimerLayerRemoteClusterStore) GetAll(offset int, limit int, filter model.RemoteClusterQueryFilter) ([]*model.RemoteCluster, error) { start := time.Now() - result, err := s.RemoteClusterStore.GetAll(filter) + result, err := s.RemoteClusterStore.GetAll(offset, limit, filter) elapsed := float64(time.Since(start)) / float64(time.Second) if s.Root.Metrics != nil { diff --git a/server/channels/web/params.go b/server/channels/web/params.go index 67ebf8215fe..7b593eaeeaf 100644 --- a/server/channels/web/params.go +++ b/server/channels/web/params.go @@ -93,6 +93,14 @@ type Params struct { FilterHasMember string IncludeChannelMemberCount string OutgoingOAuthConnectionID string + ExcludeOffline bool + InChannel string + NotInChannel string + Topic string + CreatorId string + OnlyConfirmed bool + OnlyPlugins bool + ExcludePlugins bool //Bookmarks ChannelBookmarkId string @@ -126,7 +134,11 @@ func ParamsFromRequest(r *http.Request) *Params { params.FileId = props["file_id"] params.Filename = query.Get("filename") params.UploadId = props["upload_id"] - params.PluginId = props["plugin_id"] + if val, ok := props["plugin_id"]; ok { + params.PluginId = val + } else { + params.PluginId = query.Get("plugin_id") + } params.CommandId = props["command_id"] params.HookId = props["hook_id"] params.ReportId = props["report_id"] @@ -150,6 +162,14 @@ func ParamsFromRequest(r *http.Request) *Params { params.RemoteId = props["remote_id"] params.InvoiceId = props["invoice_id"] params.OutgoingOAuthConnectionID = props["outgoing_oauth_connection_id"] + params.ExcludeOffline, _ = strconv.ParseBool(query.Get("exclude_offline")) + params.InChannel = query.Get("in_channel") + params.NotInChannel = query.Get("not_in_channel") + params.Topic = query.Get("topic") + params.CreatorId = query.Get("creator_id") + params.OnlyConfirmed, _ = strconv.ParseBool(query.Get("only_confirmed")) + params.OnlyPlugins, _ = strconv.ParseBool(query.Get("only_plugins")) + params.ExcludePlugins, _ = strconv.ParseBool(query.Get("exclude_plugins")) params.ChannelBookmarkId = props["bookmark_id"] params.Scope = query.Get("scope") diff --git a/server/i18n/en.json b/server/i18n/en.json index 66bf7bb0871..25610fdf6c7 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -1281,10 +1281,6 @@ "id": "api.command_remote.hint", "translation": "[action]" }, - { - "id": "api.command_remote.incorrect_password.error", - "translation": "Could not decrypt invitation. Incorrect password or corrupt invitation: {{.Error}}" - }, { "id": "api.command_remote.invitation.help", "translation": "Invitation from secure connection" @@ -2144,6 +2140,10 @@ "id": "api.getUsersForReporting.invalid_team_filter", "translation": "Invalid team id provided." }, + { + "id": "api.get_site_url_error", + "translation": "Could not get the instance site url" + }, { "id": "api.image.get.app_error", "translation": "Requested image url cannot be parsed." @@ -2726,14 +2726,34 @@ "id": "api.reaction.save_reaction.user_id.app_error", "translation": "You cannot save reaction for the other user." }, + { + "id": "api.remote_cluster.accept_invitation_error", + "translation": "Could not accept the remote cluster invitation" + }, + { + "id": "api.remote_cluster.base64_decode_error", + "translation": "Could not decode the base64 string" + }, + { + "id": "api.remote_cluster.cluster_not_deleted", + "translation": "Remote cluster has not been deleted" + }, { "id": "api.remote_cluster.delete.app_error", "translation": "We encountered an error deleting the secure connection." }, + { + "id": "api.remote_cluster.encrypt_invite_error", + "translation": "Could not encrypt the remote cluster invite using the provided password" + }, { "id": "api.remote_cluster.get.app_error", "translation": "We encountered an error retrieving a secure connection." }, + { + "id": "api.remote_cluster.get.not_found", + "translation": "Remote Cluster not found" + }, { "id": "api.remote_cluster.invalid_id.app_error", "translation": "Invalid id." @@ -2742,6 +2762,10 @@ "id": "api.remote_cluster.invalid_topic.app_error", "translation": "Invalid topic." }, + { + "id": "api.remote_cluster.invite_decrypt_error", + "translation": "Could not decrypt the remote cluster invite using the provided password" + }, { "id": "api.remote_cluster.save.app_error", "translation": "We encountered an error saving the secure connection." diff --git a/server/platform/services/remotecluster/invitation.go b/server/platform/services/remotecluster/invitation.go index 7c44d5ebc99..508477438f7 100644 --- a/server/platform/services/remotecluster/invitation.go +++ b/server/platform/services/remotecluster/invitation.go @@ -12,7 +12,7 @@ import ( ) // AcceptInvitation is called when accepting an invitation to connect with a remote cluster. -func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) { +func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName, creatorId string, siteURL string) (*model.RemoteCluster, error) { rc := &model.RemoteCluster{ RemoteId: invite.RemoteId, Name: name, @@ -29,7 +29,7 @@ func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name str } // confirm the invitation with the originating site - frame, err := makeConfirmFrame(rcSaved, teamId, siteURL) + frame, err := makeConfirmFrame(rcSaved, siteURL) if err != nil { return nil, err } @@ -60,7 +60,7 @@ func (rcs *Service) AcceptInvitation(invite *model.RemoteClusterInvite, name str return rcSaved, nil } -func makeConfirmFrame(rc *model.RemoteCluster, teamId string, siteURL string) (*model.RemoteClusterFrame, error) { +func makeConfirmFrame(rc *model.RemoteCluster, siteURL string) (*model.RemoteClusterFrame, error) { confirm := model.RemoteClusterInvite{ RemoteId: rc.RemoteId, SiteURL: siteURL, diff --git a/server/platform/services/remotecluster/mocks_test.go b/server/platform/services/remotecluster/mocks_test.go index 17178744224..f2f9eb06b3e 100644 --- a/server/platform/services/remotecluster/mocks_test.go +++ b/server/platform/services/remotecluster/mocks_test.go @@ -52,7 +52,7 @@ func (ms *mockServer) GetStore() store.Store { remoteClusterStoreMock := &mocks.RemoteClusterStore{} remoteClusterStoreMock.On("GetByTopic", "share").Return(ms.remotes, nil) - remoteClusterStoreMock.On("GetAll", anyQueryFilter).Return(ms.remotes, nil) + remoteClusterStoreMock.On("GetAll", 0, 999999, anyQueryFilter).Return(ms.remotes, nil) remoteClusterStoreMock.On("SetLastPingAt", anyId).Return(nil) userStoreMock := &mocks.UserStore{} diff --git a/server/platform/services/remotecluster/ping.go b/server/platform/services/remotecluster/ping.go index 111c96a6397..f81cf54c2b4 100644 --- a/server/platform/services/remotecluster/ping.go +++ b/server/platform/services/remotecluster/ping.go @@ -37,7 +37,7 @@ func (rcs *Service) PingNow(rc *model.RemoteCluster) { // pingAllNow emits a ping to all remotes immediately without waiting for next ping loop. func (rcs *Service) pingAllNow(filter model.RemoteClusterQueryFilter) { // get all remotes, including any previously offline. - remotes, err := rcs.server.GetStore().RemoteCluster().GetAll(filter) + remotes, err := rcs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter) if err != nil { rcs.server.Log().Log(mlog.LvlRemoteClusterServiceError, "Ping all remote clusters failed (could not get list of remotes)", mlog.Err(err)) return diff --git a/server/platform/services/remotecluster/sendmsg.go b/server/platform/services/remotecluster/sendmsg.go index 51ec410e83f..fd6c27fa006 100644 --- a/server/platform/services/remotecluster/sendmsg.go +++ b/server/platform/services/remotecluster/sendmsg.go @@ -41,7 +41,7 @@ func (rcs *Service) BroadcastMsg(ctx context.Context, msg model.RemoteClusterMsg filter := model.RemoteClusterQueryFilter{ Topic: msg.Topic, } - list, err := rcs.server.GetStore().RemoteCluster().GetAll(filter) + list, err := rcs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter) if err != nil { return err } diff --git a/server/platform/services/remotecluster/service.go b/server/platform/services/remotecluster/service.go index 78ff2de73d9..4241b6f7819 100644 --- a/server/platform/services/remotecluster/service.go +++ b/server/platform/services/remotecluster/service.go @@ -68,7 +68,7 @@ type RemoteClusterServiceIFace interface { SendMsg(ctx context.Context, msg model.RemoteClusterMsg, rc *model.RemoteCluster, f SendMsgResultFunc) error SendFile(ctx context.Context, us *model.UploadSession, fi *model.FileInfo, rc *model.RemoteCluster, rp ReaderProvider, f SendFileResultFunc) error SendProfileImage(ctx context.Context, userID string, rc *model.RemoteCluster, provider ProfileImageProvider, f SendProfileImageResultFunc) error - AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, teamId string, siteURL string) (*model.RemoteCluster, error) + AcceptInvitation(invite *model.RemoteClusterInvite, name string, displayName string, creatorId string, siteURL string) (*model.RemoteCluster, error) ReceiveIncomingMsg(rc *model.RemoteCluster, msg model.RemoteClusterMsg) Response ReceiveInviteConfirmation(invite model.RemoteClusterInvite) (*model.RemoteCluster, error) PingNow(rc *model.RemoteCluster) diff --git a/server/platform/services/sharedchannel/sync_send.go b/server/platform/services/sharedchannel/sync_send.go index 452db3e0243..bbf675c6618 100644 --- a/server/platform/services/sharedchannel/sync_send.go +++ b/server/platform/services/sharedchannel/sync_send.go @@ -258,7 +258,7 @@ func (scs *Service) processTask(task syncTask) error { InChannel: task.channelID, OnlyConfirmed: true, } - remotes, err := scs.server.GetStore().RemoteCluster().GetAll(filter) + remotes, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter) if err != nil { return err } @@ -270,7 +270,7 @@ func (scs *Service) processTask(task syncTask) error { filter = model.RemoteClusterQueryFilter{ RequireOptions: model.BitflagOptionAutoInvited, } - remotesAutoInvited, err := scs.server.GetStore().RemoteCluster().GetAll(filter) + remotesAutoInvited, err := scs.server.GetStore().RemoteCluster().GetAll(0, 999999, filter) if err != nil { return err } diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 71a53ff6b64..4a881de6316 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -568,6 +568,10 @@ func (c *Client4) exportRoute(name string) string { return fmt.Sprintf(c.exportsRoute()+"/%v", name) } +func (c *Client4) remoteClusterRoute() string { + return "/remotecluster" +} + func (c *Client4) sharedChannelsRoute() string { return "/sharedchannels" } @@ -8698,6 +8702,154 @@ func (c *Client4) GetRemoteClusterInfo(ctx context.Context, remoteID string) (Re return rci, BuildResponse(r), nil } +func (c *Client4) GetRemoteClusters(ctx context.Context, page, perPage int, filter RemoteClusterQueryFilter) ([]*RemoteCluster, *Response, error) { + v := url.Values{} + if page != 0 { + v.Set("page", fmt.Sprintf("%d", page)) + } + if perPage != 0 { + v.Set("per_page", fmt.Sprintf("%d", perPage)) + } + if filter.ExcludeOffline { + v.Set("exclude_offline", "true") + } + if filter.InChannel != "" { + v.Set("in_channel", filter.InChannel) + } + if filter.NotInChannel != "" { + v.Set("not_in_channel", filter.NotInChannel) + } + if filter.Topic != "" { + v.Set("topic", filter.Topic) + } + if filter.CreatorId != "" { + v.Set("creator_id", filter.CreatorId) + } + if filter.OnlyConfirmed { + v.Set("only_confirmed", "true") + } + if filter.PluginID != "" { + v.Set("plugin_id", filter.PluginID) + } + if filter.OnlyPlugins { + v.Set("only_plugins", "true") + } + if filter.ExcludePlugins { + v.Set("exclude_plugins", "true") + } + url := c.remoteClusterRoute() + if len(v) > 0 { + url += "?" + v.Encode() + } + + r, err := c.DoAPIGet(ctx, url, "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var rcs []*RemoteCluster + json.NewDecoder(r.Body).Decode(&rcs) + + return rcs, BuildResponse(r), nil +} + +func (c *Client4) CreateRemoteCluster(ctx context.Context, rcWithPassword *RemoteClusterWithPassword) (*RemoteClusterWithInvite, *Response, error) { + rcJSON, err := json.Marshal(rcWithPassword) + if err != nil { + return nil, nil, NewAppError("CreateRemoteCluster", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + r, err := c.DoAPIPost(ctx, c.remoteClusterRoute(), string(rcJSON)) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var rcWithInvite RemoteClusterWithInvite + if err := json.NewDecoder(r.Body).Decode(&rcWithInvite); err != nil { + return nil, nil, NewAppError("CreateRemoteCluster", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &rcWithInvite, BuildResponse(r), nil +} + +func (c *Client4) RemoteClusterAcceptInvite(ctx context.Context, rcAcceptInvite *RemoteClusterAcceptInvite) (*RemoteCluster, *Response, error) { + rcAcceptInviteJSON, err := json.Marshal(rcAcceptInvite) + if err != nil { + return nil, nil, NewAppError("RemoteClusterAcceptInvite", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + url := fmt.Sprintf("%s/accept_invite", c.remoteClusterRoute()) + r, err := c.DoAPIPost(ctx, url, string(rcAcceptInviteJSON)) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var rc RemoteCluster + if err := json.NewDecoder(r.Body).Decode(&rc); err != nil { + return nil, nil, NewAppError("RemoteClusterAcceptInvite", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &rc, BuildResponse(r), nil +} + +func (c *Client4) GenerateRemoteClusterInvite(ctx context.Context, remoteClusterId, password string) (string, *Response, error) { + url := fmt.Sprintf("%s/%s/generate_invite", c.remoteClusterRoute(), remoteClusterId) + r, err := c.DoAPIPost(ctx, url, MapToJSON(map[string]string{"password": password})) + if err != nil { + return "", BuildResponse(r), err + } + defer closeBody(r) + + b, err := io.ReadAll(r.Body) + if err != nil { + return "", nil, NewAppError("GenerateRemoteClusterInvite", "api.read_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return string(b), BuildResponse(r), nil +} + +func (c *Client4) GetRemoteCluster(ctx context.Context, remoteClusterId string) (*RemoteCluster, *Response, error) { + r, err := c.DoAPIGet(ctx, fmt.Sprintf("%s/%s", c.remoteClusterRoute(), remoteClusterId), "") + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var rc *RemoteCluster + json.NewDecoder(r.Body).Decode(&rc) + + return rc, BuildResponse(r), nil +} + +func (c *Client4) PatchRemoteCluster(ctx context.Context, remoteClusterId string, patch *RemoteClusterPatch) (*RemoteCluster, *Response, error) { + patchJSON, err := json.Marshal(patch) + if err != nil { + return nil, nil, NewAppError("PatchRemoteCluster", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + + url := fmt.Sprintf("%s/%s", c.remoteClusterRoute(), remoteClusterId) + r, err := c.DoAPIPatchBytes(ctx, url, patchJSON) + if err != nil { + return nil, BuildResponse(r), err + } + defer closeBody(r) + + var rc RemoteCluster + if err := json.NewDecoder(r.Body).Decode(&rc); err != nil { + return nil, nil, NewAppError("PatchRemoteCluster", "api.unmarshal_error", nil, "", http.StatusInternalServerError).Wrap(err) + } + return &rc, BuildResponse(r), nil +} + +func (c *Client4) DeleteRemoteCluster(ctx context.Context, remoteClusterId string) (*Response, error) { + r, err := c.DoAPIDelete(ctx, fmt.Sprintf("%s/%s", c.remoteClusterRoute(), remoteClusterId)) + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} + func (c *Client4) GetAncillaryPermissions(ctx context.Context, subsectionPermissions []string) ([]string, *Response, error) { var returnedPermissions []string url := fmt.Sprintf("%s/ancillary?subsection_permissions=%s", c.permissionsRoute(), strings.Join(subsectionPermissions, ",")) diff --git a/server/public/model/remote_cluster.go b/server/public/model/remote_cluster.go index 92700f0daa1..b2411fc8e82 100644 --- a/server/public/model/remote_cluster.go +++ b/server/public/model/remote_cluster.go @@ -126,6 +126,37 @@ func (rc *RemoteCluster) IsValid() *AppError { return nil } +func (rc *RemoteCluster) Sanitize() { + rc.Token = "" + rc.RemoteToken = "" +} + +type RemoteClusterPatch struct { + DisplayName *string `json:"display_name"` +} + +func (rcp *RemoteClusterPatch) Auditable() map[string]interface{} { + return map[string]interface{}{ + "display_name": rcp.DisplayName, + } +} + +func (rc *RemoteCluster) Patch(patch *RemoteClusterPatch) { + if patch.DisplayName != nil { + rc.DisplayName = *patch.DisplayName + } +} + +type RemoteClusterWithPassword struct { + *RemoteCluster + Password string `json:"password"` +} + +type RemoteClusterWithInvite struct { + RemoteCluster *RemoteCluster `json:"remote_cluster"` + Invite string `json:"invite"` +} + func newIDFromBytes(b []byte) string { hash := md5.New() _, _ = hash.Write(b) @@ -393,6 +424,13 @@ func (rci *RemoteClusterInvite) Decrypt(encrypted []byte, password string) error return json.Unmarshal(plain, &rci) } +type RemoteClusterAcceptInvite struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + Invite string `json:"invite"` + Password string `json:"password"` +} + // RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll type RemoteClusterQueryFilter struct { ExcludeOffline bool @@ -403,5 +441,6 @@ type RemoteClusterQueryFilter struct { OnlyConfirmed bool PluginID string OnlyPlugins bool + ExcludePlugins bool RequireOptions Bitmask }