VYPR
Medium severity4.3NVD Advisory· Published Jun 12, 2026

CVE-2026-6689

CVE-2026-6689

Description

Mattermost fails to enforce PermissionInviteUser during team creation, letting users without invite permission set open invites or allowed domains.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Mattermost fails to enforce PermissionInviteUser during team creation, letting users without invite permission set open invites or allowed domains.

Vulnerability

Mattermost versions 11.6.x11.6.1, 11.5.x11.5.4, 10.11.x10.11.15, and 10.11.x10.11.16 do not enforce the PermissionInviteUser check when creating a team via POST /api/v4/teams. The permission is only verified on update/patch operations. This allows an authenticated user who holds PermissionCreateTeam but not PermissionInviteUser on the resulting team to include allow_open_invite: true or a non-empty allowed_domains in the request body, thereby controlling invite-level settings that they would not be permitted to set on an existing team [1].

Exploitation

An attacker must be an authenticated Mattermost user with the PermissionCreateTeam system or team permission. No further privileges are required. The attacker crafts a POST /api/v4/teams request with the team creation payload, setting allow_open_invite to true and/or specifying allowed_domains to restrict membership. The server accepts the request without validating whether the user has PermissionInviteUser for the newly created team [1].

Impact

By exploiting this flaw, an attacker can make a team publicly joinable via open invitation or constrain membership to specific domains, even though they lack the PermissionInviteUser permission that would normally be required to change these settings on an existing team. This can lead to unauthorized team access and violation of intended access control policies, impacting the confidentiality and integrity of team membership state [1].

Mitigation

Mattermost has not yet released a patched version as of the advisory publication date (2026-06-12). Administrators are advised to monitor the Mattermost security updates page for future fixes [1]. Until a patch is available, workarounds include carefully reviewing which users are granted PermissionCreateTeam and auditing team creation requests.

AI Insight generated on Jun 12, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2
  • Mattermost/Mattermostinferred2 versions
    <=11.6.1, <=11.5.4, <=10.11.16+ 1 more
    • (no CPE)range: <=11.6.1, <=11.5.4, <=10.11.16
    • (no CPE)range: <=11.6.1, <=11.5.4, <=10.11.15, <=10.11.16

Patches

4
977c791e5b54

