mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
* MM-68547: Tighten authorization on group syncable link and patch endpoints
Adds an additional permission check on the group syncable link and patch
endpoints. Callers must hold the role-management permission for the
target team or channel (or the sysconsole groups-management permission).
Made-with: Cursor
* Linting
* MM-68547: Extend group syncable scheme_admin authorization checks
Gate any explicit scheme_admin value (in either direction) on link and
patch. Populate SchemeAdmin in the singular getGroupSyncable so that
patches that do not touch scheme_admin no longer overwrite the persisted
value. Restrict PermittedSyncableAdmins to active syncables. Start the
link upsert from the existing active row to preserve fields the caller
did not, or could not, set.
Made-with: Cursor
* MM-68547: Add store-layer regression coverage for SchemeAdmin handling
Extend testGetGroupSyncable to round-trip SchemeAdmin: true through
UpdateGroupSyncable and re-fetch, locking in that getGroupSyncable
populates the field from the persisted row.
Strengthen groupTestPermittedSyncableAdmins{Team,Channel} to assert
that DeleteGroupSyncable preserves SchemeAdmin in the persisted row
and that PermittedSyncableAdmins still excludes the row, making the
coupling between the two store changes explicit.
Made-with: Cursor
* MM-68547: Fix group details role-change dedup on remove
The roleChangeKey helper was reading team_id/channel_id from the items
in itemsToRemove, but onRemoveTeamOrChannel pushes those items with a
generic id field. The deletion of the staged role change in
handleRemovedTeamsAndChannels therefore never matched the key produced
by onChangeRoles, and a stale patchGroupSyncable was dispatched after
the unlink.
Accept either id or team_id/channel_id when computing the key. Also
extend the e2e assertion to verify the channel removal took effect
(delete_at != 0) alongside the existing scheme_admin check.
Made-with: Cursor
* MM-68547: Mirror delete_at assertion on the removed-team e2e test
The team variant of "does not update the role of a removed X" was left
asserting only on scheme_admin. Add the matching delete_at != 0 check
already present in the channel variant so both tests verify the same
user-visible contract.
Made-with: Cursor
* Skip SyncSyncableRoles if no scheme_admin
326 lines
12 KiB
Go
326 lines
12 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/hashicorp/go-multierror"
|
|
"github.com/mattermost/mattermost/server/public/model"
|
|
"github.com/mattermost/mattermost/server/public/shared/mlog"
|
|
"github.com/mattermost/mattermost/server/public/shared/request"
|
|
)
|
|
|
|
// createDefaultChannelMemberships adds users to channels based on their group memberships and how those groups are
|
|
// configured to sync with channels for group members on or after the given timestamp. If a channelID is given
|
|
// only that channel's members are created. If channelID is nil all channel memberships are created.
|
|
// If params.ReAddRemovedMembers 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(rctx request.CTX, params model.CreateDefaultMembershipParams) error {
|
|
channelMembers, appErr := a.ChannelMembersToAdd(params.Since, params.ScopedChannelID, params.ReAddRemovedMembers)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
var multiErr *multierror.Error
|
|
for _, userChannel := range channelMembers {
|
|
if params.ScopedUserID != nil && *params.ScopedUserID != userChannel.UserID {
|
|
continue
|
|
}
|
|
|
|
logger := rctx.Logger().With(
|
|
mlog.String("user_id", userChannel.UserID),
|
|
mlog.String("channel_id", userChannel.ChannelID),
|
|
)
|
|
|
|
channel, err := a.GetChannel(rctx, userChannel.ChannelID)
|
|
if err != nil {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to get channel for default channel membership: %w", err))
|
|
continue
|
|
}
|
|
|
|
tmem, err := a.GetTeamMember(rctx, channel.TeamId, userChannel.UserID)
|
|
if err != nil && err.Id != "app.team.get_member.missing.app_error" {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to get member for default channel membership: %w", err))
|
|
continue
|
|
}
|
|
|
|
// First add user to team
|
|
if tmem == nil {
|
|
_, err = a.AddTeamMember(rctx, channel.TeamId, userChannel.UserID)
|
|
if err != nil {
|
|
if err.Id == "api.team.join_user_to_team.allowed_domains.app_error" {
|
|
logger.Info(
|
|
"User not added to channel - the domain associated with the user is not in the list of allowed team domains",
|
|
mlog.String("team_id", channel.TeamId),
|
|
)
|
|
} else {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to add team member for default channel membership: %w", err))
|
|
}
|
|
continue
|
|
}
|
|
logger.Info("Added channel member for default channel membership")
|
|
}
|
|
|
|
_, err = a.AddChannelMember(rctx, userChannel.UserID, channel, ChannelMemberOpts{
|
|
SkipTeamMemberIntegrityCheck: true,
|
|
})
|
|
if err != nil {
|
|
if err.Id == "api.channel.add_user.to.channel.failed.deleted.app_error" {
|
|
logger.Info("Not adding user to channel because they have already left the team")
|
|
} else {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to add channel member for default channel membership: %w", err))
|
|
}
|
|
continue
|
|
}
|
|
|
|
logger.Info("Added channel member for default channel membership")
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
// createDefaultTeamMemberships adds users to teams based on their group memberships and how those groups are
|
|
// configured to sync with teams for group members on or after the given timestamp. If a teamID is given
|
|
// only that team's members are created. If teamID is nil all team memberships are created.
|
|
// If params.ReAddRemovedMembers 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(rctx request.CTX, params model.CreateDefaultMembershipParams) error {
|
|
teamMembers, appErr := a.TeamMembersToAdd(params.Since, params.ScopedTeamID, params.ReAddRemovedMembers)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
var multiErr *multierror.Error
|
|
for _, userTeam := range teamMembers {
|
|
if params.ScopedUserID != nil && *params.ScopedUserID != userTeam.UserID {
|
|
continue
|
|
}
|
|
|
|
logger := rctx.Logger().With(
|
|
mlog.String("user_id", userTeam.UserID),
|
|
mlog.String("team_id", userTeam.TeamID),
|
|
)
|
|
|
|
_, err := a.AddTeamMember(rctx, userTeam.TeamID, userTeam.UserID)
|
|
if err != nil {
|
|
if err.Id == "api.team.join_user_to_team.allowed_domains.app_error" {
|
|
logger.Info("User not added to team - the domain associated with the user is not in the list of allowed team domains")
|
|
} else {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to add team member for default team membership: %w", err))
|
|
}
|
|
continue
|
|
}
|
|
|
|
logger.Info("Added team member for default team membership")
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
// CreateDefaultMemberships adds users to teams and channels based on their group memberships and how those groups
|
|
// are configured to sync with teams and channels for group members on or after the given timestamp.
|
|
// If params.AddRemovedMembers 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(rctx request.CTX, params model.CreateDefaultMembershipParams) error {
|
|
err := a.createDefaultTeamMemberships(rctx, params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = a.createDefaultChannelMemberships(rctx, params)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteGroupConstrainedMemberships deletes team and channel memberships of users who aren't members of the allowed
|
|
// groups of all group-constrained teams and channels.
|
|
func (a *App) DeleteGroupConstrainedMemberships(rctx request.CTX) error {
|
|
err := a.DeleteGroupConstrainedChannelMemberships(rctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = a.DeleteGroupConstrainedTeamMemberships(rctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// DeleteGroupConstrainedTeamMemberships deletes team memberships of users who aren't members of the allowed
|
|
// groups of the given group-constrained team. If a teamID is given then the procedure is scoped to the given team,
|
|
// if teamID is nil then the procedure affects all teams.
|
|
func (a *App) DeleteGroupConstrainedTeamMemberships(rctx request.CTX, teamID *string) error {
|
|
teamMembers, appErr := a.TeamMembersToRemove(teamID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
var multiErr *multierror.Error
|
|
for _, userTeam := range teamMembers {
|
|
logger := rctx.Logger().With(
|
|
mlog.String("user_id", userTeam.UserId),
|
|
mlog.String("team_id", userTeam.TeamId),
|
|
)
|
|
|
|
err := a.RemoveUserFromTeam(rctx, userTeam.TeamId, userTeam.UserId, "")
|
|
if err != nil {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to remove team member for default team membership: %w", err))
|
|
continue
|
|
}
|
|
|
|
logger.Info("Removed team member for group constrained team membership")
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
// DeleteGroupConstrainedChannelMemberships deletes channel memberships of users who aren't members of the allowed
|
|
// groups of the given group-constrained channel. If a channelID is given then the procedure is scoped to the given team,
|
|
// if channelID is nil then the procedure affects all teams.
|
|
func (a *App) DeleteGroupConstrainedChannelMemberships(rctx request.CTX, channelID *string) error {
|
|
channelMembers, appErr := a.ChannelMembersToRemove(channelID)
|
|
if appErr != nil {
|
|
return appErr
|
|
}
|
|
|
|
var multiErr *multierror.Error
|
|
for _, userChannel := range channelMembers {
|
|
logger := rctx.Logger().With(
|
|
mlog.String("user_id", userChannel.UserId),
|
|
mlog.String("channel_id", userChannel.ChannelId),
|
|
)
|
|
|
|
channel, err := a.GetChannel(rctx, userChannel.ChannelId)
|
|
if err != nil {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to get channel for group constrained channel membership: %w", err))
|
|
continue
|
|
}
|
|
|
|
err = a.RemoveUserFromChannel(rctx, userChannel.UserId, "", channel)
|
|
if err != nil {
|
|
multiErr = multierror.Append(multiErr, fmt.Errorf("failed to remove channel member for group constrained channel membership: %w", err))
|
|
continue
|
|
}
|
|
|
|
logger.Info("Removed channel member for group constrained channel membership")
|
|
}
|
|
|
|
return multiErr.ErrorOrNil()
|
|
}
|
|
|
|
// SyncSyncableRoles updates the SchemeAdmin field value of the given syncable's members based on the configuration of
|
|
// the member's group memberships and the configuration of those groups to the syncable. This method should only
|
|
// be invoked on group-synced (aka group-constrained) syncables.
|
|
func (a *App) SyncSyncableRoles(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType) *model.AppError {
|
|
permittedAdmins, err := a.Srv().Store().Group().PermittedSyncableAdmins(syncableID, syncableType)
|
|
if err != nil {
|
|
return model.NewAppError("SyncSyncableRoles", "app.select_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
rctx.Logger().Info(
|
|
fmt.Sprintf("Permitted admins for %s", syncableType),
|
|
mlog.String(strings.ToLower(fmt.Sprintf("%s_id", syncableType)), syncableID),
|
|
mlog.Array("permitted_admins", permittedAdmins),
|
|
)
|
|
|
|
switch syncableType {
|
|
case model.GroupSyncableTypeTeam:
|
|
var updatedMembers []*model.TeamMember
|
|
updatedMembers, err = a.Srv().Store().Team().UpdateMembersRole(syncableID, permittedAdmins)
|
|
if err != nil {
|
|
return model.NewAppError("App.SyncSyncableRoles", "app.update_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
for _, member := range updatedMembers {
|
|
a.ClearSessionCacheForUser(member.UserId)
|
|
|
|
if appErr := a.sendUpdatedTeamMemberEvent(member); appErr != nil {
|
|
rctx.Logger().Warn("Error sending channel member updated websocket event", mlog.Err(appErr))
|
|
}
|
|
}
|
|
case model.GroupSyncableTypeChannel:
|
|
var updatedMembers []*model.ChannelMember
|
|
updatedMembers, err = a.Srv().Store().Channel().UpdateMembersRole(syncableID, permittedAdmins)
|
|
if err != nil {
|
|
return model.NewAppError("App.SyncSyncableRoles", "app.update_error", nil, "", http.StatusInternalServerError).Wrap(err)
|
|
}
|
|
|
|
for _, member := range updatedMembers {
|
|
a.ClearSessionCacheForUser(member.UserId)
|
|
|
|
if appErr := a.sendUpdateChannelMemberEvent(member); appErr != nil {
|
|
rctx.Logger().Warn("Error sending channel member updated websocket event", mlog.Err(appErr))
|
|
}
|
|
}
|
|
default:
|
|
return model.NewAppError("App.SyncSyncableRoles", "groups.unsupported_syncable_type", map[string]any{"Value": syncableType}, "", http.StatusInternalServerError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SyncRolesAndMembership updates the membership of the given syncable and,
|
|
// when syncRoles is true, also reconciles SchemeAdmin status for its members.
|
|
func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string, syncRoles bool) {
|
|
group, appErr := a.GetGroup(groupID, nil, nil)
|
|
if appErr != nil {
|
|
rctx.Logger().Warn("Error getting group", mlog.Err(appErr))
|
|
return
|
|
}
|
|
|
|
if syncRoles {
|
|
appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType)
|
|
if appErr != nil {
|
|
rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr))
|
|
}
|
|
}
|
|
|
|
var since int64
|
|
reAddRemovedMembers := true
|
|
if group.Source == model.GroupSourceLdap {
|
|
lastJob, _ := a.Srv().Store().Job().GetNewestJobByStatusAndType(model.JobStatusSuccess, model.JobTypeLdapSync)
|
|
if lastJob != nil {
|
|
since = lastJob.StartAt
|
|
}
|
|
|
|
reAddRemovedMembers = *a.Config().LdapSettings.ReAddRemovedMembers
|
|
}
|
|
|
|
params := model.CreateDefaultMembershipParams{Since: since, ReAddRemovedMembers: reAddRemovedMembers}
|
|
|
|
switch syncableType {
|
|
case model.GroupSyncableTypeTeam:
|
|
params.ScopedTeamID = &syncableID
|
|
if err := a.createDefaultTeamMemberships(rctx, params); err != nil {
|
|
rctx.Logger().Warn("Error creating default team memberships", mlog.Err(err))
|
|
}
|
|
case model.GroupSyncableTypeChannel:
|
|
params.ScopedChannelID = &syncableID
|
|
if err := a.createDefaultChannelMemberships(rctx, params); err != nil {
|
|
rctx.Logger().Warn("Error creating default channel memberships", mlog.Err(err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// This method should be called when a syncable is unlinked from a group
|
|
func (a *App) RemoveMembershipsFromUnlinkedSyncable(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType) {
|
|
switch syncableType {
|
|
case model.GroupSyncableTypeTeam:
|
|
if err := a.DeleteGroupConstrainedTeamMemberships(rctx, &syncableID); err != nil {
|
|
rctx.Logger().Warn("Error deleting group constrained team memberships", mlog.Err(err))
|
|
}
|
|
case model.GroupSyncableTypeChannel:
|
|
if err := a.DeleteGroupConstrainedChannelMemberships(rctx, &syncableID); err != nil {
|
|
rctx.Logger().Warn("Error deleting group constrained channel memberships", mlog.Err(err))
|
|
}
|
|
}
|
|
}
|