diff --git a/server/channels/api4/channel.go b/server/channels/api4/channel.go index 0f120908009..d21c845ae62 100644 --- a/server/channels/api4/channel.go +++ b/server/channels/api4/channel.go @@ -95,8 +95,8 @@ func (api *API) InitChannel() { api.BaseRoutes.Channel.Handle("/privacy", api.APISessionRequired(updateChannelPrivacy)).Methods(http.MethodPut) api.BaseRoutes.Channel.Handle("/restore", api.APISessionRequired(restoreChannel)).Methods(http.MethodPost) api.BaseRoutes.Channel.Handle("", api.APISessionRequired(deleteChannel)).Methods(http.MethodDelete) - api.BaseRoutes.Channel.Handle("/stats", api.APISessionRequired(getChannelStats)).Methods(http.MethodGet) - api.BaseRoutes.Channel.Handle("/pinned", api.APISessionRequired(getPinnedPosts)).Methods(http.MethodGet) + api.BaseRoutes.Channel.Handle("/stats", api.APISessionRequired(getChannelStats, handlerParamScopedSession)).Methods(http.MethodGet) + api.BaseRoutes.Channel.Handle("/pinned", api.APISessionRequired(getPinnedPosts, handlerParamScopedSession)).Methods(http.MethodGet) api.BaseRoutes.Channel.Handle("/timezones", api.APISessionRequired(getChannelMembersTimezones)).Methods(http.MethodGet) api.BaseRoutes.Channel.Handle("/members_minus_group_members", api.APISessionRequired(channelMembersMinusGroupMembers)).Methods(http.MethodGet) api.BaseRoutes.Channel.Handle("/move", api.APISessionRequired(moveChannel)).Methods(http.MethodPost) @@ -107,8 +107,8 @@ func (api *API) InitChannel() { api.BaseRoutes.ChannelForUser.Handle("/unread", api.APISessionRequired(getChannelUnread)).Methods(http.MethodGet) - api.BaseRoutes.ChannelByName.Handle("", api.APISessionRequired(getChannelByName)).Methods(http.MethodGet) - api.BaseRoutes.ChannelByNameForTeamName.Handle("", api.APISessionRequired(getChannelByNameForTeamName)).Methods(http.MethodGet) + api.BaseRoutes.ChannelByName.Handle("", api.APISessionRequired(getChannelByName, handlerParamScopedSession)).Methods(http.MethodGet) + api.BaseRoutes.ChannelByNameForTeamName.Handle("", api.APISessionRequired(getChannelByNameForTeamName, handlerParamScopedSession)).Methods(http.MethodGet) api.BaseRoutes.ChannelMembers.Handle("", api.APISessionRequired(getChannelMembers)).Methods(http.MethodGet) api.BaseRoutes.ChannelMembers.Handle("/ids", api.APISessionRequired(getChannelMembersByIds)).Methods(http.MethodPost) diff --git a/server/channels/api4/reaction.go b/server/channels/api4/reaction.go index 05458837a86..39366664db9 100644 --- a/server/channels/api4/reaction.go +++ b/server/channels/api4/reaction.go @@ -14,7 +14,7 @@ import ( func (api *API) InitReaction() { api.BaseRoutes.Reactions.Handle("", api.APISessionRequired(saveReaction)).Methods(http.MethodPost) - api.BaseRoutes.Post.Handle("/reactions", api.APISessionRequired(getReactions)).Methods(http.MethodGet) + api.BaseRoutes.Post.Handle("/reactions", api.APISessionRequired(getReactions, handlerParamScopedSession)).Methods(http.MethodGet) api.BaseRoutes.ReactionByNameForPostForUser.Handle("", api.APISessionRequired(deleteReaction)).Methods(http.MethodDelete) } diff --git a/server/channels/api4/scoped_session_test.go b/server/channels/api4/scoped_session_test.go index 7529980613f..52987203d46 100644 --- a/server/channels/api4/scoped_session_test.go +++ b/server/channels/api4/scoped_session_test.go @@ -33,17 +33,24 @@ const scopedSessionErrorID = "api.context.scoped_session_out_of_scope.app_error" var scopedSessionAllowedRoutes = []string{ "GET /api/v4/channels/{channel_id}", "GET /api/v4/channels/{channel_id}/posts", + "GET /api/v4/channels/{channel_id}/pinned", + "GET /api/v4/channels/{channel_id}/stats", "GET /api/v4/files/{file_id}", "HEAD /api/v4/files/{file_id}", "GET /api/v4/files/{file_id}/info", "GET /api/v4/posts/{post_id}", "GET /api/v4/posts/{post_id}/files/info", "GET /api/v4/posts/{post_id}/thread", + "GET /api/v4/posts/{post_id}/reactions", "GET /api/v4/teams/{team_id}", + "GET /api/v4/teams/{team_id}/channels/name/{channel_name}", + "GET /api/v4/teams/name/{team_name}", + "GET /api/v4/teams/name/{team_name}/channels/name/{channel_name}", "GET /api/v4/users/{user_id}", "POST /api/v4/users/ids", "POST /api/v4/users/usernames", "GET /api/v4/users/username/{username}", + "POST /api/v4/users/search", } type scopedAPIFixture struct { @@ -350,6 +357,187 @@ func TestScopedSessionReads(t *testing.T) { }) } +func TestScopedSessionLookupsByName(t *testing.T) { + th := Setup(t).InitBasic(t) + f := setupScopedAPIFixture(t, th) + ctx := context.Background() + + t.Run("channel by name", func(t *testing.T) { + got, _, err := f.client.GetChannelByName(ctx, f.triggering.Name, f.closedTeam.Id, "") + require.NoError(t, err) + assert.Equal(t, f.triggering.Id, got.Id) + got, _, err = f.client.GetChannelByNameForTeamName(ctx, f.publicOpen.Name, f.openTeam.Name, "") + require.NoError(t, err) + assert.Equal(t, f.publicOpen.Id, got.Id) + + publicClosed := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, f.closedTeam.Id) + for _, lookup := range []struct{ name, teamID, teamName string }{ + {f.memberPriv.Name, f.openTeam.Id, f.openTeam.Name}, + {publicClosed.Name, f.closedTeam.Id, f.closedTeam.Name}, + {f.botDM.Name, f.openTeam.Id, f.openTeam.Name}, + {"missing-" + model.NewId(), f.openTeam.Id, f.openTeam.Name}, + } { + _, resp, err := f.client.GetChannelByName(ctx, lookup.name, lookup.teamID, "") + requireScopeError(t, resp, err) + _, resp, err = f.client.GetChannelByNameForTeamName(ctx, lookup.name, lookup.teamName, "") + requireScopeError(t, resp, err) + } + }) + + t.Run("team by name", func(t *testing.T) { + got, _, err := f.client.GetTeamByName(ctx, f.openTeam.Name, "") + require.NoError(t, err) + assert.Equal(t, f.openTeam.Id, got.Id) + _, _, err = f.client.GetTeamByName(ctx, f.closedTeam.Name, "") + require.NoError(t, err, "invite-only team holding a listed channel") + + unlistedClosed := th.CreateTeam(t) + unlistedClosed.AllowOpenInvite = false + unlistedClosed.Type = model.TeamInvite + _, err = th.App.Srv().Store().Team().Update(unlistedClosed) + require.NoError(t, err) + _, resp, err := f.client.GetTeamByName(ctx, unlistedClosed.Name, "") + requireScopeError(t, resp, err) + + restricted := th.CreateTeam(t) + restricted.AllowOpenInvite = true + restricted.AllowedDomains = "example.com" + _, err = th.App.Srv().Store().Team().Update(restricted) + require.NoError(t, err) + _, _, err = th.Client.GetTeamByName(ctx, restricted.Name, "") + require.NoError(t, err, "unscoped sessions see open-invite teams by name") + _, resp, err = f.client.GetTeamByName(ctx, restricted.Name, "") + requireScopeError(t, resp, err) + }) + + t.Run("users", func(t *testing.T) { + users, _, err := f.client.SearchUsers(ctx, &model.UserSearch{Term: th.BasicUser.Username}) + require.NoError(t, err) + require.NotEmpty(t, users) + assert.Equal(t, th.BasicUser.Id, users[0].Id) + + users, _, err = f.client.SearchUsers(ctx, &model.UserSearch{Term: th.BasicUser.Username, InChannelId: f.triggering.Id}) + require.NoError(t, err) + assert.NotEmpty(t, users, "members of an in-scope channel") + users, _, err = f.client.SearchUsers(ctx, &model.UserSearch{Term: th.BasicUser.Username, TeamId: f.closedTeam.Id, NotInChannelId: f.triggering.Id}) + require.NoError(t, err) + assert.Empty(t, users) + + for _, search := range []*model.UserSearch{ + {Term: th.BasicUser.Username, InChannelId: f.botDM.Id}, + {Term: th.BasicUser.Username, InChannelId: f.memberPriv.Id}, + {Term: th.BasicUser.Username, InGroupId: model.NewId()}, + {Term: th.BasicUser.Username, NotInGroupId: model.NewId()}, + } { + _, resp, err := f.client.SearchUsers(ctx, search) + requireScopeError(t, resp, err) + } + }) +} + +func TestScopedSessionChannelMetadata(t *testing.T) { + th := Setup(t).InitBasic(t) + f := setupScopedAPIFixture(t, th) + ctx := context.Background() + + post, _, err := th.Client.CreatePost(ctx, &model.Post{ChannelId: f.triggering.Id, Message: "pinned"}) + require.NoError(t, err) + _, err = th.Client.PinPost(ctx, post.Id) + require.NoError(t, err) + _, _, err = th.Client.SaveReaction(ctx, &model.Reaction{UserId: th.BasicUser.Id, PostId: post.Id, EmojiName: "smile"}) + require.NoError(t, err) + _, err = th.Client.PinPost(ctx, f.dmPost.Id) + require.NoError(t, err) + _, _, err = th.Client.SaveReaction(ctx, &model.Reaction{UserId: th.BasicUser.Id, PostId: f.dmPost.Id, EmojiName: "smile"}) + require.NoError(t, err) + + stats, _, err := f.client.GetChannelStats(ctx, f.triggering.Id, "", false) + require.NoError(t, err) + assert.Equal(t, int64(1), stats.PinnedPostCount) + pinned, _, err := f.client.GetPinnedPosts(ctx, f.triggering.Id, "") + require.NoError(t, err) + assert.Contains(t, pinned.Posts, post.Id) + reactions, _, err := f.client.GetReactions(ctx, post.Id) + require.NoError(t, err) + assert.Len(t, reactions, 1) + + for _, channel := range []*model.Channel{f.botDM, f.memberPriv} { + _, resp, statsErr := f.client.GetChannelStats(ctx, channel.Id, "", false) + requireScopeError(t, resp, statsErr) + _, resp, pinnedErr := f.client.GetPinnedPosts(ctx, channel.Id, "") + requireScopeError(t, resp, pinnedErr) + } + _, resp, err := f.client.GetReactions(ctx, f.dmPost.Id) + requireScopeError(t, resp, err) +} + +// A fresh server's teams are invite-only, so the open-team grant alone leaves the +// triggering channel's team mostly out of scope; public_channel_team_ids covers it. +func TestScopedSessionPublicChannelsOnListedTeam(t *testing.T) { + th := Setup(t).InitBasic(t) + f := setupScopedAPIFixture(t, th) + ctx := context.Background() + + publicClosed := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, f.closedTeam.Id) + privateClosed := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypePrivate, f.closedTeam.Id) + otherClosed := th.CreateTeam(t) + otherClosed.AllowOpenInvite = false + otherClosed.Type = model.TeamInvite + _, err := th.App.Srv().Store().Team().Update(otherClosed) + require.NoError(t, err) + th.LinkUserToTeam(t, th.BasicUser, otherClosed) + otherPublic := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, otherClosed.Id) + post, _, err := th.Client.CreatePost(ctx, &model.Post{ChannelId: publicClosed.Id, Message: "team grant"}) + require.NoError(t, err) + + _, resp, err := f.client.GetChannel(ctx, publicClosed.Id) + requireScopeError(t, resp, err) + + session, appErr := th.App.CreateScopedSession(th.Context, "com.example.scoped", f.bot.Id, &model.SessionScope{ + Permissions: model.ScopeReadPermissions, + Resources: model.SessionScopeResources{ + ChannelIDs: []string{f.triggering.Id}, + OpenTeamPublicChannels: true, + PublicChannelTeamIDs: []string{f.closedTeam.Id}, + }, + }, model.GetMillis()+30*60*1000) + require.Nil(t, appErr) + client := th.CreateClient() + client.SetToken(session.Token) + + _, _, err = client.GetChannel(ctx, publicClosed.Id) + require.NoError(t, err, "public channel on the triggering team, bot not on the team") + list, _, err := client.GetPostsForChannel(ctx, publicClosed.Id, 0, 10, "", false, false) + require.NoError(t, err) + assert.Contains(t, list.Posts, post.Id) + _, _, err = client.GetPostThread(ctx, post.Id, "", false) + require.NoError(t, err) + _, _, err = client.GetChannelByName(ctx, publicClosed.Name, f.closedTeam.Id, "") + require.NoError(t, err) + _, _, err = client.GetTeam(ctx, f.closedTeam.Id, "") + require.NoError(t, err) + _, _, err = client.GetPostsForChannel(ctx, f.publicOpen.Id, 0, 10, "", false, false) + require.NoError(t, err, "the open-team grant still applies") + + for _, channel := range []*model.Channel{privateClosed, otherPublic, f.memberPriv, f.botDM} { + _, resp, err = client.GetChannel(ctx, channel.Id) + requireScopeError(t, resp, err) + _, resp, err = client.GetPostsForChannel(ctx, channel.Id, 0, 10, "", false, false) + requireScopeError(t, resp, err) + } + _, resp, err = client.GetTeam(ctx, otherClosed.Id, "") + requireScopeError(t, resp, err) + _, resp, err = client.CreatePost(ctx, &model.Post{ChannelId: publicClosed.Id, Message: "nope"}) + requireScopeError(t, resp, err) + + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ComplianceSettings.Enable = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ComplianceSettings.Enable = false }) + _, resp, err = client.GetPostsForChannel(ctx, publicClosed.Id, 0, 10, "", false, false) + requireScopeError(t, resp, err) + _, _, err = client.GetPostsForChannel(ctx, f.triggering.Id, 0, 10, "", false, false) + require.NoError(t, err, "listed channel in compliance mode") +} + func TestScopedSessionMissingObjectsLookOutOfScope(t *testing.T) { th := Setup(t).InitBasic(t) f := setupScopedAPIFixture(t, th) diff --git a/server/channels/api4/team.go b/server/channels/api4/team.go index a93a79a36dc..1b6333d2a0a 100644 --- a/server/channels/api4/team.go +++ b/server/channels/api4/team.go @@ -60,7 +60,7 @@ func (api *API) InitTeam() { api.BaseRoutes.TeamForUser.Handle("/unread", api.APISessionRequired(getTeamUnread)).Methods(http.MethodGet) - api.BaseRoutes.TeamByName.Handle("", api.APISessionRequired(getTeamByName)).Methods(http.MethodGet) + api.BaseRoutes.TeamByName.Handle("", api.APISessionRequired(getTeamByName, handlerParamScopedSession)).Methods(http.MethodGet) api.BaseRoutes.TeamMember.Handle("", api.APISessionRequired(getTeamMember)).Methods(http.MethodGet) api.BaseRoutes.TeamByName.Handle("/exists", api.APISessionRequired(teamExists)).Methods(http.MethodGet) api.BaseRoutes.TeamMember.Handle("/roles", api.APISessionRequired(updateTeamMemberRoles)).Methods(http.MethodPut) @@ -368,7 +368,8 @@ func getTeamByName(c *Context, w http.ResponseWriter, r *http.Request) { return } - if (!team.AllowOpenInvite || team.Type != model.TeamOpen) && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { + openInvite := team.AllowOpenInvite && team.Type == model.TeamOpen && !c.AppContext.Session().IsScoped() + if !openInvite && !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), team.Id, model.PermissionViewTeam) { c.SetPermissionError(model.PermissionViewTeam) return } diff --git a/server/channels/api4/user.go b/server/channels/api4/user.go index 194eff8b091..6b1e2df1887 100644 --- a/server/channels/api4/user.go +++ b/server/channels/api4/user.go @@ -32,7 +32,7 @@ func (api *API) InitUser() { api.BaseRoutes.Users.Handle("/ids", api.APISessionRequired(getUsersByIds, handlerParamScopedSession)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/usernames", api.APISessionRequired(getUsersByNames, handlerParamScopedSession)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/known", api.APISessionRequired(getKnownUsers)).Methods(http.MethodGet) - api.BaseRoutes.Users.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchUsers)).Methods(http.MethodPost) + api.BaseRoutes.Users.Handle("/search", api.APISessionRequiredDisableWhenBusy(searchUsers, handlerParamScopedSession)).Methods(http.MethodPost) api.BaseRoutes.Users.Handle("/autocomplete", api.APISessionRequired(autocompleteUsers)).Methods(http.MethodGet) api.BaseRoutes.Users.Handle("/stats", api.APISessionRequired(getTotalUsersStats)).Methods(http.MethodGet) api.BaseRoutes.Users.Handle("/stats/filtered", api.APISessionRequired(getFilteredUsersStats)).Methods(http.MethodGet) @@ -1297,6 +1297,11 @@ func searchUsers(c *Context, w http.ResponseWriter, r *http.Request) { return } + if (props.InGroupId != "" || props.NotInGroupId != "") && c.AppContext.Session().IsScoped() { + c.SetPermissionError(model.PermissionSysconsoleReadUserManagementGroups) + return + } + if props.InGroupId != "" { if appErr := hasPermissionToReadGroupMembers(c, props.InGroupId); appErr != nil { appErr.Where = "Api.searchUsers" diff --git a/server/channels/app/session_scope.go b/server/channels/app/session_scope.go index 90781191cc7..e99cebfd871 100644 --- a/server/channels/app/session_scope.go +++ b/server/channels/app/session_scope.go @@ -30,9 +30,9 @@ type SessionScopeTarget struct { // // A scoped session may use a permission only if the scope holds it, and only // on a channel in the scope (see model.SessionScopeAllowsChannel), or for -// view_team/list_team_channels, on a team open to all users or holding a -// channel the scope lists by ID. Everything else, including every system-wide -// check, is denied. +// view_team/list_team_channels, on a team a public-channel grant covers (see +// model.SessionScopeGrantsTeamPublicChannels) or holding a channel the scope +// lists by ID. Everything else, including every system-wide check, is denied. func (a *App) EvaluateSessionScope(rctx request.CTX, session *model.Session, permissionID string, target SessionScopeTarget) (scoped bool, allowed bool) { if session == nil { return false, false @@ -71,26 +71,28 @@ func (a *App) EvaluateSessionScope(rctx request.CTX, session *model.Session, per if team.DeleteAt != 0 { return true, false } - openTeamGrant := scope.Resources.OpenTeamPublicChannels && team.IsOpenToAllUsers() && !a.openTeamGrantDisabled(rctx) - return true, openTeamGrant || a.scopeListsChannelOnTeam(rctx, scope, team.Id) + return true, model.SessionScopeGrantsTeamPublicChannels(scope, team, a.sessionScopeLimits(rctx)) || a.scopeListsChannelOnTeam(rctx, scope, team.Id) } return true, false } -// openTeamGrantDisabled reports whether the server turns the scope's open-team -// grant off: in compliance mode, or when ordinary users can't join open teams, -// since the grant is only safe because any user could read those channels. -func (a *App) openTeamGrantDisabled(rctx request.CTX) bool { - if *a.Config().ComplianceSettings.Enable { - return true +// sessionScopeLimits returns the server settings that turn a scope's +// public-channel grants off. Failing to read the system user role turns the +// open-team grant off. +func (a *App) sessionScopeLimits(rctx request.CTX) model.SessionScopeLimits { + limits := model.SessionScopeLimits{ComplianceMode: *a.Config().ComplianceSettings.Enable} + if limits.ComplianceMode { + return limits } role, err := a.Srv().Store().Role().GetByName(rctx, model.SystemUserRoleId) if err != nil { rctx.Logger().Warn("Failed to get the system user role for a session scope check", mlog.Err(err)) - return true + limits.UsersCannotJoinOpenTeams = true + return limits } - return !slices.Contains(role.Permissions, model.PermissionJoinPublicTeams.Id) + limits.UsersCannotJoinOpenTeams = !slices.Contains(role.Permissions, model.PermissionJoinPublicTeams.Id) + return limits } // scopeListedChannels returns the channels the scope lists by ID that are in @@ -108,7 +110,7 @@ func (a *App) scopeListedChannels(rctx request.CTX, scope *model.SessionScope) [ } listed := make([]*model.Channel, 0, len(channels)) for _, channel := range channels { - if model.SessionScopeAllowsChannel(scope, channel, nil, true) { + if model.SessionScopeAllowsChannel(scope, channel, nil, model.SessionScopeLimits{}) { listed = append(listed, channel) } } @@ -124,27 +126,26 @@ func (a *App) scopeListsChannelOnTeam(rctx request.CTX, scope *model.SessionScop } // ScopeAllowsChannel reports whether channel is in scope, loading the channel's -// team only when the open-team grant needs it. +// team and the server limits only when a public-channel grant needs them. func (a *App) ScopeAllowsChannel(rctx request.CTX, scope *model.SessionScope, channel *model.Channel) bool { if scope == nil || channel == nil { return false } var team *model.Team - openTeamGrantDisabled := true - if scope.Resources.OpenTeamPublicChannels && channel.Type == model.ChannelTypeOpen && !scope.ListsChannel(channel.Id) { - openTeamGrantDisabled = a.openTeamGrantDisabled(rctx) - if !openTeamGrantDisabled { - var appErr *model.AppError - team, appErr = a.GetTeam(channel.TeamId) - if appErr != nil { - rctx.Logger().Debug("Failed to get team for session scope check", mlog.String("team_id", channel.TeamId), mlog.Err(appErr)) - return false - } + var limits model.SessionScopeLimits + publicGrant := scope.Resources.OpenTeamPublicChannels || slices.Contains(scope.Resources.PublicChannelTeamIDs, channel.TeamId) + if publicGrant && channel.Type == model.ChannelTypeOpen && !scope.ListsChannel(channel.Id) { + limits = a.sessionScopeLimits(rctx) + var appErr *model.AppError + team, appErr = a.GetTeam(channel.TeamId) + if appErr != nil { + rctx.Logger().Debug("Failed to get team for session scope check", mlog.String("team_id", channel.TeamId), mlog.Err(appErr)) + return false } } - return model.SessionScopeAllowsChannel(scope, channel, team, openTeamGrantDisabled) + return model.SessionScopeAllowsChannel(scope, channel, team, limits) } // sessionScopeDecision is EvaluateSessionScope for callers without a request @@ -226,6 +227,7 @@ func (a *App) CreateScopedSession(rctx request.CTX, pluginID, userID string, sco auditRec.AddMeta(model.AuditKeyOnBehalfOfUserID, scopeCopy.OnBehalfOfUserID) model.AddEventParameterToAuditRec(auditRec, "channel_ids", scopeCopy.Resources.ChannelIDs) model.AddEventParameterToAuditRec(auditRec, "open_team_public_channels", scopeCopy.Resources.OpenTeamPublicChannels) + model.AddEventParameterToAuditRec(auditRec, "public_channel_team_ids", scopeCopy.Resources.PublicChannelTeamIDs) model.AddEventParameterToAuditRec(auditRec, "permissions", scopeCopy.Permissions) if validErr := scopeCopy.IsValid(); validErr != nil { @@ -264,6 +266,18 @@ func (a *App) CreateScopedSession(rctx request.CTX, pluginID, userID string, sco } } + if len(scopeCopy.Resources.PublicChannelTeamIDs) > 0 { + teams, teamErr := a.Srv().Store().Team().GetMany(scopeCopy.Resources.PublicChannelTeamIDs) + if teamErr != nil { + return nil, model.NewAppError("CreateScopedSession", "app.session.create_scoped.team.app_error", nil, "", http.StatusBadRequest).Wrap(teamErr) + } + for _, teamID := range scopeCopy.Resources.PublicChannelTeamIDs { + if !slices.ContainsFunc(teams, func(team *model.Team) bool { return team.Id == teamID }) { + return nil, model.NewAppError("CreateScopedSession", "app.session.create_scoped.team.app_error", nil, "team_id="+teamID, http.StatusBadRequest) + } + } + } + scopeJSON, err := json.Marshal(scopeCopy) if err != nil { return nil, model.NewAppError("CreateScopedSession", "app.session.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err) diff --git a/server/channels/app/session_scope_test.go b/server/channels/app/session_scope_test.go index 36f55b2263b..5b62662b72a 100644 --- a/server/channels/app/session_scope_test.go +++ b/server/channels/app/session_scope_test.go @@ -249,6 +249,89 @@ func TestSessionScopeOpenTeamGrantNeedsJoinPublicTeams(t *testing.T) { assert.True(t, th.App.SessionHasPermissionToTeam(*session, f.closedTeam.Id, model.PermissionViewTeam), "team holding a listed channel") } +func TestSessionScopePublicChannelsOnListedTeams(t *testing.T) { + th := Setup(t).InitBasic(t) + f := newScopeFixture(t, th) + scope := f.scope() + scope.Permissions = append(scope.Permissions, model.PermissionListTeamChannels.Id) + scope.Resources.OpenTeamPublicChannels = false + scope.Resources.PublicChannelTeamIDs = []string{f.closedTeam.Id} + session := mintScoped(t, th, f.bot.Id, scope) + rctx := th.Context.WithSession(session) + + canRead := func(channel *model.Channel) bool { + ok, _ := th.App.SessionHasPermissionToChannel(rctx, *session, channel.Id, model.PermissionReadChannelContent) + return ok + } + otherClosed := setTeamOpen(t, th, th.CreateTeam(t), false) + + assert.True(t, canRead(f.publicClosed), "public channel on the listed invite-only team, bot not a member") + assert.True(t, canRead(f.triggering), "listed channel") + assert.False(t, canRead(th.CreatePrivateChannel(t, f.closedTeam)), "unlisted private channel on the listed team") + assert.False(t, canRead(f.publicOpen), "public channel on an open team without the open-team grant") + assert.False(t, canRead(th.CreateChannel(t, otherClosed)), "public channel on another invite-only team") + assert.False(t, canRead(f.botDM)) + assert.True(t, th.App.SessionHasPermissionToTeam(*session, f.closedTeam.Id, model.PermissionViewTeam)) + assert.True(t, th.App.SessionHasPermissionToTeam(*session, f.closedTeam.Id, model.PermissionListTeamChannels)) + assert.False(t, th.App.SessionHasPermissionToTeam(*session, otherClosed.Id, model.PermissionViewTeam)) + assert.False(t, th.App.SessionHasPermissionToTeam(*session, f.closedTeam.Id, model.PermissionManageTeam)) + + t.Run("not tied to join_public_teams", func(t *testing.T) { + th.RemovePermissionFromRole(t, model.PermissionJoinPublicTeams.Id, model.SystemUserRoleId) + defer th.AddPermissionToRole(t, model.PermissionJoinPublicTeams.Id, model.SystemUserRoleId) + assert.True(t, canRead(f.publicClosed)) + }) + + t.Run("compliance mode turns it off", func(t *testing.T) { + th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ComplianceSettings.Enable = true }) + defer th.App.UpdateConfig(func(cfg *model.Config) { *cfg.ComplianceSettings.Enable = false }) + assert.False(t, canRead(f.publicClosed)) + assert.True(t, canRead(f.triggering), "listed channel") + }) + + t.Run("policy-enforced channels and teams are excluded", func(t *testing.T) { + policyChannel := th.CreateChannel(t, f.closedTeam) + policyChannel.PolicyEnforced = true + assert.False(t, th.App.ScopeAllowsChannel(rctx, scope, policyChannel)) + + policyTeam := setTeamOpen(t, th, th.CreateTeam(t), false) + policyPublic := th.CreateChannel(t, policyTeam) + _, err := th.App.Srv().Store().AccessControlPolicy().Save(th.Context, &model.AccessControlPolicy{ + ID: policyTeam.Id, + Name: "Scoped team policy", + Type: model.AccessControlPolicyTypeTeam, + Active: true, + Version: model.AccessControlPolicyVersionV0_3, + Rules: []model.AccessControlPolicyRule{{ + Actions: []string{model.AccessControlPolicyActionMembership}, + Expression: `user.attributes.program == "engineering"`, + }}, + }) + require.NoError(t, err) + policyScope := f.scope() + policyScope.Resources.PublicChannelTeamIDs = []string{policyTeam.Id} + policySession := mintScoped(t, th, f.bot.Id, policyScope) + team, appErr := th.App.GetTeam(policyTeam.Id) + require.Nil(t, appErr) + require.True(t, team.PolicyEnforced) + ok, _ := th.App.SessionHasPermissionToChannel(th.Context.WithSession(policySession), *policySession, policyPublic.Id, model.PermissionReadChannelContent) + assert.False(t, ok) + assert.False(t, th.App.SessionHasPermissionToTeam(*policySession, policyTeam.Id, model.PermissionViewTeam)) + }) + + t.Run("a deleted team grants nothing", func(t *testing.T) { + team := setTeamOpen(t, th, th.CreateTeam(t), false) + public := th.CreateChannel(t, team) + teamScope := f.scope() + teamScope.Resources.PublicChannelTeamIDs = []string{team.Id} + teamSession := mintScoped(t, th, f.bot.Id, teamScope) + require.Nil(t, th.App.SoftDeleteTeam(team.Id)) + ok, _ := th.App.SessionHasPermissionToChannel(th.Context.WithSession(teamSession), *teamSession, public.Id, model.PermissionReadChannelContent) + assert.False(t, ok) + assert.False(t, th.App.SessionHasPermissionToTeam(*teamSession, team.Id, model.PermissionViewTeam)) + }) +} + func TestSessionScopeViewUsersRestrictions(t *testing.T) { th := Setup(t).InitBasic(t) f := newScopeFixture(t, th) @@ -412,6 +495,7 @@ func TestCreateScopedSession(t *testing.T) { t.Run("success", func(t *testing.T) { scope := f.scope() + scope.Resources.PublicChannelTeamIDs = []string{f.closedTeam.Id} scope.CreatedByPluginID = "forged" session, appErr := th.App.CreateScopedSession(th.Context, scopeTestPluginID, f.bot.Id, scope, expiresAt) require.Nil(t, appErr) @@ -450,10 +534,20 @@ func TestCreateScopedSession(t *testing.T) { s.Resources.ChannelIDs = []string{th.CreateGroupChannel(t, th.BasicUser2, f.bot).Id} return s }, expiresAt}, - "missing channel": {scopeTestPluginID, f.bot.Id, func() *model.SessionScope { s := f.scope(); s.Resources.ChannelIDs = []string{model.NewId()}; return s }, expiresAt}, - "expired": {scopeTestPluginID, f.bot.Id, f.scope, model.GetMillis() - 1}, - "beyond 24h": {scopeTestPluginID, f.bot.Id, f.scope, model.GetMillis() + model.SessionScopeMaxLifetimeMillis + 60*1000}, - "missing user": {scopeTestPluginID, model.NewId(), f.scope, expiresAt}, + "missing channel": {scopeTestPluginID, f.bot.Id, func() *model.SessionScope { s := f.scope(); s.Resources.ChannelIDs = []string{model.NewId()}; return s }, expiresAt}, + "expired": {scopeTestPluginID, f.bot.Id, f.scope, model.GetMillis() - 1}, + "beyond 24h": {scopeTestPluginID, f.bot.Id, f.scope, model.GetMillis() + model.SessionScopeMaxLifetimeMillis + 60*1000}, + "missing user": {scopeTestPluginID, model.NewId(), f.scope, expiresAt}, + "missing team": {scopeTestPluginID, f.bot.Id, func() *model.SessionScope { + s := f.scope() + s.Resources.PublicChannelTeamIDs = []string{f.closedTeam.Id, model.NewId()} + return s + }, expiresAt}, + "invalid team ID": {scopeTestPluginID, f.bot.Id, func() *model.SessionScope { + s := f.scope() + s.Resources.PublicChannelTeamIDs = []string{"x"} + return s + }, expiresAt}, "invalid behalf of": {scopeTestPluginID, f.bot.Id, func() *model.SessionScope { s := f.scope(); s.OnBehalfOfUserID = "x"; return s }, expiresAt}, } { t.Run(name, func(t *testing.T) { diff --git a/server/i18n/en.json b/server/i18n/en.json index 1da195f02c5..9b7805fce65 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -12111,6 +12111,11 @@ "translation": "A scope is required to create a scoped session.", "description": "Error returned when a plugin calls CreateScopedSession without a scope." }, + { + "id": "app.session.create_scoped.team.app_error", + "translation": "Scoped session teams must exist.", + "description": "Error returned when a plugin requests a scoped session granting the public channels of a team that does not exist." + }, { "id": "app.session.create_scoped.user.app_error", "translation": "Scoped sessions can only be created for active local users.", @@ -18886,6 +18891,11 @@ "translation": "Session scope permissions must be a non-empty list of read permissions.", "description": "Validation error when a plugin asks for a scoped session whose permission list is empty or includes a permission other than the allowed read permissions." }, + { + "id": "model.session_scope.is_valid.team_ids.app_error", + "translation": "Session scope team IDs must be valid, at most {{.Max}}.", + "description": "Validation error when a plugin asks for a scoped session with an invalid team ID or too many teams in public_channel_team_ids. {{.Max}} is the maximum number of teams." + }, { "id": "model.team.is_valid.characters.app_error", "translation": "Name must be 2 or more lowercase alphanumeric characters.", diff --git a/server/public/model/session_scope.go b/server/public/model/session_scope.go index eb22da9535d..a491286fc75 100644 --- a/server/public/model/session_scope.go +++ b/server/public/model/session_scope.go @@ -16,6 +16,8 @@ const SessionPropScope = "scope" const SessionScopeMaxChannelIDs = 200 +const SessionScopeMaxTeamIDs = 50 + const SessionScopeMaxLifetimeMillis = 24 * 60 * 60 * 1000 // ScopeReadPermissions are the only permissions a session scope may hold. @@ -50,6 +52,40 @@ type SessionScopeResources struct { // OpenTeamPublicChannels adds every public channel on a team that any user // can join, see Team.IsOpenToAllUsers. OpenTeamPublicChannels bool `json:"open_team_public_channels,omitempty"` + + // PublicChannelTeamIDs adds every public channel on these teams, whether or + // not the team is open to all users, and makes the teams visible. + PublicChannelTeamIDs []string `json:"public_channel_team_ids,omitempty"` +} + +// SessionScopeLimits are the server settings that turn a scope's public-channel +// grants off. +type SessionScopeLimits struct { + // ComplianceMode is ComplianceSettings.Enable. It turns both public-channel + // grants off, since compliance mode blocks reading public channels without + // membership. + ComplianceMode bool + + // UsersCannotJoinOpenTeams is true when the system_user role lacks + // join_public_teams. It turns the open-team grant off, since that grant is + // only safe because any user could join those teams. + UsersCannotJoinOpenTeams bool +} + +func (l SessionScopeLimits) openTeamGrantOff() bool { + return l.ComplianceMode || l.UsersCannotJoinOpenTeams +} + +// WithLimits returns the resources without the grants limits turns off. +func (r SessionScopeResources) WithLimits(limits SessionScopeLimits) SessionScopeResources { + effective := SessionScopeResources{ChannelIDs: slices.Clone(r.ChannelIDs)} + if !limits.openTeamGrantOff() { + effective.OpenTeamPublicChannels = r.OpenTeamPublicChannels + } + if !limits.ComplianceMode { + effective.PublicChannelTeamIDs = slices.Clone(r.PublicChannelTeamIDs) + } + return effective } func (s *SessionScope) IsValid() *AppError { @@ -71,6 +107,15 @@ func (s *SessionScope) IsValid() *AppError { } } + if len(s.Resources.PublicChannelTeamIDs) > SessionScopeMaxTeamIDs { + return NewAppError("SessionScope.IsValid", "model.session_scope.is_valid.team_ids.app_error", map[string]any{"Max": SessionScopeMaxTeamIDs}, "", http.StatusBadRequest) + } + for _, teamID := range s.Resources.PublicChannelTeamIDs { + if !IsValidId(teamID) { + return NewAppError("SessionScope.IsValid", "model.session_scope.is_valid.team_ids.app_error", map[string]any{"Max": SessionScopeMaxTeamIDs}, "team_id="+teamID, http.StatusBadRequest) + } + } + if s.OnBehalfOfUserID != "" && !IsValidId(s.OnBehalfOfUserID) { return NewAppError("SessionScope.IsValid", "model.session_scope.is_valid.on_behalf_of_user_id.app_error", nil, "", http.StatusBadRequest) } @@ -111,12 +156,24 @@ func (s *Session) Scope() (scope *SessionScope, scoped bool) { return &parsed, true } +// SessionScopeGrantsTeamPublicChannels reports whether a public-channel grant of +// the scope covers team: the open-team grant for a team open to all users, or +// the team being in PublicChannelTeamIDs. Deleted and policy-enforced teams are +// never covered. +func SessionScopeGrantsTeamPublicChannels(scope *SessionScope, team *Team, limits SessionScopeLimits) bool { + if scope == nil || team == nil || team.DeleteAt != 0 || team.PolicyEnforced { + return false + } + if scope.Resources.OpenTeamPublicChannels && !limits.openTeamGrantOff() && team.IsOpenToAllUsers() { + return true + } + return !limits.ComplianceMode && slices.Contains(scope.Resources.PublicChannelTeamIDs, team.Id) +} + // SessionScopeAllowsChannel is the single decision of whether a channel is in a // session scope. team must be the channel's team, or nil if it isn't known; it -// is only consulted for the open-team grant. openTeamGrantDisabled turns the -// open-team grant off. It must be true when ComplianceSettings.Enable is on or -// when the system_user role lacks join_public_teams. -func SessionScopeAllowsChannel(scope *SessionScope, channel *Channel, team *Team, openTeamGrantDisabled bool) bool { +// is only consulted for the public-channel grants. +func SessionScopeAllowsChannel(scope *SessionScope, channel *Channel, team *Team, limits SessionScopeLimits) bool { if scope == nil || channel == nil { return false } @@ -128,10 +185,8 @@ func SessionScopeAllowsChannel(scope *SessionScope, channel *Channel, team *Team return true } - return scope.Resources.OpenTeamPublicChannels && - !openTeamGrantDisabled && - channel.Type == ChannelTypeOpen && + return channel.Type == ChannelTypeOpen && team != nil && team.Id == channel.TeamId && - team.IsOpenToAllUsers() + SessionScopeGrantsTeamPublicChannels(scope, team, limits) } diff --git a/server/public/model/session_scope_test.go b/server/public/model/session_scope_test.go index 05a11a3a2c7..c192d002992 100644 --- a/server/public/model/session_scope_test.go +++ b/server/public/model/session_scope_test.go @@ -68,6 +68,27 @@ func TestSessionScopeIsValid(t *testing.T) { assert.NotNil(t, s.IsValid()) }) + t.Run("public channels on listed teams", func(t *testing.T) { + s := valid() + s.Resources.PublicChannelTeamIDs = []string{NewId()} + assert.Nil(t, s.IsValid()) + }) + + t.Run("invalid team ID", func(t *testing.T) { + s := valid() + s.Resources.PublicChannelTeamIDs = []string{"not-an-id"} + assert.NotNil(t, s.IsValid()) + }) + + t.Run("too many teams", func(t *testing.T) { + s := valid() + s.Resources.PublicChannelTeamIDs = make([]string, SessionScopeMaxTeamIDs+1) + for i := range s.Resources.PublicChannelTeamIDs { + s.Resources.PublicChannelTeamIDs[i] = NewId() + } + assert.NotNil(t, s.IsValid()) + }) + t.Run("invalid on-behalf-of user", func(t *testing.T) { s := valid() s.OnBehalfOfUserID = "bad" @@ -93,7 +114,7 @@ func TestSessionScopeParsing(t *testing.T) { t.Run("round trip", func(t *testing.T) { in := SessionScope{ Permissions: []string{PermissionReadChannel.Id}, - Resources: SessionScopeResources{ChannelIDs: []string{NewId()}, OpenTeamPublicChannels: true}, + Resources: SessionScopeResources{ChannelIDs: []string{NewId()}, OpenTeamPublicChannels: true, PublicChannelTeamIDs: []string{NewId()}}, OnBehalfOfUserID: NewId(), CreatedByPluginID: "plugin", } @@ -113,7 +134,7 @@ func TestSessionScopeParsing(t *testing.T) { require.NotNil(t, scope) assert.Empty(t, scope.Permissions) assert.False(t, scope.Resources.OpenTeamPublicChannels) - assert.False(t, SessionScopeAllowsChannel(scope, &Channel{Id: NewId(), Type: ChannelTypeOpen}, nil, false)) + assert.False(t, SessionScopeAllowsChannel(scope, &Channel{Id: NewId(), Type: ChannelTypeOpen}, nil, SessionScopeLimits{})) }) t.Run("empty scope prop is still scoped", func(t *testing.T) { @@ -168,6 +189,10 @@ func TestSessionScopeAllowsChannel(t *testing.T) { dm := &Channel{Id: NewId(), Type: ChannelTypeDirect} gm := &Channel{Id: NewId(), Type: ChannelTypeGroup} + none := SessionScopeLimits{} + compliance := SessionScopeLimits{ComplianceMode: true} + noJoin := SessionScopeLimits{UsersCannotJoinOpenTeams: true} + scope := &SessionScope{ Permissions: ScopeReadPermissions, Resources: SessionScopeResources{ @@ -176,30 +201,87 @@ func TestSessionScopeAllowsChannel(t *testing.T) { }, } - assert.True(t, SessionScopeAllowsChannel(scope, listedPrivate, nil, false), "listed channel") - assert.True(t, SessionScopeAllowsChannel(scope, listedPrivate, nil, true), "listed channel with compliance") - assert.True(t, SessionScopeAllowsChannel(scope, publicOnOpen, openTeam, false), "public channel on open team") - assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, openTeam, true), "compliance disables open-team grant") - assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, nil, false), "unknown team") - assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, closedTeam, false), "mismatched team") - assert.False(t, SessionScopeAllowsChannel(scope, publicOnClosed, closedTeam, false), "public channel on closed team") - assert.False(t, SessionScopeAllowsChannel(scope, privateOnOpen, openTeam, false), "unlisted private channel") - assert.False(t, SessionScopeAllowsChannel(scope, dm, nil, false), "DM is never in scope, even if listed") - assert.False(t, SessionScopeAllowsChannel(scope, gm, nil, false), "GM is never in scope, even if listed") + assert.True(t, SessionScopeAllowsChannel(scope, listedPrivate, nil, none), "listed channel") + assert.True(t, SessionScopeAllowsChannel(scope, listedPrivate, nil, compliance), "listed channel with compliance") + assert.True(t, SessionScopeAllowsChannel(scope, publicOnOpen, openTeam, none), "public channel on open team") + assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, openTeam, compliance), "compliance disables open-team grant") + assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, nil, none), "unknown team") + assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, closedTeam, none), "mismatched team") + assert.False(t, SessionScopeAllowsChannel(scope, publicOnClosed, closedTeam, none), "public channel on closed team") + assert.False(t, SessionScopeAllowsChannel(scope, privateOnOpen, openTeam, none), "unlisted private channel") + assert.False(t, SessionScopeAllowsChannel(scope, dm, nil, none), "DM is never in scope, even if listed") + assert.False(t, SessionScopeAllowsChannel(scope, gm, nil, none), "GM is never in scope, even if listed") deleted := *listedPrivate deleted.DeleteAt = 1 - assert.False(t, SessionScopeAllowsChannel(scope, &deleted, nil, false), "archived channel") + assert.False(t, SessionScopeAllowsChannel(scope, &deleted, nil, none), "archived channel") policy := *listedPrivate policy.PolicyEnforced = true - assert.False(t, SessionScopeAllowsChannel(scope, &policy, nil, false), "policy-enforced channel") + assert.False(t, SessionScopeAllowsChannel(scope, &policy, nil, none), "policy-enforced channel") policyPublic := *publicOnOpen policyPublic.PolicyEnforced = true - assert.False(t, SessionScopeAllowsChannel(scope, &policyPublic, openTeam, false), "policy-enforced public channel") + assert.False(t, SessionScopeAllowsChannel(scope, &policyPublic, openTeam, none), "policy-enforced public channel") noOpenTeams := &SessionScope{Permissions: ScopeReadPermissions, Resources: SessionScopeResources{ChannelIDs: []string{listedPrivate.Id}}} - assert.False(t, SessionScopeAllowsChannel(noOpenTeams, publicOnOpen, openTeam, false), "open-team grant off") - assert.False(t, SessionScopeAllowsChannel(nil, listedPrivate, nil, false), "nil scope") + assert.False(t, SessionScopeAllowsChannel(noOpenTeams, publicOnOpen, openTeam, none), "open-team grant off") + assert.False(t, SessionScopeAllowsChannel(nil, listedPrivate, nil, none), "nil scope") + assert.False(t, SessionScopeAllowsChannel(scope, publicOnOpen, openTeam, noJoin), "open-team grant off without join_public_teams") + + t.Run("public channels on listed teams", func(t *testing.T) { + teamScope := &SessionScope{ + Permissions: ScopeReadPermissions, + Resources: SessionScopeResources{PublicChannelTeamIDs: []string{closedTeam.Id}}, + } + assert.True(t, SessionScopeAllowsChannel(teamScope, publicOnClosed, closedTeam, none), "public channel on a listed closed team") + assert.True(t, SessionScopeAllowsChannel(teamScope, publicOnClosed, closedTeam, noJoin), "not tied to join_public_teams") + assert.False(t, SessionScopeAllowsChannel(teamScope, publicOnClosed, closedTeam, compliance), "compliance disables the team grant") + assert.False(t, SessionScopeAllowsChannel(teamScope, listedPrivate, closedTeam, none), "private channel on a listed team") + assert.False(t, SessionScopeAllowsChannel(teamScope, publicOnOpen, openTeam, none), "public channel on another team") + assert.False(t, SessionScopeAllowsChannel(teamScope, publicOnClosed, nil, none), "unknown team") + + deletedTeam := *closedTeam + deletedTeam.DeleteAt = 1 + assert.False(t, SessionScopeAllowsChannel(teamScope, publicOnClosed, &deletedTeam, none), "deleted team") + policyTeam := *closedTeam + policyTeam.PolicyEnforced = true + assert.False(t, SessionScopeAllowsChannel(teamScope, publicOnClosed, &policyTeam, none), "policy-enforced team") + policyChannel := *publicOnClosed + policyChannel.PolicyEnforced = true + assert.False(t, SessionScopeAllowsChannel(teamScope, &policyChannel, closedTeam, none), "policy-enforced channel") + archived := *publicOnClosed + archived.DeleteAt = 1 + assert.False(t, SessionScopeAllowsChannel(teamScope, &archived, closedTeam, none), "archived channel") + }) +} + +func TestSessionScopeGrantsTeamPublicChannels(t *testing.T) { + openTeam := &Team{Id: NewId(), Type: TeamOpen, AllowOpenInvite: true} + closedTeam := &Team{Id: NewId(), Type: TeamInvite} + scope := &SessionScope{Resources: SessionScopeResources{OpenTeamPublicChannels: true, PublicChannelTeamIDs: []string{closedTeam.Id}}} + + assert.True(t, SessionScopeGrantsTeamPublicChannels(scope, openTeam, SessionScopeLimits{})) + assert.True(t, SessionScopeGrantsTeamPublicChannels(scope, closedTeam, SessionScopeLimits{})) + assert.False(t, SessionScopeGrantsTeamPublicChannels(scope, openTeam, SessionScopeLimits{UsersCannotJoinOpenTeams: true})) + assert.True(t, SessionScopeGrantsTeamPublicChannels(scope, closedTeam, SessionScopeLimits{UsersCannotJoinOpenTeams: true})) + assert.False(t, SessionScopeGrantsTeamPublicChannels(scope, openTeam, SessionScopeLimits{ComplianceMode: true})) + assert.False(t, SessionScopeGrantsTeamPublicChannels(scope, closedTeam, SessionScopeLimits{ComplianceMode: true})) + assert.False(t, SessionScopeGrantsTeamPublicChannels(scope, &Team{Id: NewId(), Type: TeamInvite}, SessionScopeLimits{}), "unlisted closed team") + assert.False(t, SessionScopeGrantsTeamPublicChannels(nil, openTeam, SessionScopeLimits{})) + assert.False(t, SessionScopeGrantsTeamPublicChannels(scope, nil, SessionScopeLimits{})) +} + +func TestSessionScopeResourcesWithLimits(t *testing.T) { + resources := SessionScopeResources{ChannelIDs: []string{NewId()}, OpenTeamPublicChannels: true, PublicChannelTeamIDs: []string{NewId()}} + + assert.Equal(t, resources, resources.WithLimits(SessionScopeLimits{})) + assert.Equal(t, SessionScopeResources{ChannelIDs: resources.ChannelIDs, PublicChannelTeamIDs: resources.PublicChannelTeamIDs}, resources.WithLimits(SessionScopeLimits{UsersCannotJoinOpenTeams: true})) + assert.Equal(t, SessionScopeResources{ChannelIDs: resources.ChannelIDs}, resources.WithLimits(SessionScopeLimits{ComplianceMode: true})) + + effective := resources.WithLimits(SessionScopeLimits{}) + effective.ChannelIDs[0] = "changed" + effective.PublicChannelTeamIDs[0] = "changed" + assert.NotEqual(t, "changed", resources.ChannelIDs[0], "returns copies") + assert.NotEqual(t, "changed", resources.PublicChannelTeamIDs[0], "returns copies") }