MM-68382: Align team creation invite permission checks (#36188) (#36402)

https://github.com/mattermost/mattermostNick MisasiMay 5, 2026Fixed in 10.11.16via llm-release-walk
2 files changed · +232 13
  • server/channels/api4/team.go+31 5 modified
    @@ -120,17 +120,20 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     		return
     	}
     
    +	// Setting AllowOpenInvite or AllowedDomains requires PermissionInviteUser, matching updateTeam/patchTeam.
    +	if (team.AllowOpenInvite || team.AllowedDomains != "") && !creatorCanInviteUsersOnTeam(c, &team) {
    +		c.SetPermissionError(model.PermissionInviteUser)
    +		return
    +	}
    +
     	rteam, err := c.App.CreateTeamWithUser(c.AppContext, &team, c.AppContext.Session().UserId)
     	if err != nil {
     		c.Err = err
     		return
     	}
     
    -	// Don't sanitize the team here since the user will be a team admin and their session won't reflect that yet
    -	// instead check the scheme roles for the team and if the user has the permission to invite users
    -	_, schemeUserRole, schemeAdminRole, schemeErr := c.App.GetSchemeRolesForTeam(rteam.Id)
    -	if schemeErr != nil || !c.App.RolesGrantPermission([]string{schemeUserRole, schemeAdminRole}, model.PermissionInviteUser.Id) {
    -		// If we can't check permissions, fail secure by hiding the invite_id because the team is already created above
    +	// The creator's session doesn't yet reflect their team_admin role, so check the team's default roles directly.
    +	if !creatorCanInviteUsersOnTeam(c, rteam) {
     		rteam.InviteId = ""
     	}
     
    @@ -144,6 +147,29 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     	}
     }
     
    +// creatorCanInviteUsersOnTeam checks whether the creator will have PermissionInviteUser on the new team,
    +// using the team's scheme (if any) or the built-in team roles as defaults.
    +func creatorCanInviteUsersOnTeam(c *Context, team *model.Team) bool {
    +	if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionInviteUser) {
    +		return true
    +	}
    +
    +	if team.SchemeId != nil && *team.SchemeId != "" {
    +		scheme, appErr := c.App.GetScheme(*team.SchemeId)
    +		if appErr != nil {
    +			c.Logger.Warn("Failed to fetch scheme while checking invite permission for new team",
    +				mlog.String("scheme_id", *team.SchemeId),
    +				mlog.Err(appErr),
    +			)
    +			return false
    +		}
    +
    +		return c.App.RolesGrantPermission([]string{scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole}, model.PermissionInviteUser.Id)
    +	}
    +
    +	return c.App.RolesGrantPermission([]string{model.TeamUserRoleId, model.TeamAdminRoleId}, model.PermissionInviteUser.Id)
    +}
    +
     func getTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     	c.RequireTeamId()
     	if c.Err != nil {
    
  • server/channels/api4/team_test.go+201 8 modified
    @@ -242,23 +242,216 @@ func TestCreateTeamInviteIdHiddenWithoutInvitePermission(t *testing.T) {
     	defaultRolePermissions := th.SaveDefaultRolePermissions()
     	defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
     
    -	// Remove PermissionInviteUser from the default team user role
    +	// team_admin inherits from team_user by default, so removing from team_user is enough.
     	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
     
    -	// Regular user creates a team - InviteId should be hidden
    -	// since the team user role lacks invite permission
     	rteam, _, err := th.Client.CreateTeam(context.Background(), &model.Team{
    -		DisplayName:    "Team Without Invite Permission",
    -		Name:           GenerateTestTeamName(),
    -		Email:          th.GenerateTestEmail(),
    -		Type:           model.TeamOpen,
    -		AllowedDomains: "simulator.amazonses.com,localhost",
    +		DisplayName: "Team Without Invite Permission",
    +		Name:        GenerateTestTeamName(),
    +		Email:       th.GenerateTestEmail(),
    +		Type:        model.TeamOpen,
     	})
     	require.NoError(t, err)
     	require.NotEmpty(t, rteam.Email, "should not have sanitized email")
     	require.Empty(t, rteam.InviteId, "should have hidden invite_id when user lacks invite permission")
     }
     
    +func TestCreateTeamInviteUserPermission(t *testing.T) {
    +	th := Setup(t)
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions()
    +	defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
    +
    +	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	t.Run("AllowOpenInvite=true is rejected with 403", func(t *testing.T) {
    +		_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Open Invite Team Without Permission",
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			AllowOpenInvite: true,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("non-empty AllowedDomains is rejected with 403", func(t *testing.T) {
    +		creatorDomain := strings.SplitN(th.BasicUser.Email, "@", 2)[1]
    +
    +		_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:    "Restricted Domains Team Without Permission",
    +			Name:           GenerateTestTeamName(),
    +			Email:          th.GenerateTestEmail(),
    +			Type:           model.TeamOpen,
    +			AllowedDomains: creatorDomain,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("team without invite-restricted fields is still created", func(t *testing.T) {
    +		createdTeam, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Plain Team Without Invite Permission",
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.False(t, createdTeam.AllowOpenInvite)
    +		assert.Empty(t, createdTeam.AllowedDomains)
    +		assert.Empty(t, createdTeam.InviteId, "InviteId should be hidden from creators that can't invite users")
    +	})
    +}
    +
    +func TestCreateTeamInviteUserPermissionSystemAdmin(t *testing.T) {
    +	th := Setup(t)
    +	creatorDomain := strings.SplitN(th.SystemAdminUser.Email, "@", 2)[1]
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions()
    +	defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
    +
    +	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	createdTeam, resp, err := th.SystemAdminClient.CreateTeam(context.Background(), &model.Team{
    +		DisplayName:     "System Admin Team With Invite Permission",
    +		Name:            GenerateTestTeamName(),
    +		Email:           th.GenerateTestEmail(),
    +		Type:            model.TeamOpen,
    +		AllowOpenInvite: true,
    +		AllowedDomains:  creatorDomain,
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, resp)
    +
    +	assert.True(t, createdTeam.AllowOpenInvite, "system admins should still be able to create open invite teams")
    +	assert.Equal(t, creatorDomain, createdTeam.AllowedDomains, "system admins should still be able to set allowed domains")
    +	require.NotEmpty(t, createdTeam.InviteId, "system admins should receive the invite_id when they can invite users")
    +
    +	persistedTeam, _, err := th.SystemAdminClient.GetTeam(context.Background(), createdTeam.Id, "")
    +	require.NoError(t, err)
    +
    +	assert.True(t, persistedTeam.AllowOpenInvite, "system admins should persist open invite team settings")
    +	assert.Equal(t, creatorDomain, persistedTeam.AllowedDomains, "system admins should persist allowed domains")
    +}
    +
    +// Exercises the scheme branch of creatorCanInviteUsersOnTeam.
    +func TestCreateTeamInviteUserPermissionScheme(t *testing.T) {
    +	th := Setup(t)
    +	th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes"))
    +	err := th.App.SetPhase2PermissionsMigrationStatus(true)
    +	require.NoError(t, err)
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions()
    +	defer th.RestoreDefaultRolePermissions(defaultRolePermissions)
    +
    +	// Remove InviteUser from the built-in team roles; new schemes inherit from these at creation, so their defaults start without it too.
    +	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	// SystemManager has SchemeWrite but not system-level InviteUser, so it exercises the scheme branch.
    +	th.LoginSystemManager()
    +	managerClient := th.SystemManagerClient
    +
    +	t.Run("scheme admin role grants InviteUser - create succeeds", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +		require.NotEmpty(t, scheme.DefaultTeamAdminRole)
    +		require.NotEmpty(t, scheme.DefaultTeamUserRole)
    +
    +		th.AddPermissionToRole(model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Scheme Team With Invite " + model.NewId(),
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			SchemeId:        &scheme.Id,
    +			AllowOpenInvite: true,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.True(t, rteam.AllowOpenInvite, "AllowOpenInvite should be preserved when scheme grants InviteUser")
    +		assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser")
    +	})
    +
    +	t.Run("scheme roles do not grant InviteUser - create is rejected", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +
    +		_, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Scheme Team Without Invite " + model.NewId(),
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			SchemeId:        &scheme.Id,
    +			AllowOpenInvite: true,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("scheme roles do not grant InviteUser but no invite-restricted fields - create succeeds with hidden invite_id", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Scheme Team Without Invite Fields " + model.NewId(),
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +			SchemeId:    &scheme.Id,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.Empty(t, rteam.InviteId, "InviteId should be hidden when scheme does not grant InviteUser")
    +	})
    +
    +	t.Run("scheme admin role grants InviteUser but no invite-restricted fields - create succeeds with invite_id", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +		require.NotEmpty(t, scheme.DefaultTeamAdminRole)
    +
    +		th.AddPermissionToRole(model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Scheme Team Invite Via Defaults Only " + model.NewId(),
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +			SchemeId:    &scheme.Id,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.False(t, rteam.AllowOpenInvite)
    +		assert.Empty(t, rteam.AllowedDomains)
    +		assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser without open invite or domain restrictions")
    +	})
    +}
    +
     func TestGetTeam(t *testing.T) {
     	mainHelper.Parallel(t)
     	th := Setup(t).InitBasic()
    
2dea05864024

MM-68382: Align team creation invite permission checks (#36188) (#36384)

https://github.com/mattermost/mattermostMattermost BuildMay 4, 2026Fixed in 11.5.5via llm-release-walk
2 files changed · +232 13
  • server/channels/api4/team.go+31 5 modified
    @@ -120,17 +120,20 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     		return
     	}
     
    +	// Setting AllowOpenInvite or AllowedDomains requires PermissionInviteUser, matching updateTeam/patchTeam.
    +	if (team.AllowOpenInvite || team.AllowedDomains != "") && !creatorCanInviteUsersOnTeam(c, &team) {
    +		c.SetPermissionError(model.PermissionInviteUser)
    +		return
    +	}
    +
     	rteam, err := c.App.CreateTeamWithUser(c.AppContext, &team, c.AppContext.Session().UserId)
     	if err != nil {
     		c.Err = err
     		return
     	}
     
    -	// Don't sanitize the team here since the user will be a team admin and their session won't reflect that yet
    -	// instead check the scheme roles for the team and if the user has the permission to invite users
    -	_, schemeUserRole, schemeAdminRole, schemeErr := c.App.GetSchemeRolesForTeam(rteam.Id)
    -	if schemeErr != nil || !c.App.RolesGrantPermission([]string{schemeUserRole, schemeAdminRole}, model.PermissionInviteUser.Id) {
    -		// If we can't check permissions, fail secure by hiding the invite_id because the team is already created above
    +	// The creator's session doesn't yet reflect their team_admin role, so check the team's default roles directly.
    +	if !creatorCanInviteUsersOnTeam(c, rteam) {
     		rteam.InviteId = ""
     	}
     
    @@ -144,6 +147,29 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     	}
     }
     
    +// creatorCanInviteUsersOnTeam checks whether the creator will have PermissionInviteUser on the new team,
    +// using the team's scheme (if any) or the built-in team roles as defaults.
    +func creatorCanInviteUsersOnTeam(c *Context, team *model.Team) bool {
    +	if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionInviteUser) {
    +		return true
    +	}
    +
    +	if team.SchemeId != nil && *team.SchemeId != "" {
    +		scheme, appErr := c.App.GetScheme(*team.SchemeId)
    +		if appErr != nil {
    +			c.Logger.Warn("Failed to fetch scheme while checking invite permission for new team",
    +				mlog.String("scheme_id", *team.SchemeId),
    +				mlog.Err(appErr),
    +			)
    +			return false
    +		}
    +
    +		return c.App.RolesGrantPermission([]string{scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole}, model.PermissionInviteUser.Id)
    +	}
    +
    +	return c.App.RolesGrantPermission([]string{model.TeamUserRoleId, model.TeamAdminRoleId}, model.PermissionInviteUser.Id)
    +}
    +
     func getTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     	c.RequireTeamId()
     	if c.Err != nil {
    
  • server/channels/api4/team_test.go+201 8 modified
    @@ -239,23 +239,216 @@ func TestCreateTeamInviteIdHiddenWithoutInvitePermission(t *testing.T) {
     	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
     	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
     
    -	// Remove PermissionInviteUser from the default team user role
    +	// team_admin inherits from team_user by default, so removing from team_user is enough.
     	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
     
    -	// Regular user creates a team - InviteId should be hidden
    -	// since the team user role lacks invite permission
     	rteam, _, err := th.Client.CreateTeam(context.Background(), &model.Team{
    -		DisplayName:    "Team Without Invite Permission",
    -		Name:           GenerateTestTeamName(),
    -		Email:          th.GenerateTestEmail(),
    -		Type:           model.TeamOpen,
    -		AllowedDomains: "simulator.amazonses.com,localhost",
    +		DisplayName: "Team Without Invite Permission",
    +		Name:        GenerateTestTeamName(),
    +		Email:       th.GenerateTestEmail(),
    +		Type:        model.TeamOpen,
     	})
     	require.NoError(t, err)
     	require.NotEmpty(t, rteam.Email, "should not have sanitized email")
     	require.Empty(t, rteam.InviteId, "should have hidden invite_id when user lacks invite permission")
     }
     
    +func TestCreateTeamInviteUserPermission(t *testing.T) {
    +	th := Setup(t)
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
    +	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
    +
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	t.Run("AllowOpenInvite=true is rejected with 403", func(t *testing.T) {
    +		_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Open Invite Team Without Permission",
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			AllowOpenInvite: true,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("non-empty AllowedDomains is rejected with 403", func(t *testing.T) {
    +		creatorDomain := strings.SplitN(th.BasicUser.Email, "@", 2)[1]
    +
    +		_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:    "Restricted Domains Team Without Permission",
    +			Name:           GenerateTestTeamName(),
    +			Email:          th.GenerateTestEmail(),
    +			Type:           model.TeamOpen,
    +			AllowedDomains: creatorDomain,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("team without invite-restricted fields is still created", func(t *testing.T) {
    +		createdTeam, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Plain Team Without Invite Permission",
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.False(t, createdTeam.AllowOpenInvite)
    +		assert.Empty(t, createdTeam.AllowedDomains)
    +		assert.Empty(t, createdTeam.InviteId, "InviteId should be hidden from creators that can't invite users")
    +	})
    +}
    +
    +func TestCreateTeamInviteUserPermissionSystemAdmin(t *testing.T) {
    +	th := Setup(t)
    +	creatorDomain := strings.SplitN(th.SystemAdminUser.Email, "@", 2)[1]
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
    +	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
    +
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	createdTeam, resp, err := th.SystemAdminClient.CreateTeam(context.Background(), &model.Team{
    +		DisplayName:     "System Admin Team With Invite Permission",
    +		Name:            GenerateTestTeamName(),
    +		Email:           th.GenerateTestEmail(),
    +		Type:            model.TeamOpen,
    +		AllowOpenInvite: true,
    +		AllowedDomains:  creatorDomain,
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, resp)
    +
    +	assert.True(t, createdTeam.AllowOpenInvite, "system admins should still be able to create open invite teams")
    +	assert.Equal(t, creatorDomain, createdTeam.AllowedDomains, "system admins should still be able to set allowed domains")
    +	require.NotEmpty(t, createdTeam.InviteId, "system admins should receive the invite_id when they can invite users")
    +
    +	persistedTeam, _, err := th.SystemAdminClient.GetTeam(context.Background(), createdTeam.Id, "")
    +	require.NoError(t, err)
    +
    +	assert.True(t, persistedTeam.AllowOpenInvite, "system admins should persist open invite team settings")
    +	assert.Equal(t, creatorDomain, persistedTeam.AllowedDomains, "system admins should persist allowed domains")
    +}
    +
    +// Exercises the scheme branch of creatorCanInviteUsersOnTeam.
    +func TestCreateTeamInviteUserPermissionScheme(t *testing.T) {
    +	th := Setup(t)
    +	th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes"))
    +	err := th.App.SetPhase2PermissionsMigrationStatus(true)
    +	require.NoError(t, err)
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
    +	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
    +
    +	// Remove InviteUser from the built-in team roles; new schemes inherit from these at creation, so their defaults start without it too.
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	// SystemManager has SchemeWrite but not system-level InviteUser, so it exercises the scheme branch.
    +	th.LoginSystemManager(t)
    +	managerClient := th.SystemManagerClient
    +
    +	t.Run("scheme admin role grants InviteUser - create succeeds", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +		require.NotEmpty(t, scheme.DefaultTeamAdminRole)
    +		require.NotEmpty(t, scheme.DefaultTeamUserRole)
    +
    +		th.AddPermissionToRole(t, model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Scheme Team With Invite " + model.NewId(),
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			SchemeId:        &scheme.Id,
    +			AllowOpenInvite: true,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.True(t, rteam.AllowOpenInvite, "AllowOpenInvite should be preserved when scheme grants InviteUser")
    +		assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser")
    +	})
    +
    +	t.Run("scheme roles do not grant InviteUser - create is rejected", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +
    +		_, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Scheme Team Without Invite " + model.NewId(),
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			SchemeId:        &scheme.Id,
    +			AllowOpenInvite: true,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("scheme roles do not grant InviteUser but no invite-restricted fields - create succeeds with hidden invite_id", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Scheme Team Without Invite Fields " + model.NewId(),
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +			SchemeId:    &scheme.Id,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.Empty(t, rteam.InviteId, "InviteId should be hidden when scheme does not grant InviteUser")
    +	})
    +
    +	t.Run("scheme admin role grants InviteUser but no invite-restricted fields - create succeeds with invite_id", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +		require.NotEmpty(t, scheme.DefaultTeamAdminRole)
    +
    +		th.AddPermissionToRole(t, model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Scheme Team Invite Via Defaults Only " + model.NewId(),
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +			SchemeId:    &scheme.Id,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.False(t, rteam.AllowOpenInvite)
    +		assert.Empty(t, rteam.AllowedDomains)
    +		assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser without open invite or domain restrictions")
    +	})
    +}
    +
     func TestGetTeam(t *testing.T) {
     	mainHelper.Parallel(t)
     
    
3a96344214b1

MM-68382: Align team creation invite permission checks (#36188) (#36383)

https://github.com/mattermost/mattermostMattermost BuildMay 4, 2026Fixed in 11.6.2via llm-release-walk
2 files changed · +232 13
  • server/channels/api4/team.go+31 5 modified
    @@ -126,17 +126,20 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     		return
     	}
     
    +	// Setting AllowOpenInvite or AllowedDomains requires PermissionInviteUser, matching updateTeam/patchTeam.
    +	if (team.AllowOpenInvite || team.AllowedDomains != "") && !creatorCanInviteUsersOnTeam(c, &team) {
    +		c.SetPermissionError(model.PermissionInviteUser)
    +		return
    +	}
    +
     	rteam, err := c.App.CreateTeamWithUser(c.AppContext, &team, c.AppContext.Session().UserId)
     	if err != nil {
     		c.Err = err
     		return
     	}
     
    -	// Don't sanitize the team here since the user will be a team admin and their session won't reflect that yet
    -	// instead check the scheme roles for the team and if the user has the permission to invite users
    -	_, schemeUserRole, schemeAdminRole, schemeErr := c.App.GetSchemeRolesForTeam(rteam.Id)
    -	if schemeErr != nil || !c.App.RolesGrantPermission([]string{schemeUserRole, schemeAdminRole}, model.PermissionInviteUser.Id) {
    -		// If we can't check permissions, fail secure by hiding the invite_id because the team is already created above
    +	// The creator's session doesn't yet reflect their team_admin role, so check the team's default roles directly.
    +	if !creatorCanInviteUsersOnTeam(c, rteam) {
     		rteam.InviteId = ""
     	}
     
    @@ -150,6 +153,29 @@ func createTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     	}
     }
     
    +// creatorCanInviteUsersOnTeam checks whether the creator will have PermissionInviteUser on the new team,
    +// using the team's scheme (if any) or the built-in team roles as defaults.
    +func creatorCanInviteUsersOnTeam(c *Context, team *model.Team) bool {
    +	if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionInviteUser) {
    +		return true
    +	}
    +
    +	if team.SchemeId != nil && *team.SchemeId != "" {
    +		scheme, appErr := c.App.GetScheme(*team.SchemeId)
    +		if appErr != nil {
    +			c.Logger.Warn("Failed to fetch scheme while checking invite permission for new team",
    +				mlog.String("scheme_id", *team.SchemeId),
    +				mlog.Err(appErr),
    +			)
    +			return false
    +		}
    +
    +		return c.App.RolesGrantPermission([]string{scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole}, model.PermissionInviteUser.Id)
    +	}
    +
    +	return c.App.RolesGrantPermission([]string{model.TeamUserRoleId, model.TeamAdminRoleId}, model.PermissionInviteUser.Id)
    +}
    +
     func getTeam(c *Context, w http.ResponseWriter, r *http.Request) {
     	c.RequireTeamId()
     	if c.Err != nil {
    
  • server/channels/api4/team_test.go+201 8 modified
    @@ -280,23 +280,216 @@ func TestCreateTeamInviteIdHiddenWithoutInvitePermission(t *testing.T) {
     	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
     	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
     
    -	// Remove PermissionInviteUser from the default team user role
    +	// team_admin inherits from team_user by default, so removing from team_user is enough.
     	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
     
    -	// Regular user creates a team - InviteId should be hidden
    -	// since the team user role lacks invite permission
     	rteam, _, err := th.Client.CreateTeam(context.Background(), &model.Team{
    -		DisplayName:    "Team Without Invite Permission",
    -		Name:           GenerateTestTeamName(),
    -		Email:          th.GenerateTestEmail(),
    -		Type:           model.TeamOpen,
    -		AllowedDomains: "simulator.amazonses.com,localhost",
    +		DisplayName: "Team Without Invite Permission",
    +		Name:        GenerateTestTeamName(),
    +		Email:       th.GenerateTestEmail(),
    +		Type:        model.TeamOpen,
     	})
     	require.NoError(t, err)
     	require.NotEmpty(t, rteam.Email, "should not have sanitized email")
     	require.Empty(t, rteam.InviteId, "should have hidden invite_id when user lacks invite permission")
     }
     
    +func TestCreateTeamInviteUserPermission(t *testing.T) {
    +	th := Setup(t)
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
    +	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
    +
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	t.Run("AllowOpenInvite=true is rejected with 403", func(t *testing.T) {
    +		_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Open Invite Team Without Permission",
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			AllowOpenInvite: true,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("non-empty AllowedDomains is rejected with 403", func(t *testing.T) {
    +		creatorDomain := strings.SplitN(th.BasicUser.Email, "@", 2)[1]
    +
    +		_, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:    "Restricted Domains Team Without Permission",
    +			Name:           GenerateTestTeamName(),
    +			Email:          th.GenerateTestEmail(),
    +			Type:           model.TeamOpen,
    +			AllowedDomains: creatorDomain,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("team without invite-restricted fields is still created", func(t *testing.T) {
    +		createdTeam, resp, err := th.Client.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Plain Team Without Invite Permission",
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.False(t, createdTeam.AllowOpenInvite)
    +		assert.Empty(t, createdTeam.AllowedDomains)
    +		assert.Empty(t, createdTeam.InviteId, "InviteId should be hidden from creators that can't invite users")
    +	})
    +}
    +
    +func TestCreateTeamInviteUserPermissionSystemAdmin(t *testing.T) {
    +	th := Setup(t)
    +	creatorDomain := strings.SplitN(th.SystemAdminUser.Email, "@", 2)[1]
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
    +	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
    +
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	createdTeam, resp, err := th.SystemAdminClient.CreateTeam(context.Background(), &model.Team{
    +		DisplayName:     "System Admin Team With Invite Permission",
    +		Name:            GenerateTestTeamName(),
    +		Email:           th.GenerateTestEmail(),
    +		Type:            model.TeamOpen,
    +		AllowOpenInvite: true,
    +		AllowedDomains:  creatorDomain,
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, resp)
    +
    +	assert.True(t, createdTeam.AllowOpenInvite, "system admins should still be able to create open invite teams")
    +	assert.Equal(t, creatorDomain, createdTeam.AllowedDomains, "system admins should still be able to set allowed domains")
    +	require.NotEmpty(t, createdTeam.InviteId, "system admins should receive the invite_id when they can invite users")
    +
    +	persistedTeam, _, err := th.SystemAdminClient.GetTeam(context.Background(), createdTeam.Id, "")
    +	require.NoError(t, err)
    +
    +	assert.True(t, persistedTeam.AllowOpenInvite, "system admins should persist open invite team settings")
    +	assert.Equal(t, creatorDomain, persistedTeam.AllowedDomains, "system admins should persist allowed domains")
    +}
    +
    +// Exercises the scheme branch of creatorCanInviteUsersOnTeam.
    +func TestCreateTeamInviteUserPermissionScheme(t *testing.T) {
    +	th := Setup(t)
    +	th.App.Srv().SetLicense(model.NewTestLicense("custom_permissions_schemes"))
    +	err := th.App.SetPhase2PermissionsMigrationStatus(true)
    +	require.NoError(t, err)
    +
    +	defaultRolePermissions := th.SaveDefaultRolePermissions(t)
    +	defer th.RestoreDefaultRolePermissions(t, defaultRolePermissions)
    +
    +	// Remove InviteUser from the built-in team roles; new schemes inherit from these at creation, so their defaults start without it too.
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamUserRoleId)
    +	th.RemovePermissionFromRole(t, model.PermissionInviteUser.Id, model.TeamAdminRoleId)
    +
    +	// SystemManager has SchemeWrite but not system-level InviteUser, so it exercises the scheme branch.
    +	th.LoginSystemManager(t)
    +	managerClient := th.SystemManagerClient
    +
    +	t.Run("scheme admin role grants InviteUser - create succeeds", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +		require.NotEmpty(t, scheme.DefaultTeamAdminRole)
    +		require.NotEmpty(t, scheme.DefaultTeamUserRole)
    +
    +		th.AddPermissionToRole(t, model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Scheme Team With Invite " + model.NewId(),
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			SchemeId:        &scheme.Id,
    +			AllowOpenInvite: true,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.True(t, rteam.AllowOpenInvite, "AllowOpenInvite should be preserved when scheme grants InviteUser")
    +		assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser")
    +	})
    +
    +	t.Run("scheme roles do not grant InviteUser - create is rejected", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +
    +		_, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName:     "Scheme Team Without Invite " + model.NewId(),
    +			Name:            GenerateTestTeamName(),
    +			Email:           th.GenerateTestEmail(),
    +			Type:            model.TeamOpen,
    +			SchemeId:        &scheme.Id,
    +			AllowOpenInvite: true,
    +		})
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, resp)
    +	})
    +
    +	t.Run("scheme roles do not grant InviteUser but no invite-restricted fields - create succeeds with hidden invite_id", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Scheme Team Without Invite Fields " + model.NewId(),
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +			SchemeId:    &scheme.Id,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.Empty(t, rteam.InviteId, "InviteId should be hidden when scheme does not grant InviteUser")
    +	})
    +
    +	t.Run("scheme admin role grants InviteUser but no invite-restricted fields - create succeeds with invite_id", func(t *testing.T) {
    +		scheme, _, err := th.SystemAdminClient.CreateScheme(context.Background(), &model.Scheme{
    +			DisplayName: "dn_" + model.NewId(),
    +			Name:        model.NewId(),
    +			Scope:       model.SchemeScopeTeam,
    +		})
    +		require.NoError(t, err)
    +		require.NotEmpty(t, scheme.DefaultTeamAdminRole)
    +
    +		th.AddPermissionToRole(t, model.PermissionInviteUser.Id, scheme.DefaultTeamAdminRole)
    +
    +		rteam, resp, err := managerClient.CreateTeam(context.Background(), &model.Team{
    +			DisplayName: "Scheme Team Invite Via Defaults Only " + model.NewId(),
    +			Name:        GenerateTestTeamName(),
    +			Email:       th.GenerateTestEmail(),
    +			Type:        model.TeamOpen,
    +			SchemeId:    &scheme.Id,
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, resp)
    +
    +		assert.False(t, rteam.AllowOpenInvite)
    +		assert.Empty(t, rteam.AllowedDomains)
    +		assert.NotEmpty(t, rteam.InviteId, "InviteId should be returned when scheme grants InviteUser without open invite or domain restrictions")
    +	})
    +}
    +
     func TestGetTeam(t *testing.T) {
     	mainHelper.Parallel(t)
     
    
202d125afa87

[release-10.11] MM-68547: Tighten authorization on group syncable link and patch endpoints (#36434)

https://github.com/mattermost/mattermostMaria A NunezMay 6, 2026Fixed in 10.11.17via release-tag
8 files changed · +1006 23
  • e2e-tests/cypress/tests/integration/channels/enterprise/system_console/group_configuration_spec.js+6 2 modified
    @@ -394,8 +394,10 @@ describe('group configuration', () => {
                 // # Save settings
                 savePage();
     
    -            // * Check the groupteam via the API to ensure its role wasn't updated
    +            // * Check the groupteam via the API to ensure the team was
    +            // removed (delete_at != 0) and its role wasn't updated.
                 cy.apiGetGroupTeam(groupID, testTeam.id).then(({body}) => {
    +                expect(body.delete_at).to.not.eq(0);
                     expect(body.scheme_admin).to.eq(false);
                 });
             });
    @@ -519,8 +521,10 @@ describe('group configuration', () => {
                 // # Save settings
                 savePage();
     
    -            // * Check the groupteam via the API to ensure its role wasn't updated
    +            // * Check the groupteam via the API to ensure the channel was
    +            // removed (delete_at != 0) and its role wasn't updated.
                 cy.apiGetGroupChannel(groupID, testChannel.id).then(({body}) => {
    +                expect(body.delete_at).to.not.eq(0);
                     expect(body.scheme_admin).to.eq(false);
                 });
             });
    
  • server/channels/api4/group.go+68 6 modified
    @@ -377,10 +377,35 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
     		return
     	}
     
    -	groupSyncable := &model.GroupSyncable{
    -		GroupId:    c.Params.GroupId,
    -		SyncableId: syncableID,
    -		Type:       syncableType,
    +	appErr = verifySchemeAdminAssignmentPermission(c, syncableType, syncableID, patch)
    +	if appErr != nil {
    +		appErr.Where = "Api4.linkGroupSyncable"
    +		c.Err = appErr
    +		return
    +	}
    +
    +	// Upsert onto the existing row only when it is currently active so
    +	// unspecified fields are preserved. A fresh link, or a re-link of a
    +	// soft-deleted row, starts from a zero-value struct so that fields
    +	// the caller did not (or was not authorized to) set are not carried
    +	// over from the previous incarnation. The downstream upsert clears
    +	// DeleteAt when re-activating.
    +	existing, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType)
    +	if appErr != nil && appErr.StatusCode != http.StatusNotFound {
    +		appErr.Where = "Api4.linkGroupSyncable"
    +		c.Err = appErr
    +		return
    +	}
    +
    +	var groupSyncable *model.GroupSyncable
    +	if existing != nil && existing.DeleteAt == 0 {
    +		groupSyncable = existing
    +	} else {
    +		groupSyncable = &model.GroupSyncable{
    +			GroupId:    c.Params.GroupId,
    +			SyncableId: syncableID,
    +			Type:       syncableType,
    +		}
     	}
     	groupSyncable.Patch(patch)
     	groupSyncable, appErr = c.App.UpsertGroupSyncable(groupSyncable)
    @@ -392,8 +417,9 @@ func linkGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
     	auditRec.AddEventResultState(groupSyncable)
     	auditRec.AddEventObjectType("group_syncable")
     
    +	syncRoles := patch.SchemeAdmin != nil
     	c.App.Srv().Go(func() {
    -		c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId)
    +		c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId, syncRoles)
     	})
     
     	w.WriteHeader(http.StatusCreated)
    @@ -560,6 +586,13 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
     		return
     	}
     
    +	appErr = verifySchemeAdminAssignmentPermission(c, syncableType, syncableID, patch)
    +	if appErr != nil {
    +		appErr.Where = "Api4.patchGroupSyncable"
    +		c.Err = appErr
    +		return
    +	}
    +
     	groupSyncable, appErr := c.App.GetGroupSyncable(c.Params.GroupId, syncableID, syncableType)
     	if appErr != nil {
     		c.Err = appErr
    @@ -577,8 +610,9 @@ func patchGroupSyncable(c *Context, w http.ResponseWriter, r *http.Request) {
     	auditRec.AddEventResultState(groupSyncable)
     	auditRec.AddEventObjectType("group_syncable")
     
    +	syncRoles := patch.SchemeAdmin != nil
     	c.App.Srv().Go(func() {
    -		c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId)
    +		c.App.SyncRolesAndMembership(c.AppContext, syncableID, syncableType, c.Params.GroupId, syncRoles)
     	})
     
     	b, err := json.Marshal(groupSyncable)
    @@ -710,6 +744,34 @@ func verifyLinkUnlinkPermission(c *Context, syncableType model.GroupSyncableType
     	return nil
     }
     
    +// verifySchemeAdminAssignmentPermission requires the caller to hold the
    +// role-management permission for the target syncable
    +// (manage_team_roles / manage_channel_roles), or the sysconsole groups
    +// write permission, before an explicit SchemeAdmin value in the patch is
    +// accepted. A nil patch.SchemeAdmin is a no-op.
    +func verifySchemeAdminAssignmentPermission(c *Context, syncableType model.GroupSyncableType, syncableID string, patch *model.GroupSyncablePatch) *model.AppError {
    +	if patch == nil || patch.SchemeAdmin == nil {
    +		return nil
    +	}
    +
    +	if c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteUserManagementGroups) {
    +		return nil
    +	}
    +
    +	switch syncableType {
    +	case model.GroupSyncableTypeTeam:
    +		if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), syncableID, model.PermissionManageTeamRoles) {
    +			return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{model.PermissionManageTeamRoles})
    +		}
    +	case model.GroupSyncableTypeChannel:
    +		if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), syncableID, model.PermissionManageChannelRoles); !ok {
    +			return model.MakePermissionError(c.AppContext.Session(), []*model.Permission{model.PermissionManageChannelRoles})
    +		}
    +	}
    +
    +	return nil
    +}
    +
     func getGroupMembers(c *Context, w http.ResponseWriter, r *http.Request) {
     	permissionErr := requireLicense(c)
     	if permissionErr != nil {
    
  • server/channels/api4/group_test.go+733 0 modified
    @@ -2847,3 +2847,736 @@ func TestDeleteMembersFromGroup(t *testing.T) {
     		CheckBadRequestStatus(t, response)
     	})
     }
    +
    +// newSchemeAdminTestLdapGroup creates a fresh LDAP-source group with
    +// AllowReference=true.
    +func newSchemeAdminTestLdapGroup(t *testing.T, th *TestHelper) *model.Group {
    +	t.Helper()
    +	id := model.NewId()
    +	g, appErr := th.App.CreateGroup(&model.Group{
    +		DisplayName:    "dn_" + id,
    +		Name:           model.NewPointer("name" + id),
    +		Source:         model.GroupSourceLdap,
    +		Description:    "description_" + id,
    +		RemoteId:       model.NewPointer(model.NewId()),
    +		AllowReference: true,
    +	})
    +	require.Nil(t, appErr)
    +	return g
    +}
    +
    +// findPersistedGroupSyncable returns the persisted GroupSyncable for a
    +// given (groupID, syncableID, syncableType) tuple, including SchemeAdmin.
    +func findPersistedGroupSyncable(t *testing.T, th *TestHelper, groupID, syncableID string, syncableType model.GroupSyncableType) *model.GroupSyncable {
    +	t.Helper()
    +	syncables, appErr := th.App.GetGroupSyncables(groupID, syncableType)
    +	require.Nil(t, appErr)
    +	for _, s := range syncables {
    +		if s.SyncableId == syncableID {
    +			return s
    +		}
    +	}
    +	return nil
    +}
    +
    +func TestLinkGroupTeam_SchemeAdminRequiresElevatedPermission(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	schemeAdminTrue := &model.GroupSyncablePatch{
    +		AutoAdd:     model.NewPointer(true),
    +		SchemeAdmin: model.NewPointer(true),
    +	}
    +
    +	t.Run("regular team user with invite_user must NOT be able to set scheme_admin: true", func(t *testing.T) {
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +
    +		groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +		assert.Nil(t, groupSyncable)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		if persisted != nil {
    +			assert.False(t, persisted.SchemeAdmin)
    +		}
    +	})
    +
    +	t.Run("system admin can still set scheme_admin: true", func(t *testing.T) {
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular team user can still link with scheme_admin omitted", func(t *testing.T) {
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		}
    +		groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		require.NotNil(t, groupSyncable)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular team user must NOT be able to link with scheme_admin: false explicitly", func(t *testing.T) {
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(false),
    +		}
    +		groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +		assert.Nil(t, groupSyncable)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		if persisted != nil {
    +			assert.False(t, persisted.SchemeAdmin)
    +		}
    +	})
    +}
    +
    +func TestLinkGroupChannel_SchemeAdminRequiresElevatedPermission(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	// A regular user can only link a channel syncable when the group is
    +	// already linked to the parent team, so seed the team link as sysadmin.
    +	mkLinkedGroup := func(t *testing.T) *model.Group {
    +		t.Helper()
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		return g
    +	}
    +
    +	schemeAdminTrue := &model.GroupSyncablePatch{
    +		AutoAdd:     model.NewPointer(true),
    +		SchemeAdmin: model.NewPointer(true),
    +	}
    +
    +	t.Run("regular channel user with manage_*_channel_members must NOT be able to set scheme_admin: true", func(t *testing.T) {
    +		g := mkLinkedGroup(t)
    +
    +		groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +		assert.Nil(t, groupSyncable)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		if persisted != nil {
    +			assert.False(t, persisted.SchemeAdmin)
    +		}
    +	})
    +
    +	t.Run("system admin can still set scheme_admin: true", func(t *testing.T) {
    +		g := mkLinkedGroup(t)
    +
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular channel user can still link with scheme_admin omitted", func(t *testing.T) {
    +		g := mkLinkedGroup(t)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		}
    +		groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		require.NotNil(t, groupSyncable)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular channel user must NOT be able to link with scheme_admin: false explicitly", func(t *testing.T) {
    +		g := mkLinkedGroup(t)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(false),
    +		}
    +		groupSyncable, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +		assert.Nil(t, groupSyncable)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		if persisted != nil {
    +			assert.False(t, persisted.SchemeAdmin)
    +		}
    +	})
    +}
    +
    +func TestPatchGroupTeam_SchemeAdminRequiresElevatedPermission(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	// schemeAdmin controls the seeded SchemeAdmin value on the team syncable.
    +	setupLinkedGroup := func(t *testing.T, schemeAdmin bool) *model.Group {
    +		t.Helper()
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(schemeAdmin),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		return g
    +	}
    +
    +	schemeAdminTrue := &model.GroupSyncablePatch{
    +		SchemeAdmin: model.NewPointer(true),
    +	}
    +
    +	schemeAdminFalse := &model.GroupSyncablePatch{
    +		SchemeAdmin: model.NewPointer(false),
    +	}
    +
    +	t.Run("regular team user with invite_user must NOT be able to patch scheme_admin: true", func(t *testing.T) {
    +		g := setupLinkedGroup(t, false)
    +
    +		_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("system admin can still patch scheme_admin: true", func(t *testing.T) {
    +		g := setupLinkedGroup(t, false)
    +
    +		_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular team user can still patch other fields with scheme_admin omitted", func(t *testing.T) {
    +		g := setupLinkedGroup(t, true)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(false),
    +		}
    +		_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.AutoAdd)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular team user must NOT be able to patch scheme_admin: false", func(t *testing.T) {
    +		g := setupLinkedGroup(t, true)
    +
    +		_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("system admin can still patch scheme_admin: false", func(t *testing.T) {
    +		g := setupLinkedGroup(t, true)
    +
    +		_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("sysconsole_write_user_management_groups holder can patch scheme_admin in either direction", func(t *testing.T) {
    +		// system_manager bundles sysconsole_write_user_management_groups,
    +		// the override honoured by verifySchemeAdminAssignmentPermission.
    +		th.LoginSystemManager()
    +
    +		gPromote := setupLinkedGroup(t, false)
    +		_, response, err := th.SystemManagerClient.PatchGroupSyncable(context.Background(), gPromote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminTrue)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +		persistedPromote := findPersistedGroupSyncable(t, th, gPromote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persistedPromote)
    +		assert.True(t, persistedPromote.SchemeAdmin)
    +
    +		gDemote := setupLinkedGroup(t, true)
    +		_, response, err = th.SystemManagerClient.PatchGroupSyncable(context.Background(), gDemote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, schemeAdminFalse)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +		persistedDemote := findPersistedGroupSyncable(t, th, gDemote.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persistedDemote)
    +		assert.False(t, persistedDemote.SchemeAdmin)
    +	})
    +}
    +
    +func TestPatchGroupChannel_SchemeAdminRequiresElevatedPermission(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	// schemeAdmin controls the seeded SchemeAdmin value on the channel
    +	// syncable. The team syncable is seeded so the channel link succeeds.
    +	setupLinkedGroup := func(t *testing.T, schemeAdmin bool) *model.Group {
    +		t.Helper()
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +
    +		_, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(schemeAdmin),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		return g
    +	}
    +
    +	schemeAdminTrue := &model.GroupSyncablePatch{
    +		SchemeAdmin: model.NewPointer(true),
    +	}
    +
    +	schemeAdminFalse := &model.GroupSyncablePatch{
    +		SchemeAdmin: model.NewPointer(false),
    +	}
    +
    +	t.Run("regular channel user with manage_*_channel_members must NOT be able to patch scheme_admin: true", func(t *testing.T) {
    +		g := setupLinkedGroup(t, false)
    +
    +		_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("system admin can still patch scheme_admin: true", func(t *testing.T) {
    +		g := setupLinkedGroup(t, false)
    +
    +		_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular channel user can still patch other fields with scheme_admin omitted", func(t *testing.T) {
    +		g := setupLinkedGroup(t, true)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(false),
    +		}
    +		_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.AutoAdd)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular channel user must NOT be able to patch scheme_admin: false", func(t *testing.T) {
    +		g := setupLinkedGroup(t, true)
    +
    +		_, response, err := th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse)
    +		require.Error(t, err)
    +		CheckForbiddenStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("system admin can still patch scheme_admin: false", func(t *testing.T) {
    +		g := setupLinkedGroup(t, true)
    +
    +		_, response, err := th.SystemAdminClient.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("sysconsole_write_user_management_groups holder can patch scheme_admin in either direction", func(t *testing.T) {
    +		// system_manager bundles sysconsole_write_user_management_groups,
    +		// the override honoured by verifySchemeAdminAssignmentPermission.
    +		th.LoginSystemManager()
    +
    +		gPromote := setupLinkedGroup(t, false)
    +		_, response, err := th.SystemManagerClient.PatchGroupSyncable(context.Background(), gPromote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminTrue)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +		persistedPromote := findPersistedGroupSyncable(t, th, gPromote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persistedPromote)
    +		assert.True(t, persistedPromote.SchemeAdmin)
    +
    +		gDemote := setupLinkedGroup(t, true)
    +		_, response, err = th.SystemManagerClient.PatchGroupSyncable(context.Background(), gDemote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, schemeAdminFalse)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +		persistedDemote := findPersistedGroupSyncable(t, th, gDemote.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persistedDemote)
    +		assert.False(t, persistedDemote.SchemeAdmin)
    +	})
    +}
    +
    +func TestLinkGroupTeam_LinkOnExistingPreservesSchemeAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	seedSchemeAdminTrue := func(t *testing.T) *model.Group {
    +		t.Helper()
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(true),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		require.True(t, persisted.SchemeAdmin)
    +		return g
    +	}
    +
    +	t.Run("regular team user calling LINK with scheme_admin omitted must not change persisted scheme_admin", func(t *testing.T) {
    +		g := seedSchemeAdminTrue(t)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		}
    +		_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular team user calling LINK with scheme_admin: false must not change persisted scheme_admin", func(t *testing.T) {
    +		g := seedSchemeAdminTrue(t)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(false),
    +		}
    +		_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +}
    +
    +func TestLinkGroupChannel_LinkOnExistingPreservesSchemeAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	seedSchemeAdminTrue := func(t *testing.T) *model.Group {
    +		t.Helper()
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +
    +		_, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(true),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		require.True(t, persisted.SchemeAdmin)
    +		return g
    +	}
    +
    +	t.Run("regular channel user calling LINK with scheme_admin omitted must not change persisted scheme_admin", func(t *testing.T) {
    +		g := seedSchemeAdminTrue(t)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		}
    +		_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +
    +	t.Run("regular channel user calling LINK with scheme_admin: false must not change persisted scheme_admin", func(t *testing.T) {
    +		g := seedSchemeAdminTrue(t)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(false),
    +		}
    +		_, _, _ = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel)
    +		require.NotNil(t, persisted)
    +		assert.True(t, persisted.SchemeAdmin)
    +	})
    +}
    +
    +func TestLinkGroupTeam_LinkOnSoftDeletedDoesNotPreserveSchemeAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	t.Run("regular team user re-linking a soft-deleted syncable with scheme_admin omitted must persist scheme_admin: false", func(t *testing.T) {
    +		g := newSchemeAdminTestLdapGroup(t, th)
    +
    +		_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +			AutoAdd:     model.NewPointer(true),
    +			SchemeAdmin: model.NewPointer(true),
    +		})
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +
    +		response, err = th.SystemAdminClient.UnlinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NoError(t, err)
    +		CheckOKStatus(t, response)
    +
    +		patch := &model.GroupSyncablePatch{
    +			AutoAdd: model.NewPointer(true),
    +		}
    +		_, response, err = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +		require.NoError(t, err)
    +		CheckCreatedStatus(t, response)
    +
    +		persisted := findPersistedGroupSyncable(t, th, g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam)
    +		require.NotNil(t, persisted)
    +		assert.False(t, persisted.SchemeAdmin)
    +	})
    +}
    +
    +func TestPatchGroupTeam_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	g := newSchemeAdminTestLdapGroup(t, th)
    +
    +	_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	th.UpdateUserToTeamAdmin(th.BasicUser2, th.BasicTeam)
    +
    +	patch := &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(false),
    +	}
    +	_, response, err = th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +	require.NoError(t, err)
    +	CheckOKStatus(t, response)
    +
    +	time.Sleep(2 * time.Second)
    +
    +	tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
    +	require.Nil(t, appErr)
    +	assert.True(t, tm.SchemeAdmin)
    +}
    +
    +func TestPatchGroupChannel_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	g := newSchemeAdminTestLdapGroup(t, th)
    +
    +	_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	_, response, err = th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	th.MakeUserChannelAdmin(th.BasicUser2, th.BasicChannel)
    +
    +	patch := &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(false),
    +	}
    +	_, response, err = th.Client.PatchGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +	require.NoError(t, err)
    +	CheckOKStatus(t, response)
    +
    +	time.Sleep(2 * time.Second)
    +
    +	cm, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser2.Id)
    +	require.Nil(t, appErr)
    +	assert.True(t, cm.SchemeAdmin)
    +}
    +
    +func TestLinkGroupTeam_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	g := newSchemeAdminTestLdapGroup(t, th)
    +
    +	th.UpdateUserToTeamAdmin(th.BasicUser2, th.BasicTeam)
    +
    +	patch := &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	}
    +	_, response, err := th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, patch)
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	time.Sleep(2 * time.Second)
    +
    +	tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
    +	require.Nil(t, appErr)
    +	assert.True(t, tm.SchemeAdmin)
    +}
    +
    +func TestLinkGroupChannel_OmittedSchemeAdminDoesNotDemoteDirectAdmin(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	g := newSchemeAdminTestLdapGroup(t, th)
    +
    +	_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	th.MakeUserChannelAdmin(th.BasicUser2, th.BasicChannel)
    +
    +	patch := &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	}
    +	_, response, err = th.Client.LinkGroupSyncable(context.Background(), g.Id, th.BasicChannel.Id, model.GroupSyncableTypeChannel, patch)
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	time.Sleep(2 * time.Second)
    +
    +	cm, appErr := th.App.GetChannelMember(th.Context, th.BasicChannel.Id, th.BasicUser2.Id)
    +	require.Nil(t, appErr)
    +	assert.True(t, cm.SchemeAdmin)
    +}
    +
    +func TestLinkGroupTeam_SchemeAdminTruePromotesGroupMembers(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	g := newSchemeAdminTestLdapGroup(t, th)
    +
    +	_, appErr := th.App.UpsertGroupMember(g.Id, th.BasicUser2.Id)
    +	require.Nil(t, appErr)
    +
    +	_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +		AutoAdd:     model.NewPointer(true),
    +		SchemeAdmin: model.NewPointer(true),
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	time.Sleep(2 * time.Second)
    +
    +	tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, th.BasicUser2.Id)
    +	require.Nil(t, appErr)
    +	assert.True(t, tm.SchemeAdmin)
    +}
    +
    +func TestLinkGroupTeam_AutoAddOnlyAddsGroupMembers(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	th.App.Srv().SetLicense(model.NewTestLicense("ldap"))
    +
    +	g := newSchemeAdminTestLdapGroup(t, th)
    +
    +	newUser := th.CreateUser()
    +	_, appErr := th.App.UpsertGroupMember(g.Id, newUser.Id)
    +	require.Nil(t, appErr)
    +
    +	_, appErr = th.App.GetTeamMember(th.Context, th.BasicTeam.Id, newUser.Id)
    +	require.NotNil(t, appErr)
    +
    +	_, response, err := th.SystemAdminClient.LinkGroupSyncable(context.Background(), g.Id, th.BasicTeam.Id, model.GroupSyncableTypeTeam, &model.GroupSyncablePatch{
    +		AutoAdd: model.NewPointer(true),
    +	})
    +	require.NoError(t, err)
    +	CheckCreatedStatus(t, response)
    +
    +	time.Sleep(2 * time.Second)
    +
    +	tm, appErr := th.App.GetTeamMember(th.Context, th.BasicTeam.Id, newUser.Id)
    +	require.Nil(t, appErr)
    +	assert.Equal(t, newUser.Id, tm.UserId)
    +}
    
  • server/channels/app/syncables.go+8 6 modified
    @@ -268,18 +268,20 @@ func (a *App) SyncSyncableRoles(rctx request.CTX, syncableID string, syncableTyp
     	return nil
     }
     
    -// SyncRolesAndMembership updates the SchemeAdmin status and membership of all of the members of the given
    -// syncable.
    -func (a *App) SyncRolesAndMembership(rctx request.CTX, syncableID string, syncableType model.GroupSyncableType, groupID string) {
    +// 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
     	}
     
    -	appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType)
    -	if appErr != nil {
    -		rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr))
    +	if syncRoles {
    +		appErr = a.SyncSyncableRoles(rctx, syncableID, syncableType)
    +		if appErr != nil {
    +			rctx.Logger().Warn("Error syncing syncable roles", mlog.Err(appErr))
    +		}
     	}
     
     	var since int64
    
  • server/channels/app/syncables_test.go+144 0 modified
    @@ -677,3 +677,147 @@ func TestSyncSyncableRoles(t *testing.T) {
     		require.True(t, cm.SchemeAdmin)
     	}
     }
    +
    +func TestSyncRolesAndMembership_RoleSyncGate(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	setup := func(t *testing.T) (*model.Team, *model.Channel, *model.Group, *model.User) {
    +		t.Helper()
    +
    +		team := th.CreateTeam()
    +		channel := th.CreateChannel(th.Context, team)
    +		group := th.CreateGroup()
    +
    +		_, err := th.App.UpsertGroupSyncable(&model.GroupSyncable{
    +			SyncableId: team.Id,
    +			Type:       model.GroupSyncableTypeTeam,
    +			GroupId:    group.Id,
    +			AutoAdd:    true,
    +		})
    +		require.Nil(t, err)
    +
    +		_, err = th.App.UpsertGroupSyncable(&model.GroupSyncable{
    +			SyncableId: channel.Id,
    +			Type:       model.GroupSyncableTypeChannel,
    +			GroupId:    group.Id,
    +			AutoAdd:    true,
    +		})
    +		require.Nil(t, err)
    +
    +		directAdmin := th.CreateUser()
    +		_, appErr := th.App.AddTeamMember(th.Context, team.Id, directAdmin.Id)
    +		require.Nil(t, appErr)
    +		_, appErr = th.App.AddUserToChannel(th.Context, directAdmin, channel, false)
    +		require.Nil(t, appErr)
    +
    +		tm, storeErr := th.App.Srv().Store().Team().GetMember(th.Context, team.Id, directAdmin.Id)
    +		require.NoError(t, storeErr)
    +		tm.SchemeAdmin = true
    +		_, storeErr = th.App.Srv().Store().Team().UpdateMember(th.Context, tm)
    +		require.NoError(t, storeErr)
    +
    +		cm, storeErr := th.App.Srv().Store().Channel().GetMember(th.Context.Context(), channel.Id, directAdmin.Id)
    +		require.NoError(t, storeErr)
    +		cm.SchemeAdmin = true
    +		_, storeErr = th.App.Srv().Store().Channel().UpdateMember(th.Context, cm)
    +		require.NoError(t, storeErr)
    +
    +		return team, channel, group, directAdmin
    +	}
    +
    +	t.Run("syncRoles=false preserves the existing SchemeAdmin on team members", func(t *testing.T) {
    +		team, _, group, directAdmin := setup(t)
    +
    +		th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, false)
    +
    +		tm, appErr := th.App.GetTeamMember(th.Context, team.Id, directAdmin.Id)
    +		require.Nil(t, appErr)
    +		assert.True(t, tm.SchemeAdmin)
    +	})
    +
    +	t.Run("syncRoles=false preserves the existing SchemeAdmin on channel members", func(t *testing.T) {
    +		_, channel, group, directAdmin := setup(t)
    +
    +		th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, false)
    +
    +		cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, directAdmin.Id)
    +		require.Nil(t, appErr)
    +		assert.True(t, cm.SchemeAdmin)
    +	})
    +
    +	t.Run("syncRoles=true reconciles team SchemeAdmin against PermittedSyncableAdmins", func(t *testing.T) {
    +		team, _, group, directAdmin := setup(t)
    +
    +		th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, true)
    +
    +		tm, appErr := th.App.GetTeamMember(th.Context, team.Id, directAdmin.Id)
    +		require.Nil(t, appErr)
    +		assert.False(t, tm.SchemeAdmin)
    +	})
    +
    +	t.Run("syncRoles=true reconciles channel SchemeAdmin against PermittedSyncableAdmins", func(t *testing.T) {
    +		_, channel, group, directAdmin := setup(t)
    +
    +		th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, true)
    +
    +		cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, directAdmin.Id)
    +		require.Nil(t, appErr)
    +		assert.False(t, cm.SchemeAdmin)
    +	})
    +}
    +
    +func TestSyncRolesAndMembership_AlwaysSyncsMembership(t *testing.T) {
    +	mainHelper.Parallel(t)
    +	th := Setup(t).InitBasic()
    +
    +	setup := func(t *testing.T) (*model.Team, *model.Channel, *model.Group, *model.User) {
    +		t.Helper()
    +
    +		team := th.CreateTeam()
    +		channel := th.CreateChannel(th.Context, team)
    +		group := th.CreateGroup()
    +
    +		_, err := th.App.UpsertGroupSyncable(&model.GroupSyncable{
    +			SyncableId: team.Id,
    +			Type:       model.GroupSyncableTypeTeam,
    +			GroupId:    group.Id,
    +			AutoAdd:    true,
    +		})
    +		require.Nil(t, err)
    +
    +		_, err = th.App.UpsertGroupSyncable(&model.GroupSyncable{
    +			SyncableId: channel.Id,
    +			Type:       model.GroupSyncableTypeChannel,
    +			GroupId:    group.Id,
    +			AutoAdd:    true,
    +		})
    +		require.Nil(t, err)
    +
    +		groupMember := th.CreateUser()
    +		_, err = th.App.UpsertGroupMember(group.Id, groupMember.Id)
    +		require.Nil(t, err)
    +
    +		return team, channel, group, groupMember
    +	}
    +
    +	t.Run("syncRoles=false still adds group members to the team", func(t *testing.T) {
    +		team, _, group, groupMember := setup(t)
    +
    +		th.App.SyncRolesAndMembership(th.Context, team.Id, model.GroupSyncableTypeTeam, group.Id, false)
    +
    +		tm, appErr := th.App.GetTeamMember(th.Context, team.Id, groupMember.Id)
    +		require.Nil(t, appErr)
    +		assert.Equal(t, groupMember.Id, tm.UserId)
    +	})
    +
    +	t.Run("syncRoles=false still adds group members to the channel", func(t *testing.T) {
    +		_, channel, group, groupMember := setup(t)
    +
    +		th.App.SyncRolesAndMembership(th.Context, channel.Id, model.GroupSyncableTypeChannel, group.Id, false)
    +
    +		cm, appErr := th.App.GetChannelMember(th.Context, channel.Id, groupMember.Id)
    +		require.Nil(t, appErr)
    +		assert.Equal(t, groupMember.Id, cm.UserId)
    +	})
    +}
    
  • server/channels/store/sqlstore/group_store.go+3 1 modified
    @@ -777,6 +777,7 @@ func (s *SqlGroupStore) getGroupSyncable(groupID string, syncableID string, sync
     		groupSyncable.DeleteAt = groupTeam.DeleteAt
     		groupSyncable.UpdateAt = groupTeam.UpdateAt
     		groupSyncable.Type = syncableType
    +		groupSyncable.SchemeAdmin = groupTeam.SchemeAdmin
     	case model.GroupSyncableTypeChannel:
     		groupChannel := result.(*groupChannel)
     		groupSyncable.SyncableId = groupChannel.ChannelId
    @@ -786,6 +787,7 @@ func (s *SqlGroupStore) getGroupSyncable(groupID string, syncableID string, sync
     		groupSyncable.DeleteAt = groupChannel.DeleteAt
     		groupSyncable.UpdateAt = groupChannel.UpdateAt
     		groupSyncable.Type = syncableType
    +		groupSyncable.SchemeAdmin = groupChannel.SchemeAdmin
     	default:
     		return nil, fmt.Errorf("unable to convert syncableType: %s", syncableType.String())
     	}
    @@ -1834,7 +1836,7 @@ func (s *SqlGroupStore) AdminRoleGroupsForSyncableMember(userID, syncableID stri
     func (s *SqlGroupStore) PermittedSyncableAdmins(syncableID string, syncableType model.GroupSyncableType) ([]string, error) {
     	builder := s.getQueryBuilder().Select("UserId").
     		From(fmt.Sprintf("Group%ss", syncableType)).
    -		Join(fmt.Sprintf("GroupMembers ON GroupMembers.GroupId = Group%ss.GroupId AND Group%[1]ss.SchemeAdmin = TRUE AND GroupMembers.DeleteAt = 0", syncableType.String())).Where(fmt.Sprintf("Group%[1]ss.%[1]sId = ?", syncableType.String()), syncableID)
    +		Join(fmt.Sprintf("GroupMembers ON GroupMembers.GroupId = Group%ss.GroupId AND Group%[1]ss.SchemeAdmin = TRUE AND Group%[1]ss.DeleteAt = 0 AND GroupMembers.DeleteAt = 0", syncableType.String())).Where(fmt.Sprintf("Group%[1]ss.%[1]sId = ?", syncableType.String()), syncableID)
     
     	var userIDs []string
     	if err := s.GetMaster().SelectBuilder(&userIDs, builder); err != nil {
    
  • server/channels/store/storetest/group_store.go+28 0 modified
    @@ -1616,9 +1616,19 @@ func testGetGroupSyncable(t *testing.T, rctx request.CTX, ss store.Store) {
     	require.Equal(t, gt1.GroupId, dgt.GroupId)
     	require.Equal(t, gt1.SyncableId, dgt.SyncableId)
     	require.Equal(t, gt1.AutoAdd, dgt.AutoAdd)
    +	require.Equal(t, gt1.SchemeAdmin, dgt.SchemeAdmin)
     	require.NotZero(t, gt1.CreateAt)
     	require.NotZero(t, gt1.UpdateAt)
     	require.Zero(t, gt1.DeleteAt)
    +
    +	// Round-trip SchemeAdmin: true through UpdateGroupSyncable and re-fetch.
    +	dgt.SchemeAdmin = true
    +	_, err = ss.Group().UpdateGroupSyncable(dgt)
    +	require.NoError(t, err)
    +
    +	dgt, err = ss.Group().GetGroupSyncable(groupTeam.GroupId, groupTeam.SyncableId, model.GroupSyncableTypeTeam)
    +	require.NoError(t, err)
    +	require.True(t, dgt.SchemeAdmin, "GetGroupSyncable must populate SchemeAdmin from the persisted row")
     }
     
     func testGetGroupSyncableErrors(t *testing.T, rctx request.CTX, ss store.Store) {
    @@ -4989,6 +4999,15 @@ func groupTestPermittedSyncableAdminsTeam(t *testing.T, rctx request.CTX, ss sto
     	// deleted group syncable no longer includes group members
     	_, err = ss.Group().DeleteGroupSyncable(group1.Id, team.Id, model.GroupSyncableTypeTeam)
     	require.NoError(t, err)
    +
    +	// The persisted row must still carry SchemeAdmin=true after soft-delete;
    +	// PermittedSyncableAdmins excludes it via the DeleteAt = 0 predicate, not
    +	// via the field having been silently cleared.
    +	deletedSyncable, err := ss.Group().GetGroupSyncable(group1.Id, team.Id, model.GroupSyncableTypeTeam)
    +	require.NoError(t, err)
    +	require.True(t, deletedSyncable.SchemeAdmin)
    +	require.NotZero(t, deletedSyncable.DeleteAt)
    +
     	actualUserIDs, err = ss.Group().PermittedSyncableAdmins(team.Id, model.GroupSyncableTypeTeam)
     	require.NoError(t, err)
     	require.ElementsMatch(t, []string{user3.Id}, actualUserIDs)
    @@ -5096,6 +5115,15 @@ func groupTestPermittedSyncableAdminsChannel(t *testing.T, rctx request.CTX, ss
     	// deleted group syncable no longer includes group members
     	_, err = ss.Group().DeleteGroupSyncable(group1.Id, channel.Id, model.GroupSyncableTypeChannel)
     	require.NoError(t, err)
    +
    +	// The persisted row must still carry SchemeAdmin=true after soft-delete;
    +	// PermittedSyncableAdmins excludes it via the DeleteAt = 0 predicate, not
    +	// via the field having been silently cleared.
    +	deletedSyncable, err := ss.Group().GetGroupSyncable(group1.Id, channel.Id, model.GroupSyncableTypeChannel)
    +	require.NoError(t, err)
    +	require.True(t, deletedSyncable.SchemeAdmin)
    +	require.NotZero(t, deletedSyncable.DeleteAt)
    +
     	actualUserIDs, err = ss.Group().PermittedSyncableAdmins(channel.Id, model.GroupSyncableTypeChannel)
     	require.NoError(t, err)
     	require.ElementsMatch(t, []string{user3.Id}, actualUserIDs)
    
  • webapp/channels/src/components/admin_console/group_settings/group_details/group_details.tsx+16 8 modified
    @@ -417,17 +417,25 @@ class GroupDetails extends React.PureComponent<Props, State> {
     
         roleChangeKey = (groupTeamOrChannel: {
             type?: SyncableType;
    +        id?: string;
             team_id?: string;
             channel_id?: string;
         }) => {
    -        let id;
    -        if (
    -            this.syncableTypeFromEntryType(groupTeamOrChannel.type) ===
    -            SyncableType.Team
    -        ) {
    -            id = groupTeamOrChannel.team_id;
    -        } else {
    -            id = groupTeamOrChannel.channel_id;
    +        // Items in itemsToRemove use a generic `id`, while items coming from
    +        // teamsToAdd/channelsToAdd use `team_id`/`channel_id`. The key must
    +        // be identical regardless of source so the dedup in
    +        // handleRemovedTeamsAndChannels and handleAddedTeamsAndChannels
    +        // matches the key produced by onChangeRoles.
    +        let id = groupTeamOrChannel.id;
    +        if (!id) {
    +            if (
    +                this.syncableTypeFromEntryType(groupTeamOrChannel.type) ===
    +                SyncableType.Team
    +            ) {
    +                id = groupTeamOrChannel.team_id;
    +            } else {
    +                id = groupTeamOrChannel.channel_id;
    +            }
             }
             return `${id}/${groupTeamOrChannel.type}`;
         };
    

Vulnerability mechanics

Root cause

"Missing authorization check for PermissionInviteUser on the AllowOpenInvite and AllowedDomains fields during team creation."

Attack vector

An authenticated user who holds `PermissionCreateTeam` but lacks `PermissionInviteUser` can send a `POST /api/v4/teams` request with `allow_open_invite: true` and/or a non-empty `allowed_domains` in the JSON body. Because the server did not check `PermissionInviteUser` during team creation, the team is created with those invite-controlled settings, making the team publicly joinable or restricting membership by domain—settings the user would not be permitted to configure on an existing team. The attack requires no special network position beyond authenticated API access.

Affected code

The vulnerability resides in the `createTeam` handler in `server/channels/api4/team.go`. The `POST /api/v4/teams` endpoint did not enforce `PermissionInviteUser` when the request body contained `allow_open_invite: true` or a non-empty `allowed_domains` field; the permission check was only applied on the update/patch endpoints. The patch introduces a new helper function `creatorCanInviteUsersOnTeam` and adds the missing guard before the team is created.

What the fix does

The patch adds a permission check in `createTeam` before calling `CreateTeamWithUser`: if the request sets `AllowOpenInvite` or `AllowedDomains`, the server now calls the new `creatorCanInviteUsersOnTeam` function to verify the user holds `PermissionInviteUser`. The helper checks the session-level permission first, then falls back to inspecting the team's scheme roles or the built-in team roles. If the check fails, the request is rejected with a 403 Forbidden, matching the behavior of the existing update/patch endpoints. The existing post-creation sanitization of `InviteId` is also refactored to use the same helper.

Preconditions

  • authThe attacker must be an authenticated user with a session on the Mattermost server.
  • authThe attacker's role must include PermissionCreateTeam but not PermissionInviteUser.
  • inputThe attacker sends a POST request to /api/v4/teams with allow_open_invite: true and/or a non-empty allowed_domains field.

Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

1

News mentions

0

No linked articles in our index yet.