CVE-2026-3433
Description
Mattermost versions 11.6.x <= 11.6.1, 11.5.x <= 11.5.4, 10.11.x <= 10.11.15, and 10.11.x <= 10.11.16 expose role_updated websocket events to guest users for private teams.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Mattermost versions 11.6.x <= 11.6.1, 11.5.x <= 11.5.4, 10.11.x <= 10.11.15, and 10.11.x <= 10.11.16 expose role_updated websocket events to guest users for private teams.
Vulnerability
Mattermost fails to restrict role_updated websocket event broadcasts to members of the affected team or channel. This affects versions 11.6.x up to 11.6.1, 11.5.x up to 11.5.4, 10.11.x up to 10.11.15, and 10.11.x up to 10.11.16. An authenticated attacker with guest-level access can observe permission scheme change notifications for private teams they are not a member of via the websocket connection [1].
Exploitation
The attacker must have authenticated guest access to the Mattermost server. By maintaining a websocket connection, the attacker can receive role_updated events that are broadcast without proper channel or team membership checks. No additional privileges or user interaction are required beyond guest authentication [1].
Impact
Successful exploitation leads to information disclosure. The attacker learns about permission scheme changes for private teams they are not a member of, potentially revealing sensitive configuration changes within the organization [1].
Mitigation
Mattermost has published advisory MMSA-2026-00616 and recommends applying security updates. Users should upgrade to a patched version as detailed in the advisory [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(expand)+ 1 more
- (no CPE)
- (no CPE)range: 11.6.x <= 11.6.1; 11.5.x <= 11.5.4; 10.11.x <= 10.11.15; 10.11.x <= 10.11.16
Patches
3a30a331a29b9[MM-67741] Scope role_updated WS events to affected team/channel (#35497) (#36257)
15 files changed · +457 −46
server/channels/app/role.go+85 −3 modified@@ -7,12 +7,14 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "reflect" "slices" "strings" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" @@ -276,13 +278,93 @@ func (a *App) CheckRolesExist(roleNames []string) *model.AppError { } func (a *App) sendUpdatedRoleEvent(role *model.Role) *model.AppError { - message := model.NewWebSocketEvent(model.WebsocketEventRoleUpdated, "", "", "", nil, "") roleJSON, jsonErr := json.Marshal(role) if jsonErr != nil { return model.NewAppError("sendUpdatedRoleEvent", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) } - message.Add("role", string(roleJSON)) - a.Publish(message) + + publishEvent := func(teamID, channelID string) { + message := model.NewWebSocketEvent(model.WebsocketEventRoleUpdated, teamID, channelID, "", nil, "") + message.Add("role", string(roleJSON)) + a.Publish(message) + } + + // Built-in system roles apply to all users; broadcast globally without a DB lookup. + if role.BuiltIn { + publishEvent("", "") + return nil + } + + // Scheme-managed roles: use SchemeId to look up the owning scheme. + if role.SchemeId == nil { + // No owning scheme — treat as global (e.g. custom non-scheme role). + publishEvent("", "") + return nil + } + scheme, err := a.Srv().Store().Scheme().Get(*role.SchemeId) + if err != nil { + a.Log().Error("Failed to look up scheme for role event; skipping broadcast", + mlog.String("role_id", role.Id), + mlog.String("scheme_id", *role.SchemeId), + mlog.Err(err)) + return nil + } + + const pageSize = 1000 + const maxBroadcasts = 100000 + switch scheme.Scope { + case model.SchemeScopeTeam: + totalBroadcasts := 0 + offset := 0 + for { + teams, storeErr := a.Srv().Store().Team().GetTeamsByScheme(scheme.Id, offset, pageSize) + if storeErr != nil { + return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + for _, team := range teams { + publishEvent(team.Id, "") + } + totalBroadcasts += len(teams) + if len(teams) < pageSize { + break + } + if totalBroadcasts >= maxBroadcasts { + a.Log().Error("sendUpdatedRoleEvent: hit broadcast limit for team scheme", + mlog.String("scheme_id", scheme.Id), + mlog.Int("totalBroadcasts", totalBroadcasts)) + break + } + offset += pageSize + } + case model.SchemeScopeChannel: + totalBroadcasts := 0 + offset := 0 + for { + channels, storeErr := a.Srv().Store().Channel().GetChannelsByScheme(scheme.Id, offset, pageSize) + if storeErr != nil { + return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + for _, channel := range channels { + publishEvent("", channel.Id) + } + totalBroadcasts += len(channels) + if len(channels) < pageSize { + break + } + if totalBroadcasts >= maxBroadcasts { + a.Log().Error("sendUpdatedRoleEvent: hit broadcast limit for channel scheme", + mlog.String("scheme_id", scheme.Id), + mlog.Int("totalBroadcasts", totalBroadcasts)) + break + } + offset += pageSize + } + case model.SchemeScopePlaybook, model.SchemeScopeRun: + // Playbook/run schemes don't map to teams or channels; broadcast globally. + publishEvent("", "") + default: + return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.unknown_scope", nil, fmt.Sprintf("unknown scheme scope: %s", scheme.Scope), http.StatusInternalServerError) + } return nil }
server/channels/app/role_test.go+225 −0 modified@@ -5,16 +5,19 @@ package app import ( "encoding/csv" + "errors" "io" "os" "slices" "strconv" "strings" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" ) type permissionInheritanceTestData struct { @@ -259,3 +262,225 @@ func testPermissionInheritance(t *testing.T, testCallback func(t *testing.T, th // test 24 combinations where the higher-scoped scheme is a TEAM scheme test(teamScheme.DefaultChannelGuestRole, teamScheme.DefaultChannelUserRole, teamScheme.DefaultChannelAdminRole) } + +func TestSendUpdatedRoleEvent(t *testing.T) { + t.Run("BuiltIn role broadcasts globally without a DB lookup", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockStore.On("Scheme").Return(&mockSchemeStore) + + role := &model.Role{Name: model.TeamAdminRoleId, BuiltIn: true} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertNotCalled(t, "Get", mock.Anything) + }) + + t.Run("Team scheme role calls GetTeamsByScheme and emits per-team events", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam} + teams := []*model.Team{{Id: model.NewId()}, {Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(teams, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertCalled(t, "Get", schemeID) + mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 0, 1000) + }) + + t.Run("Channel scheme role calls GetChannelsByScheme and emits per-channel events", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel} + channels := model.ChannelList{{Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(channels, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertCalled(t, "Get", schemeID) + mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 0, 1000) + }) + + t.Run("Role not in any scheme broadcasts globally", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: nil} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertNotCalled(t, "Get", mock.Anything) + mockTeamStore.AssertNotCalled(t, "GetTeamsByScheme", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("Playbook scope falls back to global broadcast without querying teams or channels", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopePlaybook} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockTeamStore.AssertNotCalled(t, "GetTeamsByScheme", mock.Anything, mock.Anything, mock.Anything) + mockChannelStore.AssertNotCalled(t, "GetChannelsByScheme", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("Scheme store error is logged and skips broadcast", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockSchemeStore.On("Get", schemeID).Return(nil, errors.New("db error")) + mockStore.On("Scheme").Return(&mockSchemeStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + }) + + t.Run("GetTeamsByScheme store error propagates as AppError", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(nil, errors.New("db error")) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.NotNil(t, appErr) + }) + + t.Run("Team scheme paginates across multiple pages", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam} + + // Build a full first page (1000 teams) and a partial second page (2 teams). + page1 := make([]*model.Team, 1000) + for i := range page1 { + page1[i] = &model.Team{Id: model.NewId()} + } + page2 := []*model.Team{{Id: model.NewId()}, {Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(page1, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 1000, 1000).Return(page2, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 0, 1000) + mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 1000, 1000) + }) + + t.Run("Channel scheme paginates across multiple pages", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel} + + page1 := make(model.ChannelList, 1000) + for i := range page1 { + page1[i] = &model.Channel{Id: model.NewId()} + } + page2 := model.ChannelList{{Id: model.NewId()}, {Id: model.NewId()}, {Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(page1, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 1000, 1000).Return(page2, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 0, 1000) + mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 1000, 1000) + }) + + t.Run("GetChannelsByScheme store error propagates as AppError", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(nil, errors.New("db error")) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.NotNil(t, appErr) + }) +}
server/channels/db/migrations/migrations.list+6 −0 modified@@ -307,3 +307,9 @@ channels/db/migrations/postgres/000154_drop_translation_updateat_index.down.sql channels/db/migrations/postgres/000154_drop_translation_updateat_index.up.sql channels/db/migrations/postgres/000155_create_translation_channel_updateat_index.down.sql channels/db/migrations/postgres/000155_create_translation_channel_updateat_index.up.sql +channels/db/migrations/postgres/000156_add_schemeid_to_roles.down.sql +channels/db/migrations/postgres/000156_add_schemeid_to_roles.up.sql +channels/db/migrations/postgres/000157_backfill_roles_schemeid.down.sql +channels/db/migrations/postgres/000157_backfill_roles_schemeid.up.sql +channels/db/migrations/postgres/000158_add_roles_schemeid_index.down.sql +channels/db/migrations/postgres/000158_add_roles_schemeid_index.up.sql
server/channels/db/migrations/postgres/000156_add_schemeid_to_roles.down.sql+1 −0 added@@ -0,0 +1 @@ +ALTER TABLE roles DROP COLUMN IF EXISTS schemeid;
server/channels/db/migrations/postgres/000156_add_schemeid_to_roles.up.sql+1 −0 added@@ -0,0 +1 @@ +ALTER TABLE roles ADD COLUMN IF NOT EXISTS schemeid VARCHAR(26);
server/channels/db/migrations/postgres/000157_backfill_roles_schemeid.down.sql+1 −0 added@@ -0,0 +1 @@ +UPDATE roles SET schemeid = NULL;
server/channels/db/migrations/postgres/000157_backfill_roles_schemeid.up.sql+23 −0 added@@ -0,0 +1,23 @@ +UPDATE roles +SET schemeid = match.scheme_id +FROM ( + SELECT DISTINCT ON (role_name) id AS scheme_id, role_name + FROM ( + SELECT id, unnest(ARRAY[ + defaultteamadminrole, + defaultteamuserrole, + defaultteamguestrole, + defaultchanneladminrole, + defaultchanneluserrole, + defaultchannelguestrole, + defaultplaybookadminrole, + defaultplaybookmemberrole, + defaultrunadminrole, + defaultrunmemberrole + ]) AS role_name + FROM schemes + ) expanded + WHERE role_name IS NOT NULL AND role_name <> '' + ORDER BY role_name, scheme_id +) match +WHERE roles.name = match.role_name;
server/channels/db/migrations/postgres/000158_add_roles_schemeid_index.down.sql+2 −0 added@@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_roles_scheme_id;
server/channels/db/migrations/postgres/000158_add_roles_schemeid_index.up.sql+2 −0 added@@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_roles_scheme_id ON roles(schemeid);
server/channels/store/sqlstore/role_store.go+16 −10 modified@@ -33,6 +33,7 @@ type Role struct { Permissions string SchemeManaged bool BuiltIn bool + SchemeId *string } type channelRolesPermissions struct { @@ -66,6 +67,7 @@ func NewRoleFromModel(role *model.Role) *Role { Permissions: permissions.String(), SchemeManaged: role.SchemeManaged, BuiltIn: role.BuiltIn, + SchemeId: role.SchemeId, } } @@ -81,6 +83,7 @@ func (role Role) ToModel() *model.Role { Permissions: strings.Fields(role.Permissions), SchemeManaged: role.SchemeManaged, BuiltIn: role.BuiltIn, + SchemeId: role.SchemeId, } } @@ -90,7 +93,7 @@ func newSqlRoleStore(sqlStore *SqlStore) store.RoleStore { } s.tableSelectQuery = s.getQueryBuilder(). - Select("Id", "Name", "DisplayName", "Description", "CreateAt", "UpdateAt", "DeleteAt", "Permissions", "SchemeManaged", "BuiltIn"). + Select("Id", "Name", "DisplayName", "Description", "CreateAt", "UpdateAt", "DeleteAt", "Permissions", "SchemeManaged", "BuiltIn", "SchemeId"). From("Roles") return &s @@ -122,9 +125,10 @@ func (s *SqlRoleStore) Save(role *model.Role) (_ *model.Role, err error) { dbRole.UpdateAt = model.GetMillis() res, err := s.GetMaster().NamedExec(`UPDATE Roles - SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName, - Description=:Description, Permissions=:Permissions, SchemeManaged=:SchemeManaged, BuiltIn=:BuiltIn - WHERE Id=:Id`, &dbRole) + SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName, + Description=:Description, Permissions=:Permissions, SchemeManaged=:SchemeManaged, BuiltIn=:BuiltIn, + SchemeId=:SchemeId + WHERE Id=:Id`, &dbRole) if err != nil { return nil, errors.Wrap(err, "failed to update Role") @@ -155,9 +159,9 @@ func (s *SqlRoleStore) createRole(role *model.Role, transaction *sqlxTxWrapper) dbRole.UpdateAt = dbRole.CreateAt if _, err := transaction.NamedExec(`INSERT INTO Roles - (Id, Name, DisplayName, Description, Permissions, CreateAt, UpdateAt, DeleteAt, SchemeManaged, BuiltIn) + (Id, Name, DisplayName, Description, Permissions, CreateAt, UpdateAt, DeleteAt, SchemeManaged, BuiltIn, SchemeId) VALUES - (:Id, :Name, :DisplayName, :Description, :Permissions, :CreateAt, :UpdateAt, :DeleteAt, :SchemeManaged, :BuiltIn)`, dbRole); err != nil { + (:Id, :Name, :DisplayName, :Description, :Permissions, :CreateAt, :UpdateAt, :DeleteAt, :SchemeManaged, :BuiltIn, :SchemeId)`, dbRole); err != nil { return nil, errors.Wrap(err, "failed to save Role") } @@ -230,7 +234,7 @@ func (s *SqlRoleStore) GetByNames(names []string) ([]*model.Role, error) { err = rows.Scan( &role.Id, &role.Name, &role.DisplayName, &role.Description, &role.CreateAt, &role.UpdateAt, &role.DeleteAt, &role.Permissions, - &role.SchemeManaged, &role.BuiltIn) + &role.SchemeManaged, &role.BuiltIn, &role.SchemeId) if err != nil { return nil, errors.Wrap(err, "failed to scan values") } @@ -394,9 +398,10 @@ func (s *SqlRoleStore) AllChannelSchemeRoles() ([]*model.Role, error) { "Roles.Permissions", "Roles.SchemeManaged", "Roles.BuiltIn", + "Roles.SchemeId", ). - From("Schemes"). - Join("Roles ON Schemes.DefaultChannelGuestRole = Roles.Name OR Schemes.DefaultChannelUserRole = Roles.Name OR Schemes.DefaultChannelAdminRole = Roles.Name"). + From("Roles"). + Join("Schemes ON Roles.SchemeId = Schemes.Id"). Where(sq.Eq{"Schemes.Scope": model.SchemeScopeChannel}). Where(sq.Eq{"Roles.DeleteAt": 0}). Where(sq.Eq{"Schemes.DeleteAt": 0}) @@ -433,13 +438,14 @@ func (s *SqlRoleStore) ChannelRolesUnderTeamRole(roleName string) ([]*model.Role "ChannelSchemeRoles.Permissions", "ChannelSchemeRoles.SchemeManaged", "ChannelSchemeRoles.BuiltIn", + "ChannelSchemeRoles.SchemeId", ). From("Roles AS HigherScopedRoles"). Join("Schemes AS HigherScopedSchemes ON (HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelGuestRole OR HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelUserRole OR HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelAdminRole)"). Join("Teams ON Teams.SchemeId = HigherScopedSchemes.Id"). Join("Channels ON Channels.TeamId = Teams.Id"). Join("Schemes AS ChannelSchemes ON Channels.SchemeId = ChannelSchemes.Id"). - Join("Roles AS ChannelSchemeRoles ON (ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelGuestRole OR ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelUserRole OR ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelAdminRole)"). + Join("Roles AS ChannelSchemeRoles ON ChannelSchemeRoles.SchemeId = ChannelSchemes.Id"). Where(sq.Eq{"HigherScopedSchemes.Scope": model.SchemeScopeTeam}). Where(sq.Eq{"HigherScopedRoles.Name": roleName}). Where(sq.Eq{"HigherScopedRoles.DeleteAt": 0}).
server/channels/store/sqlstore/scheme_store.go+18 −18 modified@@ -100,6 +100,12 @@ func (s *SqlSchemeStore) Save(scheme *model.Scheme) (_ *model.Scheme, err error) } func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxWrapper) (*model.Scheme, error) { + // Generate the scheme ID up front so it can be recorded on each created role. + scheme.Id = model.NewId() + if scheme.Name == "" { + scheme.Name = model.NewId() + } + // Fetch the default system scheme roles to populate default permissions. defaultRoleNames := []string{ model.TeamAdminRoleId, @@ -135,6 +141,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamAdmin, scheme.Name), Permissions: defaultRoles[model.TeamAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err := s.SqlStore.Role().(*SqlRoleStore).createRole(teamAdminRole, transaction) @@ -149,6 +156,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamUser, scheme.Name), Permissions: defaultRoles[model.TeamUserRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(teamUserRole, transaction) @@ -163,6 +171,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamGuest, scheme.Name), Permissions: defaultRoles[model.TeamGuestRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(teamGuestRole, transaction) @@ -177,6 +186,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookAdmin, scheme.Name), Permissions: defaultRoles[model.PlaybookAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(playbookAdminRole, transaction) if err != nil { @@ -190,6 +200,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookMember, scheme.Name), Permissions: defaultRoles[model.PlaybookMemberRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(playbookMemberRole, transaction) if err != nil { @@ -203,6 +214,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunAdmin, scheme.Name), Permissions: defaultRoles[model.RunAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(runAdminRole, transaction) if err != nil { @@ -216,6 +228,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunMember, scheme.Name), Permissions: defaultRoles[model.RunMemberRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(runMemberRole, transaction) if err != nil { @@ -231,6 +244,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("Channel Admin Role for Scheme %s", scheme.Name), Permissions: defaultRoles[model.ChannelAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } if scheme.Scope == model.SchemeScopeChannel { @@ -249,6 +263,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("Channel User Role for Scheme %s", scheme.Name), Permissions: defaultRoles[model.ChannelUserRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } if scheme.Scope == model.SchemeScopeChannel { @@ -267,6 +282,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("Channel Guest Role for Scheme %s", scheme.Name), Permissions: defaultRoles[model.ChannelGuestRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } if scheme.Scope == model.SchemeScopeChannel { @@ -280,10 +296,6 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW scheme.DefaultChannelGuestRole = savedRole.Name } - scheme.Id = model.NewId() - if scheme.Name == "" { - scheme.Name = model.NewId() - } scheme.CreateAt = model.GetMillis() scheme.UpdateAt = scheme.CreateAt @@ -363,23 +375,11 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) { s.Channel().ClearCaches() // Delete the roles belonging to the scheme. - roleNames := []string{scheme.DefaultChannelGuestRole, scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole} - if scheme.Scope == model.SchemeScopeTeam { - roleNames = append(roleNames, scheme.DefaultTeamGuestRole, scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole) - } - if scheme.Scope == model.SchemeScopePlaybook { - roleNames = append(roleNames, scheme.DefaultPlaybookAdminRole, scheme.DefaultPlaybookMemberRole) - } - - if scheme.Scope == model.SchemeScopeRun { - roleNames = append(roleNames, scheme.DefaultRunAdminRole, scheme.DefaultRunMemberRole) - } - time := model.GetMillis() updateQuery, args, err := s.getQueryBuilder(). Update("Roles"). - Where(sq.Eq{"Name": roleNames}). + Where(sq.Eq{"SchemeId": schemeId}). Set("UpdateAt", time). Set("DeleteAt", time). ToSql() @@ -388,7 +388,7 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) { } if _, err = s.GetMaster().Exec(updateQuery, args...); err != nil { - return nil, errors.Wrapf(err, "failed to update Roles with name in (%s)", roleNames) + return nil, errors.Wrapf(err, "failed to update Roles with SchemeId=%s", schemeId) } // Delete the scheme itself.
server/channels/store/storetest/role_store.go+41 −15 modified@@ -456,39 +456,45 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX, actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelGuestRole) require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } - require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelGuestRole) - require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelGuestRole) + require.Contains(t, roleByName, channelScheme1.DefaultChannelGuestRole) + require.NotContains(t, roleByName, channelScheme2.DefaultChannelGuestRole) + require.NotNil(t, roleByName[channelScheme1.DefaultChannelGuestRole].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelGuestRole].SchemeId) }) t.Run("user role for the right team's channels are returned", func(t *testing.T) { actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelUserRole) require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } - require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelUserRole) - require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelUserRole) + require.Contains(t, roleByName, channelScheme1.DefaultChannelUserRole) + require.NotContains(t, roleByName, channelScheme2.DefaultChannelUserRole) + require.NotNil(t, roleByName[channelScheme1.DefaultChannelUserRole].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelUserRole].SchemeId) }) t.Run("admin role for the right team's channels are returned", func(t *testing.T) { actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelAdminRole) require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } - require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelAdminRole) - require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelAdminRole) + require.Contains(t, roleByName, channelScheme1.DefaultChannelAdminRole) + require.NotContains(t, roleByName, channelScheme2.DefaultChannelAdminRole) + require.NotNil(t, roleByName[channelScheme1.DefaultChannelAdminRole].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelAdminRole].SchemeId) }) }) @@ -497,9 +503,9 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX, actualRoles, err := ss.Role().AllChannelSchemeRoles() require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } allRoleNames := []string{ @@ -514,7 +520,27 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX, } for _, roleName := range allRoleNames { - require.Contains(t, actualRoleNames, roleName) + require.Contains(t, roleByName, roleName) + } + + // Roles for channelScheme1 must carry channelScheme1's ID. + for _, roleName := range []string{ + channelScheme1.DefaultChannelGuestRole, + channelScheme1.DefaultChannelUserRole, + channelScheme1.DefaultChannelAdminRole, + } { + require.NotNil(t, roleByName[roleName].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[roleName].SchemeId) + } + + // Roles for channelScheme2 must carry channelScheme2's ID. + for _, roleName := range []string{ + channelScheme2.DefaultChannelGuestRole, + channelScheme2.DefaultChannelUserRole, + channelScheme2.DefaultChannelAdminRole, + } { + require.NotNil(t, roleByName[roleName].SchemeId) + assert.Equal(t, channelScheme2.Id, *roleByName[roleName].SchemeId) } }) })
server/channels/store/storetest/scheme_store.go+22 −0 modified@@ -174,6 +174,28 @@ func testSchemeStoreSave(t *testing.T, rctx request.CTX, ss store.Store) { assert.Equal(t, role6.Permissions, []string{"read_channel", "read_channel_content", "create_post"}) assert.True(t, role6.SchemeManaged) + role7, err := ss.Role().GetByName(context.Background(), d1.DefaultPlaybookAdminRole) + assert.NoError(t, err) + assert.True(t, role7.SchemeManaged) + + role8, err := ss.Role().GetByName(context.Background(), d1.DefaultPlaybookMemberRole) + assert.NoError(t, err) + assert.True(t, role8.SchemeManaged) + + role9, err := ss.Role().GetByName(context.Background(), d1.DefaultRunAdminRole) + assert.NoError(t, err) + assert.True(t, role9.SchemeManaged) + + role10, err := ss.Role().GetByName(context.Background(), d1.DefaultRunMemberRole) + assert.NoError(t, err) + assert.True(t, role10.SchemeManaged) + + // Every role created for a scheme must carry the scheme's ID. + for _, role := range []*model.Role{role1, role2, role3, role4, role5, role6, role7, role8, role9, role10} { + require.NotNil(t, role.SchemeId) + assert.Equal(t, d1.Id, *role.SchemeId) + } + // Change the scheme description and update. d1.Description = model.NewId()
server/i18n/en.json+8 −0 modified@@ -7666,6 +7666,14 @@ "id": "app.role.save.invalid_role.app_error", "translation": "The role was not valid." }, + { + "id": "app.role.send_updated_role_event.app_error", + "translation": "An error occurred while broadcasting the role update." + }, + { + "id": "app.role.send_updated_role_event.unknown_scope", + "translation": "An error occurred while broadcasting the role update: unknown scheme scope." + }, { "id": "app.save_config.app_error", "translation": "An error occurred saving the configuration."
server/public/model/role.go+6 −0 modified@@ -418,6 +418,7 @@ type Role struct { Permissions []string `json:"permissions"` SchemeManaged bool `json:"scheme_managed"` BuiltIn bool `json:"built_in"` + SchemeId *string `json:"scheme_id"` } func (r *Role) Auditable() map[string]any { @@ -432,6 +433,7 @@ func (r *Role) Auditable() map[string]any { "permissions": r.Permissions, "scheme_managed": r.SchemeManaged, "built_in": r.BuiltIn, + "scheme_id": r.SchemeId, } } @@ -452,6 +454,7 @@ func (r *Role) MarshalYAML() (any, error) { Permissions []string `yaml:"permissions"` SchemeManaged bool `yaml:"scheme_managed"` BuiltIn bool `yaml:"built_in"` + SchemeId *string `yaml:"scheme_id"` }{ Id: r.Id, Name: r.Name, @@ -463,6 +466,7 @@ func (r *Role) MarshalYAML() (any, error) { Permissions: r.Permissions, SchemeManaged: r.SchemeManaged, BuiltIn: r.BuiltIn, + SchemeId: r.SchemeId, }, nil } @@ -478,6 +482,7 @@ func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { Permissions []string `yaml:"permissions"` SchemeManaged bool `yaml:"scheme_managed"` BuiltIn bool `yaml:"built_in"` + SchemeId *string `yaml:"scheme_id"` }{} err := unmarshal(&out) @@ -509,6 +514,7 @@ func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { Permissions: out.Permissions, SchemeManaged: out.SchemeManaged, BuiltIn: out.BuiltIn, + SchemeId: out.SchemeId, } return nil }
0a0ab0d54d89[MM-67741] Scope role_updated WS events to affected team/channel (#35497) (#36256)
15 files changed · +457 −46
server/channels/app/role.go+85 −3 modified@@ -7,12 +7,14 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "reflect" "slices" "strings" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" "github.com/mattermost/mattermost/server/v8/channels/store" @@ -276,13 +278,93 @@ func (a *App) CheckRolesExist(roleNames []string) *model.AppError { } func (a *App) sendUpdatedRoleEvent(role *model.Role) *model.AppError { - message := model.NewWebSocketEvent(model.WebsocketEventRoleUpdated, "", "", "", nil, "") roleJSON, jsonErr := json.Marshal(role) if jsonErr != nil { return model.NewAppError("sendUpdatedRoleEvent", "api.marshal_error", nil, "", http.StatusInternalServerError).Wrap(jsonErr) } - message.Add("role", string(roleJSON)) - a.Publish(message) + + publishEvent := func(teamID, channelID string) { + message := model.NewWebSocketEvent(model.WebsocketEventRoleUpdated, teamID, channelID, "", nil, "") + message.Add("role", string(roleJSON)) + a.Publish(message) + } + + // Built-in system roles apply to all users; broadcast globally without a DB lookup. + if role.BuiltIn { + publishEvent("", "") + return nil + } + + // Scheme-managed roles: use SchemeId to look up the owning scheme. + if role.SchemeId == nil { + // No owning scheme — treat as global (e.g. custom non-scheme role). + publishEvent("", "") + return nil + } + scheme, err := a.Srv().Store().Scheme().Get(*role.SchemeId) + if err != nil { + a.Log().Error("Failed to look up scheme for role event; skipping broadcast", + mlog.String("role_id", role.Id), + mlog.String("scheme_id", *role.SchemeId), + mlog.Err(err)) + return nil + } + + const pageSize = 1000 + const maxBroadcasts = 100000 + switch scheme.Scope { + case model.SchemeScopeTeam: + totalBroadcasts := 0 + offset := 0 + for { + teams, storeErr := a.Srv().Store().Team().GetTeamsByScheme(scheme.Id, offset, pageSize) + if storeErr != nil { + return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + for _, team := range teams { + publishEvent(team.Id, "") + } + totalBroadcasts += len(teams) + if len(teams) < pageSize { + break + } + if totalBroadcasts >= maxBroadcasts { + a.Log().Error("sendUpdatedRoleEvent: hit broadcast limit for team scheme", + mlog.String("scheme_id", scheme.Id), + mlog.Int("totalBroadcasts", totalBroadcasts)) + break + } + offset += pageSize + } + case model.SchemeScopeChannel: + totalBroadcasts := 0 + offset := 0 + for { + channels, storeErr := a.Srv().Store().Channel().GetChannelsByScheme(scheme.Id, offset, pageSize) + if storeErr != nil { + return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.app_error", nil, "", http.StatusInternalServerError).Wrap(storeErr) + } + for _, channel := range channels { + publishEvent("", channel.Id) + } + totalBroadcasts += len(channels) + if len(channels) < pageSize { + break + } + if totalBroadcasts >= maxBroadcasts { + a.Log().Error("sendUpdatedRoleEvent: hit broadcast limit for channel scheme", + mlog.String("scheme_id", scheme.Id), + mlog.Int("totalBroadcasts", totalBroadcasts)) + break + } + offset += pageSize + } + case model.SchemeScopePlaybook, model.SchemeScopeRun: + // Playbook/run schemes don't map to teams or channels; broadcast globally. + publishEvent("", "") + default: + return model.NewAppError("sendUpdatedRoleEvent", "app.role.send_updated_role_event.unknown_scope", nil, fmt.Sprintf("unknown scheme scope: %s", scheme.Scope), http.StatusInternalServerError) + } return nil }
server/channels/app/role_test.go+225 −0 modified@@ -5,16 +5,19 @@ package app import ( "encoding/csv" + "errors" "io" "os" "slices" "strconv" "strings" "testing" + "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" "github.com/mattermost/mattermost/server/public/model" + "github.com/mattermost/mattermost/server/v8/channels/store/storetest/mocks" ) type permissionInheritanceTestData struct { @@ -259,3 +262,225 @@ func testPermissionInheritance(t *testing.T, testCallback func(t *testing.T, th // test 24 combinations where the higher-scoped scheme is a TEAM scheme test(teamScheme.DefaultChannelGuestRole, teamScheme.DefaultChannelUserRole, teamScheme.DefaultChannelAdminRole) } + +func TestSendUpdatedRoleEvent(t *testing.T) { + t.Run("BuiltIn role broadcasts globally without a DB lookup", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockStore.On("Scheme").Return(&mockSchemeStore) + + role := &model.Role{Name: model.TeamAdminRoleId, BuiltIn: true} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertNotCalled(t, "Get", mock.Anything) + }) + + t.Run("Team scheme role calls GetTeamsByScheme and emits per-team events", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam} + teams := []*model.Team{{Id: model.NewId()}, {Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(teams, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertCalled(t, "Get", schemeID) + mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 0, 1000) + }) + + t.Run("Channel scheme role calls GetChannelsByScheme and emits per-channel events", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel} + channels := model.ChannelList{{Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(channels, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertCalled(t, "Get", schemeID) + mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 0, 1000) + }) + + t.Run("Role not in any scheme broadcasts globally", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: nil} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockSchemeStore.AssertNotCalled(t, "Get", mock.Anything) + mockTeamStore.AssertNotCalled(t, "GetTeamsByScheme", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("Playbook scope falls back to global broadcast without querying teams or channels", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopePlaybook} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockTeamStore.AssertNotCalled(t, "GetTeamsByScheme", mock.Anything, mock.Anything, mock.Anything) + mockChannelStore.AssertNotCalled(t, "GetChannelsByScheme", mock.Anything, mock.Anything, mock.Anything) + }) + + t.Run("Scheme store error is logged and skips broadcast", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockSchemeStore.On("Get", schemeID).Return(nil, errors.New("db error")) + mockStore.On("Scheme").Return(&mockSchemeStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + }) + + t.Run("GetTeamsByScheme store error propagates as AppError", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(nil, errors.New("db error")) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.NotNil(t, appErr) + }) + + t.Run("Team scheme paginates across multiple pages", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeTeam} + + // Build a full first page (1000 teams) and a partial second page (2 teams). + page1 := make([]*model.Team, 1000) + for i := range page1 { + page1[i] = &model.Team{Id: model.NewId()} + } + page2 := []*model.Team{{Id: model.NewId()}, {Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockTeamStore := mocks.TeamStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 0, 1000).Return(page1, nil) + mockTeamStore.On("GetTeamsByScheme", schemeID, 1000, 1000).Return(page2, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Team").Return(&mockTeamStore) + + role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 0, 1000) + mockTeamStore.AssertCalled(t, "GetTeamsByScheme", schemeID, 1000, 1000) + }) + + t.Run("Channel scheme paginates across multiple pages", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel} + + page1 := make(model.ChannelList, 1000) + for i := range page1 { + page1[i] = &model.Channel{Id: model.NewId()} + } + page2 := model.ChannelList{{Id: model.NewId()}, {Id: model.NewId()}, {Id: model.NewId()}} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(page1, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 1000, 1000).Return(page2, nil) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: model.NewId(), BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.Nil(t, appErr) + mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 0, 1000) + mockChannelStore.AssertCalled(t, "GetChannelsByScheme", schemeID, 1000, 1000) + }) + + t.Run("GetChannelsByScheme store error propagates as AppError", func(t *testing.T) { + mainHelper.Parallel(t) + th := SetupWithStoreMock(t) + + schemeID := model.NewId() + roleName := model.NewId() + scheme := &model.Scheme{Id: schemeID, Scope: model.SchemeScopeChannel} + + mockStore := th.App.Srv().Store().(*mocks.Store) + mockSchemeStore := mocks.SchemeStore{} + mockChannelStore := mocks.ChannelStore{} + mockSchemeStore.On("Get", schemeID).Return(scheme, nil) + mockChannelStore.On("GetChannelsByScheme", schemeID, 0, 1000).Return(nil, errors.New("db error")) + mockStore.On("Scheme").Return(&mockSchemeStore) + mockStore.On("Channel").Return(&mockChannelStore) + + role := &model.Role{Name: roleName, BuiltIn: false, SchemeId: &schemeID} + appErr := th.App.sendUpdatedRoleEvent(role) + require.NotNil(t, appErr) + }) +}
server/channels/db/migrations/migrations.list+6 −0 modified@@ -307,3 +307,9 @@ channels/db/migrations/postgres/000154_drop_translation_updateat_index.down.sql channels/db/migrations/postgres/000154_drop_translation_updateat_index.up.sql channels/db/migrations/postgres/000155_create_translation_channel_updateat_index.down.sql channels/db/migrations/postgres/000155_create_translation_channel_updateat_index.up.sql +channels/db/migrations/postgres/000156_add_schemeid_to_roles.down.sql +channels/db/migrations/postgres/000156_add_schemeid_to_roles.up.sql +channels/db/migrations/postgres/000157_backfill_roles_schemeid.down.sql +channels/db/migrations/postgres/000157_backfill_roles_schemeid.up.sql +channels/db/migrations/postgres/000158_add_roles_schemeid_index.down.sql +channels/db/migrations/postgres/000158_add_roles_schemeid_index.up.sql
server/channels/db/migrations/postgres/000156_add_schemeid_to_roles.down.sql+1 −0 added@@ -0,0 +1 @@ +ALTER TABLE roles DROP COLUMN IF EXISTS schemeid;
server/channels/db/migrations/postgres/000156_add_schemeid_to_roles.up.sql+1 −0 added@@ -0,0 +1 @@ +ALTER TABLE roles ADD COLUMN IF NOT EXISTS schemeid VARCHAR(26);
server/channels/db/migrations/postgres/000157_backfill_roles_schemeid.down.sql+1 −0 added@@ -0,0 +1 @@ +UPDATE roles SET schemeid = NULL;
server/channels/db/migrations/postgres/000157_backfill_roles_schemeid.up.sql+23 −0 added@@ -0,0 +1,23 @@ +UPDATE roles +SET schemeid = match.scheme_id +FROM ( + SELECT DISTINCT ON (role_name) id AS scheme_id, role_name + FROM ( + SELECT id, unnest(ARRAY[ + defaultteamadminrole, + defaultteamuserrole, + defaultteamguestrole, + defaultchanneladminrole, + defaultchanneluserrole, + defaultchannelguestrole, + defaultplaybookadminrole, + defaultplaybookmemberrole, + defaultrunadminrole, + defaultrunmemberrole + ]) AS role_name + FROM schemes + ) expanded + WHERE role_name IS NOT NULL AND role_name <> '' + ORDER BY role_name, scheme_id +) match +WHERE roles.name = match.role_name;
server/channels/db/migrations/postgres/000158_add_roles_schemeid_index.down.sql+2 −0 added@@ -0,0 +1,2 @@ +-- morph:nontransactional +DROP INDEX CONCURRENTLY IF EXISTS idx_roles_scheme_id;
server/channels/db/migrations/postgres/000158_add_roles_schemeid_index.up.sql+2 −0 added@@ -0,0 +1,2 @@ +-- morph:nontransactional +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_roles_scheme_id ON roles(schemeid);
server/channels/store/sqlstore/role_store.go+16 −10 modified@@ -33,6 +33,7 @@ type Role struct { Permissions string SchemeManaged bool BuiltIn bool + SchemeId *string } type channelRolesPermissions struct { @@ -66,6 +67,7 @@ func NewRoleFromModel(role *model.Role) *Role { Permissions: permissions.String(), SchemeManaged: role.SchemeManaged, BuiltIn: role.BuiltIn, + SchemeId: role.SchemeId, } } @@ -81,6 +83,7 @@ func (role Role) ToModel() *model.Role { Permissions: strings.Fields(role.Permissions), SchemeManaged: role.SchemeManaged, BuiltIn: role.BuiltIn, + SchemeId: role.SchemeId, } } @@ -90,7 +93,7 @@ func newSqlRoleStore(sqlStore *SqlStore) store.RoleStore { } s.tableSelectQuery = s.getQueryBuilder(). - Select("Id", "Name", "DisplayName", "Description", "CreateAt", "UpdateAt", "DeleteAt", "Permissions", "SchemeManaged", "BuiltIn"). + Select("Id", "Name", "DisplayName", "Description", "CreateAt", "UpdateAt", "DeleteAt", "Permissions", "SchemeManaged", "BuiltIn", "SchemeId"). From("Roles") return &s @@ -122,9 +125,10 @@ func (s *SqlRoleStore) Save(role *model.Role) (_ *model.Role, err error) { dbRole.UpdateAt = model.GetMillis() res, err := s.GetMaster().NamedExec(`UPDATE Roles - SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName, - Description=:Description, Permissions=:Permissions, SchemeManaged=:SchemeManaged, BuiltIn=:BuiltIn - WHERE Id=:Id`, &dbRole) + SET UpdateAt=:UpdateAt, DeleteAt=:DeleteAt, CreateAt=:CreateAt, Name=:Name, DisplayName=:DisplayName, + Description=:Description, Permissions=:Permissions, SchemeManaged=:SchemeManaged, BuiltIn=:BuiltIn, + SchemeId=:SchemeId + WHERE Id=:Id`, &dbRole) if err != nil { return nil, errors.Wrap(err, "failed to update Role") @@ -155,9 +159,9 @@ func (s *SqlRoleStore) createRole(role *model.Role, transaction *sqlxTxWrapper) dbRole.UpdateAt = dbRole.CreateAt if _, err := transaction.NamedExec(`INSERT INTO Roles - (Id, Name, DisplayName, Description, Permissions, CreateAt, UpdateAt, DeleteAt, SchemeManaged, BuiltIn) + (Id, Name, DisplayName, Description, Permissions, CreateAt, UpdateAt, DeleteAt, SchemeManaged, BuiltIn, SchemeId) VALUES - (:Id, :Name, :DisplayName, :Description, :Permissions, :CreateAt, :UpdateAt, :DeleteAt, :SchemeManaged, :BuiltIn)`, dbRole); err != nil { + (:Id, :Name, :DisplayName, :Description, :Permissions, :CreateAt, :UpdateAt, :DeleteAt, :SchemeManaged, :BuiltIn, :SchemeId)`, dbRole); err != nil { return nil, errors.Wrap(err, "failed to save Role") } @@ -230,7 +234,7 @@ func (s *SqlRoleStore) GetByNames(names []string) ([]*model.Role, error) { err = rows.Scan( &role.Id, &role.Name, &role.DisplayName, &role.Description, &role.CreateAt, &role.UpdateAt, &role.DeleteAt, &role.Permissions, - &role.SchemeManaged, &role.BuiltIn) + &role.SchemeManaged, &role.BuiltIn, &role.SchemeId) if err != nil { return nil, errors.Wrap(err, "failed to scan values") } @@ -394,9 +398,10 @@ func (s *SqlRoleStore) AllChannelSchemeRoles() ([]*model.Role, error) { "Roles.Permissions", "Roles.SchemeManaged", "Roles.BuiltIn", + "Roles.SchemeId", ). - From("Schemes"). - Join("Roles ON Schemes.DefaultChannelGuestRole = Roles.Name OR Schemes.DefaultChannelUserRole = Roles.Name OR Schemes.DefaultChannelAdminRole = Roles.Name"). + From("Roles"). + Join("Schemes ON Roles.SchemeId = Schemes.Id"). Where(sq.Eq{"Schemes.Scope": model.SchemeScopeChannel}). Where(sq.Eq{"Roles.DeleteAt": 0}). Where(sq.Eq{"Schemes.DeleteAt": 0}) @@ -433,13 +438,14 @@ func (s *SqlRoleStore) ChannelRolesUnderTeamRole(roleName string) ([]*model.Role "ChannelSchemeRoles.Permissions", "ChannelSchemeRoles.SchemeManaged", "ChannelSchemeRoles.BuiltIn", + "ChannelSchemeRoles.SchemeId", ). From("Roles AS HigherScopedRoles"). Join("Schemes AS HigherScopedSchemes ON (HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelGuestRole OR HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelUserRole OR HigherScopedRoles.Name = HigherScopedSchemes.DefaultChannelAdminRole)"). Join("Teams ON Teams.SchemeId = HigherScopedSchemes.Id"). Join("Channels ON Channels.TeamId = Teams.Id"). Join("Schemes AS ChannelSchemes ON Channels.SchemeId = ChannelSchemes.Id"). - Join("Roles AS ChannelSchemeRoles ON (ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelGuestRole OR ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelUserRole OR ChannelSchemeRoles.Name = ChannelSchemes.DefaultChannelAdminRole)"). + Join("Roles AS ChannelSchemeRoles ON ChannelSchemeRoles.SchemeId = ChannelSchemes.Id"). Where(sq.Eq{"HigherScopedSchemes.Scope": model.SchemeScopeTeam}). Where(sq.Eq{"HigherScopedRoles.Name": roleName}). Where(sq.Eq{"HigherScopedRoles.DeleteAt": 0}).
server/channels/store/sqlstore/scheme_store.go+18 −18 modified@@ -100,6 +100,12 @@ func (s *SqlSchemeStore) Save(scheme *model.Scheme) (_ *model.Scheme, err error) } func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxWrapper) (*model.Scheme, error) { + // Generate the scheme ID up front so it can be recorded on each created role. + scheme.Id = model.NewId() + if scheme.Name == "" { + scheme.Name = model.NewId() + } + // Fetch the default system scheme roles to populate default permissions. defaultRoleNames := []string{ model.TeamAdminRoleId, @@ -135,6 +141,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamAdmin, scheme.Name), Permissions: defaultRoles[model.TeamAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err := s.SqlStore.Role().(*SqlRoleStore).createRole(teamAdminRole, transaction) @@ -149,6 +156,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamUser, scheme.Name), Permissions: defaultRoles[model.TeamUserRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(teamUserRole, transaction) @@ -163,6 +171,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameTeamGuest, scheme.Name), Permissions: defaultRoles[model.TeamGuestRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(teamGuestRole, transaction) @@ -177,6 +186,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookAdmin, scheme.Name), Permissions: defaultRoles[model.PlaybookAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(playbookAdminRole, transaction) if err != nil { @@ -190,6 +200,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNamePlaybookMember, scheme.Name), Permissions: defaultRoles[model.PlaybookMemberRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(playbookMemberRole, transaction) if err != nil { @@ -203,6 +214,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunAdmin, scheme.Name), Permissions: defaultRoles[model.RunAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(runAdminRole, transaction) if err != nil { @@ -216,6 +228,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("%s %s", SchemeRoleDisplayNameRunMember, scheme.Name), Permissions: defaultRoles[model.RunMemberRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } savedRole, err = s.SqlStore.Role().(*SqlRoleStore).createRole(runMemberRole, transaction) if err != nil { @@ -231,6 +244,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("Channel Admin Role for Scheme %s", scheme.Name), Permissions: defaultRoles[model.ChannelAdminRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } if scheme.Scope == model.SchemeScopeChannel { @@ -249,6 +263,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("Channel User Role for Scheme %s", scheme.Name), Permissions: defaultRoles[model.ChannelUserRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } if scheme.Scope == model.SchemeScopeChannel { @@ -267,6 +282,7 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW DisplayName: fmt.Sprintf("Channel Guest Role for Scheme %s", scheme.Name), Permissions: defaultRoles[model.ChannelGuestRoleId].Permissions, SchemeManaged: true, + SchemeId: &scheme.Id, } if scheme.Scope == model.SchemeScopeChannel { @@ -280,10 +296,6 @@ func (s *SqlSchemeStore) createScheme(scheme *model.Scheme, transaction *sqlxTxW scheme.DefaultChannelGuestRole = savedRole.Name } - scheme.Id = model.NewId() - if scheme.Name == "" { - scheme.Name = model.NewId() - } scheme.CreateAt = model.GetMillis() scheme.UpdateAt = scheme.CreateAt @@ -363,23 +375,11 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) { s.Channel().ClearCaches() // Delete the roles belonging to the scheme. - roleNames := []string{scheme.DefaultChannelGuestRole, scheme.DefaultChannelUserRole, scheme.DefaultChannelAdminRole} - if scheme.Scope == model.SchemeScopeTeam { - roleNames = append(roleNames, scheme.DefaultTeamGuestRole, scheme.DefaultTeamUserRole, scheme.DefaultTeamAdminRole) - } - if scheme.Scope == model.SchemeScopePlaybook { - roleNames = append(roleNames, scheme.DefaultPlaybookAdminRole, scheme.DefaultPlaybookMemberRole) - } - - if scheme.Scope == model.SchemeScopeRun { - roleNames = append(roleNames, scheme.DefaultRunAdminRole, scheme.DefaultRunMemberRole) - } - time := model.GetMillis() updateQuery, args, err := s.getQueryBuilder(). Update("Roles"). - Where(sq.Eq{"Name": roleNames}). + Where(sq.Eq{"SchemeId": schemeId}). Set("UpdateAt", time). Set("DeleteAt", time). ToSql() @@ -388,7 +388,7 @@ func (s *SqlSchemeStore) Delete(schemeId string) (*model.Scheme, error) { } if _, err = s.GetMaster().Exec(updateQuery, args...); err != nil { - return nil, errors.Wrapf(err, "failed to update Roles with name in (%s)", roleNames) + return nil, errors.Wrapf(err, "failed to update Roles with SchemeId=%s", schemeId) } // Delete the scheme itself.
server/channels/store/storetest/role_store.go+41 −15 modified@@ -456,39 +456,45 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX, actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelGuestRole) require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } - require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelGuestRole) - require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelGuestRole) + require.Contains(t, roleByName, channelScheme1.DefaultChannelGuestRole) + require.NotContains(t, roleByName, channelScheme2.DefaultChannelGuestRole) + require.NotNil(t, roleByName[channelScheme1.DefaultChannelGuestRole].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelGuestRole].SchemeId) }) t.Run("user role for the right team's channels are returned", func(t *testing.T) { actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelUserRole) require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } - require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelUserRole) - require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelUserRole) + require.Contains(t, roleByName, channelScheme1.DefaultChannelUserRole) + require.NotContains(t, roleByName, channelScheme2.DefaultChannelUserRole) + require.NotNil(t, roleByName[channelScheme1.DefaultChannelUserRole].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelUserRole].SchemeId) }) t.Run("admin role for the right team's channels are returned", func(t *testing.T) { actualRoles, err := ss.Role().ChannelRolesUnderTeamRole(teamScheme1.DefaultChannelAdminRole) require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } - require.Contains(t, actualRoleNames, channelScheme1.DefaultChannelAdminRole) - require.NotContains(t, actualRoleNames, channelScheme2.DefaultChannelAdminRole) + require.Contains(t, roleByName, channelScheme1.DefaultChannelAdminRole) + require.NotContains(t, roleByName, channelScheme2.DefaultChannelAdminRole) + require.NotNil(t, roleByName[channelScheme1.DefaultChannelAdminRole].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[channelScheme1.DefaultChannelAdminRole].SchemeId) }) }) @@ -497,9 +503,9 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX, actualRoles, err := ss.Role().AllChannelSchemeRoles() require.NoError(t, err) - var actualRoleNames []string + roleByName := make(map[string]*model.Role, len(actualRoles)) for _, role := range actualRoles { - actualRoleNames = append(actualRoleNames, role.Name) + roleByName[role.Name] = role } allRoleNames := []string{ @@ -514,7 +520,27 @@ func testRoleStoreLowerScopedChannelSchemeRoles(t *testing.T, rctx request.CTX, } for _, roleName := range allRoleNames { - require.Contains(t, actualRoleNames, roleName) + require.Contains(t, roleByName, roleName) + } + + // Roles for channelScheme1 must carry channelScheme1's ID. + for _, roleName := range []string{ + channelScheme1.DefaultChannelGuestRole, + channelScheme1.DefaultChannelUserRole, + channelScheme1.DefaultChannelAdminRole, + } { + require.NotNil(t, roleByName[roleName].SchemeId) + assert.Equal(t, channelScheme1.Id, *roleByName[roleName].SchemeId) + } + + // Roles for channelScheme2 must carry channelScheme2's ID. + for _, roleName := range []string{ + channelScheme2.DefaultChannelGuestRole, + channelScheme2.DefaultChannelUserRole, + channelScheme2.DefaultChannelAdminRole, + } { + require.NotNil(t, roleByName[roleName].SchemeId) + assert.Equal(t, channelScheme2.Id, *roleByName[roleName].SchemeId) } }) })
server/channels/store/storetest/scheme_store.go+22 −0 modified@@ -174,6 +174,28 @@ func testSchemeStoreSave(t *testing.T, rctx request.CTX, ss store.Store) { assert.Equal(t, role6.Permissions, []string{"read_channel", "read_channel_content", "create_post"}) assert.True(t, role6.SchemeManaged) + role7, err := ss.Role().GetByName(context.Background(), d1.DefaultPlaybookAdminRole) + assert.NoError(t, err) + assert.True(t, role7.SchemeManaged) + + role8, err := ss.Role().GetByName(context.Background(), d1.DefaultPlaybookMemberRole) + assert.NoError(t, err) + assert.True(t, role8.SchemeManaged) + + role9, err := ss.Role().GetByName(context.Background(), d1.DefaultRunAdminRole) + assert.NoError(t, err) + assert.True(t, role9.SchemeManaged) + + role10, err := ss.Role().GetByName(context.Background(), d1.DefaultRunMemberRole) + assert.NoError(t, err) + assert.True(t, role10.SchemeManaged) + + // Every role created for a scheme must carry the scheme's ID. + for _, role := range []*model.Role{role1, role2, role3, role4, role5, role6, role7, role8, role9, role10} { + require.NotNil(t, role.SchemeId) + assert.Equal(t, d1.Id, *role.SchemeId) + } + // Change the scheme description and update. d1.Description = model.NewId()
server/i18n/en.json+8 −0 modified@@ -7718,6 +7718,14 @@ "id": "app.role.save.invalid_role.app_error", "translation": "The role was not valid." }, + { + "id": "app.role.send_updated_role_event.app_error", + "translation": "An error occurred while broadcasting the role update." + }, + { + "id": "app.role.send_updated_role_event.unknown_scope", + "translation": "An error occurred while broadcasting the role update: unknown scheme scope." + }, { "id": "app.save_config.app_error", "translation": "An error occurred saving the configuration."
server/public/model/role.go+6 −0 modified@@ -432,6 +432,7 @@ type Role struct { Permissions []string `json:"permissions"` SchemeManaged bool `json:"scheme_managed"` BuiltIn bool `json:"built_in"` + SchemeId *string `json:"scheme_id"` } func (r *Role) Auditable() map[string]any { @@ -446,6 +447,7 @@ func (r *Role) Auditable() map[string]any { "permissions": r.Permissions, "scheme_managed": r.SchemeManaged, "built_in": r.BuiltIn, + "scheme_id": r.SchemeId, } } @@ -466,6 +468,7 @@ func (r *Role) MarshalYAML() (any, error) { Permissions []string `yaml:"permissions"` SchemeManaged bool `yaml:"scheme_managed"` BuiltIn bool `yaml:"built_in"` + SchemeId *string `yaml:"scheme_id"` }{ Id: r.Id, Name: r.Name, @@ -477,6 +480,7 @@ func (r *Role) MarshalYAML() (any, error) { Permissions: r.Permissions, SchemeManaged: r.SchemeManaged, BuiltIn: r.BuiltIn, + SchemeId: r.SchemeId, }, nil } @@ -492,6 +496,7 @@ func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { Permissions []string `yaml:"permissions"` SchemeManaged bool `yaml:"scheme_managed"` BuiltIn bool `yaml:"built_in"` + SchemeId *string `yaml:"scheme_id"` }{} err := unmarshal(&out) @@ -523,6 +528,7 @@ func (r *Role) UnmarshalYAML(unmarshal func(any) error) error { Permissions: out.Permissions, SchemeManaged: out.SchemeManaged, BuiltIn: out.BuiltIn, + SchemeId: out.SchemeId, } return nil }
202d125afa87[release-10.11] MM-68547: Tighten authorization on group syncable link and patch endpoints (#36434)
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
"The `role_updated` WebSocket event was broadcast globally with empty team and channel IDs instead of being scoped to the affected team or channel."
Attack vector
An authenticated attacker with guest-level access connects to the Mattermost WebSocket endpoint. When an administrator modifies a permission scheme (e.g., updates a role for a team or channel scheme), the server broadcasts a `role_updated` event. Prior to the patch, this event was sent with empty `team_id` and `channel_id`, meaning every connected WebSocket client received it regardless of team or channel membership. This allows the guest attacker to observe that a permission change occurred for a private team or channel they are not a member of, leaking the existence and timing of such changes.
Affected code
The vulnerability is in the `sendUpdatedRoleEvent` function in `server/channels/app/role.go`. The function previously broadcast `role_updated` WebSocket events globally (empty team and channel IDs), allowing any authenticated user, including guests, to observe permission scheme changes for private teams and channels they do not belong to. The patch also modifies `server/channels/store/sqlstore/scheme_store.go` to populate the `SchemeId` field on roles at creation time, enabling the scoped broadcast logic.
What the fix does
The patch rewrites `sendUpdatedRoleEvent` in `server/channels/app/role.go` to scope WebSocket broadcasts to the specific team or channel affected by the role change. For built-in roles, it still broadcasts globally. For scheme-managed roles, it looks up the owning scheme via `SchemeId`, then queries the teams or channels associated with that scheme and emits a separate event for each, setting the `team_id` or `channel_id` accordingly. The Mattermost WebSocket event system then automatically filters these scoped events to only deliver them to clients that are members of the specified team or channel. Additionally, `server/channels/store/sqlstore/scheme_store.go` is updated to populate the `SchemeId` field on roles at creation time, ensuring the new lookup logic works correctly for all scheme-managed roles.
Preconditions
- authThe attacker must be authenticated with at least guest-level access to the Mattermost server.
- networkThe attacker must maintain an active WebSocket connection to the Mattermost server.
Generated on Jun 12, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.