From fcd45de73b142b16e8710b0575eeb99cd001b978 Mon Sep 17 00:00:00 2001 From: Martin Kraft Date: Mon, 21 Nov 2022 13:36:41 -0500 Subject: [PATCH] MM-48186: Add a new API endpoint to add a user to their default GroupChannels and GroupTeams. (#21591) * MM-48186: Add a new API endpoint to add a user to their default GroupChannels and GroupTeams. * MM-48186: Removed unrelated lint fixes. * MM-48186: Removed variable from previous iteration. * MM-48186: Adds translation. * MM-48186: Not upgrading golang.org/x/text in this pr. * MM-48186: Validate user ID and auth service. * MM-48186: Use user id from struct. * MM-48186: Added basic client test. * MM-48186: Adds empty translation. * MM-48186: Added translations. Co-authored-by: Mattermod --- api4/ldap.go | 34 ++++++++ api4/ldap_test.go | 31 +++++++ app/app_iface.go | 2 +- app/opentracing/opentracing_layer.go | 4 +- app/syncables.go | 28 ++++-- app/syncables_test.go | 123 +++++++++++++++++++++++++-- i18n/en.json | 8 ++ model/client4.go | 9 ++ model/group.go | 8 ++ 9 files changed, 227 insertions(+), 20 deletions(-) diff --git a/api4/ldap.go b/api4/ldap.go index b71415c940..aabacc7599 100644 --- a/api4/ldap.go +++ b/api4/ldap.go @@ -40,6 +40,7 @@ func (api *API) InitLdap() { api.BaseRoutes.LDAP.Handle("/certificate/public", api.APISessionRequired(removeLdapPublicCertificate)).Methods("DELETE") api.BaseRoutes.LDAP.Handle("/certificate/private", api.APISessionRequired(removeLdapPrivateCertificate)).Methods("DELETE") + api.BaseRoutes.LDAP.Handle("/users/{user_id}/group_sync_memberships", api.APISessionRequired(addUserToGroupSyncables)).Methods("POST") } func syncLdap(c *Context, w http.ResponseWriter, r *http.Request) { @@ -419,3 +420,36 @@ func removeLdapPrivateCertificate(c *Context, w http.ResponseWriter, r *http.Req auditRec.Success() ReturnStatusOK(w) } + +// addUserToGroupSyncables creates memberships—for the given user—to all of their group syncables (i.e. channels or teams). +// For each group the user is a member of, for each channel and/or team that group is associated with, the user will be added. +func addUserToGroupSyncables(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) { + c.SetPermissionError(model.PermissionSysconsoleWriteUserManagementGroups) + return + } + + user, appErr := c.App.GetUser(c.Params.UserId) + if appErr != nil { + c.Err = appErr + return + } + + if user.AuthService != model.UserAuthServiceLdap { + c.Err = model.NewAppError("addUserToGroupSyncables", "api.user.add_user_to_group_syncables.not_ldap_user.app_error", nil, "", http.StatusBadRequest) + return + } + + auditRec := c.MakeAuditRecord("addUserToGroupSyncables", audit.Fail) + defer c.LogAuditRec(auditRec) + + params := model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: true, ScopedUserID: &user.Id} + err := c.App.CreateDefaultMemberships(c.AppContext, params) + if err != nil { + c.Err = model.NewAppError("addUserToGroupSyncables", "api.admin.syncables_error", nil, err.Error(), http.StatusBadRequest) + return + } + + auditRec.Success() + ReturnStatusOK(w) +} diff --git a/api4/ldap_test.go b/api4/ldap_test.go index 82e9c0b31b..d364096b57 100644 --- a/api4/ldap_test.go +++ b/api4/ldap_test.go @@ -277,3 +277,34 @@ func TestUploadPrivateCertificate(t *testing.T) { require.NoErrorf(t, err, "Should have passed. System Admin privileges %v", err) }) } + +func TestAddUserToGroupSyncables(t *testing.T) { + th := Setup(t) + defer th.TearDown() + + resp, err := th.Client.AddUserToGroupSyncables(th.BasicUser.Id) + require.Error(t, err) + CheckForbiddenStatus(t, resp) + + resp, err = th.SystemAdminClient.AddUserToGroupSyncables("invalid-user-id") + require.Error(t, err) + CheckNotFoundStatus(t, resp) + + resp, err = th.SystemAdminClient.AddUserToGroupSyncables(th.BasicUser.Id) + require.Error(t, err) + CheckBadRequestStatus(t, resp) + + id := model.NewId() + user := &model.User{ + Email: "test@localhost", + Username: model.NewId(), + AuthData: &id, + AuthService: model.UserAuthServiceLdap, + } + user, err = th.App.Srv().Store().User().Save(user) + require.NoError(t, err) + + resp, err = th.SystemAdminClient.AddUserToGroupSyncables(user.Id) + require.NoError(t, err) + CheckOKStatus(t, resp) +} diff --git a/app/app_iface.go b/app/app_iface.go index 57b6f6fde1..ab22b9cbf9 100644 --- a/app/app_iface.go +++ b/app/app_iface.go @@ -93,7 +93,7 @@ type AppIface interface { // are configured to sync with teams and channels for group members on or after the given timestamp. // If includeRemovedMembers is true, then members who left or were removed from a team/channel will // be re-added; otherwise, they will not be re-added. - CreateDefaultMemberships(c *request.Context, since int64, includeRemovedMembers bool) error + CreateDefaultMemberships(c *request.Context, params model.CreateDefaultMembershipParams) error // CreateGuest creates a guest and sets several fields of the returned User struct to // their zero values. CreateGuest(c request.CTX, user *model.User) (*model.User, *model.AppError) diff --git a/app/opentracing/opentracing_layer.go b/app/opentracing/opentracing_layer.go index b10458ce0d..b40c1921aa 100644 --- a/app/opentracing/opentracing_layer.go +++ b/app/opentracing/opentracing_layer.go @@ -2007,7 +2007,7 @@ func (a *OpenTracingAppLayer) CreateCommandWebhook(commandID string, args *model return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, since int64, includeRemovedMembers bool) error { +func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, params model.CreateDefaultMembershipParams) error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.CreateDefaultMemberships") @@ -2019,7 +2019,7 @@ func (a *OpenTracingAppLayer) CreateDefaultMemberships(c *request.Context, since }() defer span.Finish() - resultVar0 := a.app.CreateDefaultMemberships(c, since, includeRemovedMembers) + resultVar0 := a.app.CreateDefaultMemberships(c, params) if resultVar0 != nil { span.LogFields(spanlog.Error(resultVar0)) diff --git a/app/syncables.go b/app/syncables.go index cc87edc8ee..2114055b17 100644 --- a/app/syncables.go +++ b/app/syncables.go @@ -18,13 +18,16 @@ import ( // only that channel's members are created. If channelID is nil all channel memberships are created. // If includeRemovedMembers is true, then channel members who left or were removed from the channel will // be re-added; otherwise, they will not be re-added. -func (a *App) createDefaultChannelMemberships(c request.CTX, since int64, channelID *string, includeRemovedMembers bool) error { - channelMembers, appErr := a.ChannelMembersToAdd(since, channelID, includeRemovedMembers) +func (a *App) createDefaultChannelMemberships(c request.CTX, params model.CreateDefaultMembershipParams) error { + channelMembers, appErr := a.ChannelMembersToAdd(params.Since, params.ScopedChannelID, params.ReAddRemovedMembers) if appErr != nil { return appErr } for _, userChannel := range channelMembers { + if params.ScopedUserID != nil && *params.ScopedUserID != userChannel.UserID { + continue + } channel, err := a.GetChannel(c, userChannel.ChannelID) if err != nil { return err @@ -83,13 +86,16 @@ func (a *App) createDefaultChannelMemberships(c request.CTX, since int64, channe // only that team's members are created. If teamID is nil all team memberships are created. // If includeRemovedMembers is true, then team members who left or were removed from the team will // be re-added; otherwise, they will not be re-added. -func (a *App) createDefaultTeamMemberships(c request.CTX, since int64, teamID *string, includeRemovedMembers bool) error { - teamMembers, appErr := a.TeamMembersToAdd(since, teamID, includeRemovedMembers) +func (a *App) createDefaultTeamMemberships(c request.CTX, params model.CreateDefaultMembershipParams) error { + teamMembers, appErr := a.TeamMembersToAdd(params.Since, params.ScopedTeamID, params.ReAddRemovedMembers) if appErr != nil { return appErr } for _, userTeam := range teamMembers { + if params.ScopedUserID != nil && *params.ScopedUserID != userTeam.UserID { + continue + } _, err := a.AddTeamMember(c, userTeam.TeamID, userTeam.UserID) if err != nil { if err.Id == "api.team.join_user_to_team.allowed_domains.app_error" { @@ -115,13 +121,13 @@ func (a *App) createDefaultTeamMemberships(c request.CTX, since int64, teamID *s // are configured to sync with teams and channels for group members on or after the given timestamp. // If includeRemovedMembers is true, then members who left or were removed from a team/channel will // be re-added; otherwise, they will not be re-added. -func (a *App) CreateDefaultMemberships(c *request.Context, since int64, includeRemovedMembers bool) error { - err := a.createDefaultTeamMemberships(c, since, nil, includeRemovedMembers) +func (a *App) CreateDefaultMemberships(c *request.Context, params model.CreateDefaultMembershipParams) error { + err := a.createDefaultTeamMemberships(c, params) if err != nil { return err } - err = a.createDefaultChannelMemberships(c, since, nil, includeRemovedMembers) + err = a.createDefaultChannelMemberships(c, params) if err != nil { return err } @@ -242,15 +248,19 @@ func (a *App) SyncRolesAndMembership(c request.CTX, syncableID string, syncableT since = lastJob.StartAt } + params := model.CreateDefaultMembershipParams{Since: since, ReAddRemovedMembers: includeRemovedMembers} + switch syncableType { case model.GroupSyncableTypeTeam: - a.createDefaultTeamMemberships(c, since, &syncableID, includeRemovedMembers) + params.ScopedTeamID = &syncableID + a.createDefaultTeamMemberships(c, params) a.deleteGroupConstrainedTeamMemberships(c, &syncableID) if err := a.ClearTeamMembersCache(syncableID); err != nil { c.Logger().Warn("Error clearing team members cache", mlog.Err(err)) } case model.GroupSyncableTypeChannel: - a.createDefaultChannelMemberships(c, since, &syncableID, includeRemovedMembers) + params.ScopedChannelID = &syncableID + a.createDefaultChannelMemberships(c, params) a.deleteGroupConstrainedChannelMemberships(c, &syncableID) if err := a.ClearChannelMembersCache(c, syncableID); err != nil { c.Logger().Warn("Error clearing channel members cache", mlog.Err(err)) diff --git a/app/syncables_test.go b/app/syncables_test.go index d78d46f1d2..0908a859a2 100644 --- a/app/syncables_test.go +++ b/app/syncables_test.go @@ -103,7 +103,7 @@ func TestCreateDefaultMemberships(t *testing.T) { t.Errorf("test groupmember not created: %s", err.Error()) } - pErr := th.App.CreateDefaultMemberships(th.Context, 0, false) + pErr := th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("faild to populate syncables: %s", pErr.Error()) } @@ -173,7 +173,7 @@ func TestCreateDefaultMemberships(t *testing.T) { } // Sync everything after syncable was created (proving that team updates trigger re-sync) - pErr = th.App.CreateDefaultMemberships(th.Context, scientistGroupMember.CreateAt+1, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: scientistGroupMember.CreateAt + 1, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("faild to populate syncables: %s", pErr.Error()) } @@ -216,7 +216,7 @@ func TestCreateDefaultMemberships(t *testing.T) { } // Sync everything after syncable was created (proving that channel updates trigger re-sync) - pErr = th.App.CreateDefaultMemberships(th.Context, scientistGroupMember.CreateAt+1, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: scientistGroupMember.CreateAt + 1, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("faild to populate syncables: %s", pErr.Error()) } @@ -241,7 +241,7 @@ func TestCreateDefaultMemberships(t *testing.T) { } // Even re-syncing from the beginning doesn't re-add to channel or team - pErr = th.App.CreateDefaultMemberships(th.Context, 0, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("faild to populate syncables: %s", pErr.Error()) } @@ -282,7 +282,7 @@ func TestCreateDefaultMemberships(t *testing.T) { t.Errorf("error updating group syncable: %s", err.Error()) } - pErr = th.App.CreateDefaultMemberships(th.Context, 0, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("faild to populate syncables: %s", pErr.Error()) } @@ -305,7 +305,7 @@ func TestCreateDefaultMemberships(t *testing.T) { } require.Equal(t, int64(1), deletedCount) - pErr = th.App.CreateDefaultMemberships(th.Context, scienceChannelGroupSyncable.UpdateAt, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: scienceChannelGroupSyncable.UpdateAt, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("failed to populate syncables: %s", pErr.Error()) } @@ -323,7 +323,7 @@ func TestCreateDefaultMemberships(t *testing.T) { } require.Equal(t, int64(1), deletedCount) - pErr = th.App.CreateDefaultMemberships(th.Context, scienceChannelGroupSyncable.UpdateAt, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: scienceChannelGroupSyncable.UpdateAt, ReAddRemovedMembers: false}) if pErr != nil { t.Errorf("failed to populate syncables: %s", pErr.Error()) } @@ -363,7 +363,7 @@ func TestCreateDefaultMemberships(t *testing.T) { _, err = th.App.UpsertGroupSyncable(model.NewGroupChannel(scienceGroup.Id, restrictedChannel.Id, true)) require.Nil(t, err) - pErr = th.App.CreateDefaultMemberships(th.Context, 0, false) + pErr = th.App.CreateDefaultMemberships(th.Context, model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: false}) require.NoError(t, pErr) // Ensure only the restricted user was added to both the team and channel @@ -375,6 +375,113 @@ func TestCreateDefaultMemberships(t *testing.T) { require.Len(t, tmembers, 1) require.Equal(t, tmembers[0].UserId, restrictedUser.Id) }) + + t.Run("scoped to a single user", func(t *testing.T) { + team1, err := th.App.CreateTeam(th.Context, &model.Team{ + DisplayName: "Team 1", + Name: "zz" + model.NewId(), + Email: "team1admin@test.com", + Type: model.TeamOpen, + }) + if err != nil { + t.Errorf("test team not created: %s", err.Error()) + } + + team1Channel1, err := th.App.CreateChannel(th.Context, &model.Channel{ + TeamId: team1.Id, + DisplayName: "Team 1 Channel 1", + Name: model.NewId(), + Type: model.ChannelTypeOpen, + }, false) + if err != nil { + t.Errorf("test channel not created: %s", err.Error()) + } + + group1, err := th.App.CreateGroup(&model.Group{ + Name: model.NewString(model.NewId()), + DisplayName: "Group 1", + RemoteId: model.NewString(model.NewId()), + Source: model.GroupSourceLdap, + }) + if err != nil { + t.Errorf("test group not created: %s", err.Error()) + } + + _, err = th.App.UpsertGroupSyncable(model.NewGroupTeam(group1.Id, team1.Id, true)) + if err != nil { + t.Errorf("test groupchannel not created: %s", err.Error()) + } + + _, err = th.App.UpsertGroupSyncable(model.NewGroupChannel(group1.Id, team1Channel1.Id, true)) + if err != nil { + t.Errorf("test groupchannel not created: %s", err.Error()) + } + + user1 := th.BasicUser + user2 := th.BasicUser2 + + _, err = th.App.UpsertGroupMember(group1.Id, user1.Id) + if err != nil { + t.Errorf("test groupmember not created: %s", err.Error()) + } + + _, err = th.App.UpsertGroupMember(group1.Id, user2.Id) + if err != nil { + t.Errorf("test groupmember not created: %s", err.Error()) + } + + params := model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: false, ScopedUserID: &user1.Id} + pErr = th.App.CreateDefaultMemberships(th.Context, params) + if pErr != nil { + t.Errorf("failed to populate syncables: %s", pErr.Error()) + } + + // test that only user 1 is successfully added to the team and channel. + team1Members, err := th.App.GetTeamMembers(team1.Id, 0, 100, nil) + if err != nil { + t.Errorf("failed to get team members: %s", err.Error()) + } + if len(team1Members) != 1 { + t.Errorf("expected 1 team member on team1, got %d", len(team1Members)) + } + if team1Members[0].UserId != user1.Id { + t.Errorf("expected user1 to be a team member on team1, got %s", team1Members[0].UserId) + } + + team1Channel1Members, err := th.App.GetChannelMembersPage(th.Context, team1Channel1.Id, 0, 100) + if err != nil { + t.Errorf("failed to get channel members: %s", err.Error()) + } + if len(team1Channel1Members) != 1 { + t.Errorf("expected 1 channel member on team1Channel1, got %d", len(team1Channel1Members)) + } + if team1Channel1Members[0].UserId != user1.Id { + t.Errorf("expected user1 to be a channel member on team1Channel1, got %s", team1Channel1Members[0].UserId) + } + + // unscoped should add user2 to the team and channel + params = model.CreateDefaultMembershipParams{Since: 0, ReAddRemovedMembers: false} + pErr = th.App.CreateDefaultMemberships(th.Context, params) + if pErr != nil { + t.Errorf("failed to populate syncables: %s", pErr.Error()) + } + + team1Members, err = th.App.GetTeamMembers(team1.Id, 0, 100, nil) + if err != nil { + t.Errorf("failed to get team members: %s", err.Error()) + } + if len(team1Members) != 2 { + t.Errorf("expected 2 team member on team1, got %d", len(team1Members)) + } + + team1Channel1Members, err = th.App.GetChannelMembersPage(th.Context, team1Channel1.Id, 0, 100) + if err != nil { + t.Errorf("failed to get channel members: %s", err.Error()) + } + if len(team1Channel1Members) != 2 { + t.Errorf("expected 2 channel member on team1Channel1, got %d", len(team1Channel1Members)) + } + }) } func TestDeleteGroupMemberships(t *testing.T) { diff --git a/i18n/en.json b/i18n/en.json index fee81c6f65..b1c57495a7 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -147,6 +147,10 @@ "id": "api.admin.saml.set_certificate_from_metadata.missing_content_type.app_error", "translation": "Missing content type." }, + { + "id": "api.admin.syncables_error", + "translation": "failed to add user to group-teams and group-channels" + }, { "id": "api.admin.test_email.body", "translation": "It appears your Mattermost email is setup correctly!" @@ -3931,6 +3935,10 @@ "id": "api.user.add_direct_channels_and_forget.failed.error", "translation": "Failed to add direct channel preferences for user user_id={{.UserId}}, team_id={{.TeamId}}, err={{.Error}}" }, + { + "id": "api.user.add_user_to_group_syncables.not_ldap_user.app_error", + "translation": "not an ldap user" + }, { "id": "api.user.authorize_oauth_user.bad_response.app_error", "translation": "Bad response from token request." diff --git a/model/client4.go b/model/client4.go index 6ab4ed4952..24ffc859ca 100644 --- a/model/client4.go +++ b/model/client4.go @@ -8458,3 +8458,12 @@ func (c *Client4) GetNewTeamMembersSince(teamID string, timeRange string, page i } return newTeamMembersList, BuildResponse(r), nil } + +func (c *Client4) AddUserToGroupSyncables(userID string) (*Response, error) { + r, err := c.DoAPIPost(c.ldapRoute()+"/users/"+userID+"/group_sync_memberships", "") + if err != nil { + return BuildResponse(r), err + } + defer closeBody(r) + return BuildResponse(r), nil +} diff --git a/model/group.go b/model/group.go index f6ce813c38..20d9533401 100644 --- a/model/group.go +++ b/model/group.go @@ -247,3 +247,11 @@ type GroupsWithCount struct { Groups []*Group `json:"groups"` TotalCount int64 `json:"total_count"` } + +type CreateDefaultMembershipParams struct { + Since int64 + ReAddRemovedMembers bool + ScopedUserID *string + ScopedTeamID *string + ScopedChannelID *string +}