VYPR
Medium severity6.7NVD Advisory· Published Jun 12, 2026

CVE-2026-6739

CVE-2026-6739

Description

Mattermost fails to require system-level permission for patching protected default system roles, allowing privilege escalation.

AI Insight

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

Mattermost fails to require system-level permission for patching protected default system roles, allowing privilege escalation.

Vulnerability

Mattermost versions 11.6.x <= 11.6.1, 11.5.x <= 11.5.4, and 10.11.x <= 10.11.15 (and 10.11.x <= 10.11.16) do not enforce proper system-level permission checks when patching protected default system roles via the role patch API. This allows authenticated users with delegated user-management permissions to modify built-in role permissions without the intended authorization [1].

Exploitation

An attacker must be an authenticated user who has been delegated user-management permissions (e.g., as a system admin or custom role with user management privileges). No additional network position or user interaction beyond normal API access is required. The attacker can craft a request to the role patch API to alter the permissions of a protected default system role, thereby elevating their own or another user's privileges [1].

Impact

Successful exploitation allows the attacker to escalate privileges beyond their intended scope. By modifying a built-in role's permissions, they can gain capabilities such as administrative control, resulting in unauthorized access, data manipulation, or system compromise [1].

Mitigation

Mattermost has released security updates for the affected versions. Users should upgrade to a fixed version as noted in the Mattermost Security Updates page. No workarounds have been published, and it is recommended to apply the patch promptly. The vulnerability is tracked under MMSA-2026-00656 [1].

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
    >=10.11.0,<=10.11.16 && >=11.5.0,<=11.5.4 && >=11.6.0,<=11.6.1+ 1 more
    • (no CPE)range: >=10.11.0,<=10.11.16 && >=11.5.0,<=11.5.4 && >=11.6.0,<=11.6.1
    • (no CPE)range: <=11.6.1, <=11.5.4, <=10.11.16

Patches

3
5e159647b16e

