Grant scoped sessions the public channels of listed teams and open name lookups

Adds SessionScopeResources.PublicChannelTeamIDs: every public channel on
the listed teams, whether or not the team is open, so the plugin can
always include the triggering channel's team. Compliance mode turns it off;
deleted and policy-enforced teams and channels stay excluded.
SessionScopeAllowsChannel now takes SessionScopeLimits so compliance mode
and join_public_teams can limit the two public-channel grants separately.

Flags channel/team lookups by name, channel stats, pinned posts, post
reactions and user search for scoped sessions, each gated by the scope.

Co-authored-by: Christopher Speller <crspeller@users.noreply.github.com>
This commit is contained in:
Cursor AgentandChristopher Speller committed 2026-11-02 07:51:38 +00:00
1 parent e639400063
commit fc59cb3cd5
10 files changed
+512 -63

No files matched your search

+4 -4
View File
@@ -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)
+1 -1
View File
@@ -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)
}
+188
View File
@@ -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)
+3 -2
View File
@@ -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
}
+6 -1
View File
@@ -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"
+40 -26
View File
@@ -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)
+98 -4
View File
@@ -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) {
+10
View File
@@ -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.",
+63 -8
View File
@@ -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)
}
+99 -17
View File
@@ -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")
}