[MM-68393] Tighten protected role patch authorization (#36197) (#36379)

https://github.com/mattermost/mattermostMattermost BuildMay 4, 2026Fixed in 11.6.2via llm-release-walk
2 files changed · +59 2
  • server/channels/api4/role.go+2 2 modified
    @@ -151,9 +151,9 @@ func patchRole(c *Context, w http.ResponseWriter, r *http.Request) {
     	auditRec.AddEventPriorState(oldRole)
     	auditRec.AddEventObjectType("role")
     
    -	// manage_system permission is required to patch system_admin
    +	// manage_system permission is required to patch system_admin and other protected system roles.
     	requiredPermission := model.PermissionSysconsoleWriteUserManagementPermissions
    -	specialProtectedSystemRoles := append(model.NewSystemRoleIDs, model.SystemAdminRoleId)
    +	specialProtectedSystemRoles := append(append([]string{}, model.NewSystemRoleIDs...), model.SystemAdminRoleId, model.SystemUserRoleId, model.SystemGuestRoleId)
     	for _, roleID := range specialProtectedSystemRoles {
     		if oldRole.Name == roleID {
     			requiredPermission = model.PermissionManageSystem
    
  • server/channels/api4/role_test.go+57 0 modified
    @@ -323,6 +323,63 @@ func TestPatchRole(t *testing.T) {
     		Permissions: &[]string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"},
     	}
     
    +	t.Run("system manager cannot patch system_user", func(t *testing.T) {
    +		systemUserRole, appErr := th.App.GetRoleByName(th.Context, model.SystemUserRoleId)
    +		require.Nil(t, appErr)
    +
    +		originalPermissions := append([]string{}, systemUserRole.Permissions...)
    +		require.NotContains(t, originalPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		patchedPermissions := append([]string{}, originalPermissions...)
    +		patchedPermissions = append(patchedPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		th.LoginSystemManager(t)
    +
    +		_, systemUserResp, err := th.SystemManagerClient.PatchRole(context.Background(), systemUserRole.Id, &model.RolePatch{
    +			Permissions: &patchedPermissions,
    +		})
    +		if assert.Error(t, err, "system_manager must not be able to patch system_user") {
    +			CheckForbiddenStatus(t, systemUserResp)
    +		}
    +
    +		systemUserRole, appErr = th.App.GetRoleByName(th.Context, model.SystemUserRoleId)
    +		require.Nil(t, appErr)
    +		assert.ElementsMatch(t, originalPermissions, systemUserRole.Permissions)
    +		assert.NotContains(t, systemUserRole.Permissions, model.PermissionEditOtherUsers.Id, "system_manager must not be able to inject privileged permissions into system_user")
    +	})
    +
    +	t.Run("system manager cannot patch system_guest", func(t *testing.T) {
    +		license := model.NewTestLicense()
    +		license.Features.GuestAccountsPermissions = model.NewPointer(true)
    +		th.App.Srv().SetLicense(license)
    +		t.Cleanup(func() {
    +			th.App.Srv().SetLicense(nil)
    +		})
    +
    +		systemGuestRole, appErr := th.App.GetRoleByName(th.Context, model.SystemGuestRoleId)
    +		require.Nil(t, appErr)
    +
    +		originalPermissions := append([]string{}, systemGuestRole.Permissions...)
    +		require.NotContains(t, originalPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		patchedPermissions := append([]string{}, originalPermissions...)
    +		patchedPermissions = append(patchedPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		th.LoginSystemManager(t)
    +
    +		_, systemGuestResp, err := th.SystemManagerClient.PatchRole(context.Background(), systemGuestRole.Id, &model.RolePatch{
    +			Permissions: &patchedPermissions,
    +		})
    +		if assert.Error(t, err, "system_manager must not be able to patch system_guest") {
    +			CheckForbiddenStatus(t, systemGuestResp)
    +		}
    +
    +		systemGuestRole, appErr = th.App.GetRoleByName(th.Context, model.SystemGuestRoleId)
    +		require.Nil(t, appErr)
    +		assert.ElementsMatch(t, originalPermissions, systemGuestRole.Permissions)
    +		assert.NotContains(t, systemGuestRole.Permissions, model.PermissionEditOtherUsers.Id, "system_manager must not be able to inject privileged permissions into system_guest")
    +	})
    +
     	th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
     		received, _, err := client.PatchRole(context.Background(), role.Id, patch)
     		require.NoError(t, err)
    
2c89c2f6768f

[MM-68393] Tighten protected role patch authorization (#36197) (#36380)

https://github.com/mattermost/mattermostMattermost BuildMay 4, 2026Fixed in 11.5.5via llm-release-walk
2 files changed · +59 2
  • server/channels/api4/role.go+2 2 modified
    @@ -151,9 +151,9 @@ func patchRole(c *Context, w http.ResponseWriter, r *http.Request) {
     	auditRec.AddEventPriorState(oldRole)
     	auditRec.AddEventObjectType("role")
     
    -	// manage_system permission is required to patch system_admin
    +	// manage_system permission is required to patch system_admin and other protected system roles.
     	requiredPermission := model.PermissionSysconsoleWriteUserManagementPermissions
    -	specialProtectedSystemRoles := append(model.NewSystemRoleIDs, model.SystemAdminRoleId)
    +	specialProtectedSystemRoles := append(append([]string{}, model.NewSystemRoleIDs...), model.SystemAdminRoleId, model.SystemUserRoleId, model.SystemGuestRoleId)
     	for _, roleID := range specialProtectedSystemRoles {
     		if oldRole.Name == roleID {
     			requiredPermission = model.PermissionManageSystem
    
  • server/channels/api4/role_test.go+57 0 modified
    @@ -323,6 +323,63 @@ func TestPatchRole(t *testing.T) {
     		Permissions: &[]string{"create_direct_channel", "manage_incoming_webhooks", "manage_outgoing_webhooks"},
     	}
     
    +	t.Run("system manager cannot patch system_user", func(t *testing.T) {
    +		systemUserRole, appErr := th.App.GetRoleByName(th.Context, model.SystemUserRoleId)
    +		require.Nil(t, appErr)
    +
    +		originalPermissions := append([]string{}, systemUserRole.Permissions...)
    +		require.NotContains(t, originalPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		patchedPermissions := append([]string{}, originalPermissions...)
    +		patchedPermissions = append(patchedPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		th.LoginSystemManager(t)
    +
    +		_, systemUserResp, err := th.SystemManagerClient.PatchRole(context.Background(), systemUserRole.Id, &model.RolePatch{
    +			Permissions: &patchedPermissions,
    +		})
    +		if assert.Error(t, err, "system_manager must not be able to patch system_user") {
    +			CheckForbiddenStatus(t, systemUserResp)
    +		}
    +
    +		systemUserRole, appErr = th.App.GetRoleByName(th.Context, model.SystemUserRoleId)
    +		require.Nil(t, appErr)
    +		assert.ElementsMatch(t, originalPermissions, systemUserRole.Permissions)
    +		assert.NotContains(t, systemUserRole.Permissions, model.PermissionEditOtherUsers.Id, "system_manager must not be able to inject privileged permissions into system_user")
    +	})
    +
    +	t.Run("system manager cannot patch system_guest", func(t *testing.T) {
    +		license := model.NewTestLicense()
    +		license.Features.GuestAccountsPermissions = model.NewPointer(true)
    +		th.App.Srv().SetLicense(license)
    +		t.Cleanup(func() {
    +			th.App.Srv().SetLicense(nil)
    +		})
    +
    +		systemGuestRole, appErr := th.App.GetRoleByName(th.Context, model.SystemGuestRoleId)
    +		require.Nil(t, appErr)
    +
    +		originalPermissions := append([]string{}, systemGuestRole.Permissions...)
    +		require.NotContains(t, originalPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		patchedPermissions := append([]string{}, originalPermissions...)
    +		patchedPermissions = append(patchedPermissions, model.PermissionEditOtherUsers.Id)
    +
    +		th.LoginSystemManager(t)
    +
    +		_, systemGuestResp, err := th.SystemManagerClient.PatchRole(context.Background(), systemGuestRole.Id, &model.RolePatch{
    +			Permissions: &patchedPermissions,
    +		})
    +		if assert.Error(t, err, "system_manager must not be able to patch system_guest") {
    +			CheckForbiddenStatus(t, systemGuestResp)
    +		}
    +
    +		systemGuestRole, appErr = th.App.GetRoleByName(th.Context, model.SystemGuestRoleId)
    +		require.Nil(t, appErr)
    +		assert.ElementsMatch(t, originalPermissions, systemGuestRole.Permissions)
    +		assert.NotContains(t, systemGuestRole.Permissions, model.PermissionEditOtherUsers.Id, "system_manager must not be able to inject privileged permissions into system_guest")
    +	})
    +
     	th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) {
     		received, _, err := client.PatchRole(context.Background(), role.Id, patch)
     		require.NoError(t, err)
    
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: the `patchRole` function did not include `system_user` and `system_guest` in the list of protected system roles, allowing users with delegated permissions to alter built-in role permissions."

Attack vector

An authenticated attacker with delegated user-management permissions (e.g., a System Manager) sends a PATCH request to the role API targeting the `system_user` or `system_guest` role, adding privileged permissions such as `edit_other_users`. Because the authorization logic in `patchRole` did not include these roles in the `specialProtectedSystemRoles` list, the request was allowed with only `sysconsole_write_user_management_permissions` instead of requiring the higher `manage_system` permission. This lets the attacker escalate privileges by injecting permissions into default system roles.

Affected code

The vulnerability is in the `patchRole` function in `server/channels/api4/role.go`. The authorization check only required `manage_system` permission for `system_admin` and "new" system roles, but omitted `system_user` and `system_guest` from the protected list, allowing users with delegated user-management permissions to patch these built-in roles via the role patch API.

What the fix does

The patch modifies `server/channels/api4/role.go` to add `model.SystemUserRoleId` and `model.SystemGuestRoleId` to the `specialProtectedSystemRoles` slice. Previously this slice only contained `model.NewSystemRoleIDs` and `model.SystemAdminRoleId`. By including the default user and guest roles, the authorization loop now requires `manage_system` permission (instead of the weaker `sysconsole_write_user_management_permissions`) when patching these roles, preventing privilege escalation.

Preconditions

  • authAttacker must be authenticated as a user with delegated user-management permissions (e.g., System Manager role).
  • networkAttacker must have network access to the Mattermost role patch API endpoint.
  • configThe Mattermost instance must be running a vulnerable version (11.6.x <= 11.6.1, 11.5.x <= 11.5.4, 10.11.x <= 10.11.15, or 10.11.x <= 10.11.16).

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.