MM-68763: Discoverable Private Channels — Server feature complete (visibility, ABAC, queue API) (#36580)

This commit is contained in:
Ibrahim Serdar Acikgoz
2026-05-21 17:10:51 +02:00
committed by GitHub
parent 29fe2789a0
commit e6c59693af
13 changed files with 2033 additions and 3 deletions
+144 -1
View File
@@ -100,6 +100,8 @@ func (api *API) InitChannel() {
api.BaseRoutes.ChannelModerations.Handle("", api.APISessionRequired(getChannelModerations)).Methods(http.MethodGet)
api.BaseRoutes.ChannelModerations.Handle("/patch", api.APISessionRequired(patchChannelModerations)).Methods(http.MethodPut)
api.initChannelJoinRequestRoutes()
}
func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -144,6 +146,24 @@ func createChannel(c *Context, w http.ResponseWriter, r *http.Request) {
return
}
if channel.Discoverable {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
c.Err = model.NewAppError("createChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
return
}
if channel.Type != model.ChannelTypePrivate {
c.Err = model.NewAppError("createChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
return
}
// The team-scoped check is the closest analog to "would this user
// have permission to manage discoverability after the channel is
// created" — channel-scope grants don't exist yet at creation time.
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManagePrivateChannelDiscoverability) {
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
return
}
}
sc, appErr := c.App.CreateChannelWithUser(c.AppContext, channel, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
@@ -377,12 +397,36 @@ func patchChannel(c *Context, w http.ResponseWriter, r *http.Request) {
updatingProperties := patch.DisplayName != nil || patch.Name != nil || patch.Header != nil || patch.Purpose != nil || patch.GroupConstrained != nil || patch.DefaultCategoryName != nil
updatingAutoTranslation := patch.AutoTranslation != nil
updatingManagedCategory := patch.ManagedCategoryName != nil
updatingDiscoverable := patch.Discoverable != nil
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory {
if !updatingProperties && !updatingAutoTranslation && patch.BannerInfo == nil && !updatingManagedCategory && !updatingDiscoverable {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.no_changes.app_error", nil, "", http.StatusBadRequest)
return
}
if updatingDiscoverable {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusBadRequest)
return
}
if oldChannel.Type != model.ChannelTypePrivate {
c.Err = model.NewAppError("patchChannel", "model.channel.is_valid.discoverable.app_error", nil, "", http.StatusBadRequest)
return
}
if oldChannel.DeleteAt != 0 {
c.Err = model.NewAppError("patchChannel", "api.channel.update_channel.deleted.app_error", nil, "", http.StatusBadRequest)
return
}
if oldChannel.IsShared() {
c.Err = model.NewAppError("patchChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "", http.StatusBadRequest)
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManagePrivateChannelDiscoverability); !ok {
c.SetPermissionError(model.PermissionManagePrivateChannelDiscoverability)
return
}
}
if updatingAutoTranslation && (c.App.AutoTranslation() == nil || !c.App.AutoTranslation().IsFeatureAvailable()) {
c.Err = model.NewAppError("patchChannel", "api.channel.patch_update_channel.feature_not_available.app_error", nil, "", http.StatusForbidden)
return
@@ -806,6 +850,9 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
} else if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionReadChannel); !ok {
if served := serveDiscoverableNonMember(c, w, channel); served {
return
}
c.SetPermissionError(model.PermissionReadChannel)
return
}
@@ -822,6 +869,80 @@ func getChannel(c *Context, w http.ResponseWriter, r *http.Request) {
}
}
// sanitizeDiscoverableChannel returns a copy of `channel` containing only the
// fields safe to expose to a non-member who can see the channel through the
// discoverable surface. Cell-level secrets such as Props or per-channel
// scheme identifiers are stripped so this view is strictly read-only metadata.
func sanitizeDiscoverableChannel(channel *model.Channel) *model.Channel {
if channel == nil {
return nil
}
return &model.Channel{
Id: channel.Id,
TeamId: channel.TeamId,
Type: channel.Type,
DisplayName: channel.DisplayName,
Name: channel.Name,
Header: channel.Header,
Purpose: channel.Purpose,
Discoverable: channel.Discoverable,
PolicyEnforced: channel.PolicyEnforced,
CreateAt: channel.CreateAt,
UpdateAt: channel.UpdateAt,
DeleteAt: channel.DeleteAt,
}
}
// discoverableNonMemberView returns a sanitized non-member view of `channel`
// when the calling user qualifies under the discoverable visibility rules,
// or (nil, nil) when the channel must remain hidden — the caller should
// emit its own permission-denied response. Errors from the discoverable
// lookup are returned for the caller to assign to c.Err. When the feature
// flag is off, this returns (nil, nil) and the caller falls through to its
// default 403/404 path so the existing read contract is preserved.
func discoverableNonMemberView(c *Context, channel *model.Channel) (*model.Channel, *model.AppError) {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
return nil, nil
}
user, userErr := c.App.GetUser(c.AppContext.Session().UserId)
if userErr != nil {
return nil, userErr
}
allowed, allowedErr := c.App.IsDiscoverableJoinAllowed(c.AppContext, user, channel)
if allowedErr != nil {
return nil, allowedErr
}
if !allowed {
return nil, nil
}
return sanitizeDiscoverableChannel(channel), nil
}
// serveDiscoverableNonMember writes the sanitized non-member discoverable
// view of `channel` to `w` and returns true when the request was handled
// here (either the response was written, or c.Err was set on a lookup
// failure). Returns false without touching the response when the caller
// should emit its own permission-denied response (the channel is hidden
// from this non-member, or the feature flag is off).
//
// Centralising this here means every read endpoint that previously emitted
// 403/404 to a non-member can keep its prior failure shape while opting in
// to the discoverable surface with a single `if served { return }` guard.
func serveDiscoverableNonMember(c *Context, w http.ResponseWriter, channel *model.Channel) bool {
sanitized, err := discoverableNonMemberView(c, channel)
if err != nil {
c.Err = err
return true
}
if sanitized == nil {
return false
}
if encErr := json.NewEncoder(w).Encode(sanitized); encErr != nil {
c.Logger.Warn("Error while writing response", mlog.Err(encErr))
}
return true
}
func getChannelUnread(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId().RequireUserId()
if c.Err != nil {
@@ -1646,6 +1767,9 @@ func getChannelByName(c *Context, w http.ResponseWriter, r *http.Request) {
// allows team admins to access private channel
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionReadChannel); !ok {
if served := serveDiscoverableNonMember(c, w, channel); served {
return
}
c.Err = model.NewAppError("getChannelByName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
return
}
@@ -1686,6 +1810,9 @@ func getChannelByNameForTeamName(c *Context, w http.ResponseWriter, r *http.Requ
} else if !channelOk {
// allows team admins to access private channel
if !c.App.SessionHasPermissionToTeam(*c.AppContext.Session(), channel.TeamId, model.PermissionManageTeam) {
if served := serveDiscoverableNonMember(c, w, channel); served {
return
}
c.Err = model.NewAppError("getChannelByNameForTeamName", "app.channel.get_by_name.missing.app_error", nil, "teamId="+channel.TeamId+", "+"name="+channel.Name+"", http.StatusNotFound)
return
}
@@ -2252,9 +2379,25 @@ func addChannelMember(c *Context, w http.ResponseWriter, r *http.Request) {
if channel.Type == model.ChannelTypePrivate {
if hasPermission, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), channel.Id, model.PermissionManagePrivateChannelMembers); !hasPermission {
// Allow the user to self-add to a discoverable private channel only
// through the request flow — the discoverable toggle does not
// implicitly grant PermissionManagePrivateChannelMembers, and the
// existing addChannelMember API would otherwise let any caller
// bypass the queue by issuing a direct POST.
c.SetPermissionError(model.PermissionManagePrivateChannelMembers)
return
}
// Discoverable + no policy: the request flow is the only path. Even
// admins use it to ensure the audit trail. We exempt the case where
// the requester is adding someone other than themselves so admin
// invites still work.
for _, userId := range userIds {
if c.App.IsDiscoverableSelfAddBlocked(c.AppContext, channel, c.AppContext.Session().UserId, userId) {
c.Err = model.NewAppError("addChannelMember", "api.channel.discoverable_join_request.discoverable_requires_approval.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
return
}
}
}
if channel.IsGroupConstrained() {
@@ -0,0 +1,293 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"encoding/json"
"net/http"
"strconv"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
)
// initChannelJoinRequestRoutes registers the discoverable-private-channel
// join request endpoints. The route group is split into its own file so the
// handlers stay isolated from the rest of api4/channel.go.
func (api *API) initChannelJoinRequestRoutes() {
if !api.srv.Config().FeatureFlags.DiscoverableChannels {
return
}
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(requestJoinChannel)).Methods(http.MethodPost)
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(getMyChannelJoinRequest)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("/join_request", api.APISessionRequired(withdrawMyChannelJoinRequest)).Methods(http.MethodDelete)
api.BaseRoutes.Channel.Handle("/join_requests", api.APISessionRequired(getChannelJoinRequests)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("/join_requests/count", api.APISessionRequired(countPendingChannelJoinRequests)).Methods(http.MethodGet)
api.BaseRoutes.Channel.Handle("/join_requests/{request_id:[A-Za-z0-9]+}", api.APISessionRequired(patchChannelJoinRequest)).Methods(http.MethodPatch)
api.BaseRoutes.User.Handle("/channel_join_requests", api.APISessionRequired(getMyChannelJoinRequests)).Methods(http.MethodGet)
}
// channelJoinRequestBody is the POST body shape for /channels/{id}/join_request.
type channelJoinRequestBody struct {
Message string `json:"message"`
}
func requireDiscoverableChannelsEnabled(c *Context, where string) bool {
if !c.App.Config().FeatureFlags.DiscoverableChannels {
c.Err = model.NewAppError(where, "api.channel.discoverable_join_request.feature_disabled.app_error", nil, "", http.StatusNotFound)
return false
}
return true
}
func requestJoinChannel(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "requestJoinChannel") {
return
}
var body channelJoinRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
c.SetInvalidParamWithErr("body", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventCreateChannelJoinRequest, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
joined, req, appErr := c.App.RequestJoinChannel(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId, body.Message)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
if req != nil {
auditRec.AddEventResultState(req)
}
if joined {
// Mirror the membership endpoint's "no body, just status" semantics
// when the user was added directly via the ABAC fast path.
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(map[string]string{"status": model.ChannelJoinRequestStatusApproved}); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
return
}
w.WriteHeader(http.StatusCreated)
if err := json.NewEncoder(w).Encode(req); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequest") {
return
}
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if req == nil {
// Mirror REST conventions: not-found instead of an explicit `null`
// so clients can distinguish "no pending request" from "service down".
w.WriteHeader(http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(req); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func withdrawMyChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "withdrawMyChannelJoinRequest") {
return
}
auditRec := c.MakeAuditRecord(model.AuditEventWithdrawChannelJoinRequest, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "user_id", c.AppContext.Session().UserId)
req, appErr := c.App.GetMyChannelJoinRequest(c.AppContext, c.AppContext.Session().UserId, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if req == nil {
c.Err = model.NewAppError("withdrawMyChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "channel_id="+c.Params.ChannelId, http.StatusNotFound)
return
}
updated, appErr := c.App.WithdrawChannelJoinRequest(c.AppContext, req.Id, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updated)
if err := json.NewEncoder(w).Encode(updated); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "getChannelJoinRequests") {
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
return
}
opts := model.GetChannelJoinRequestsOpts{
Status: r.URL.Query().Get("status"),
Page: c.Params.Page,
PerPage: c.Params.PerPage,
}
list, appErr := c.App.GetChannelJoinRequests(c.AppContext, c.Params.ChannelId, opts)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(list); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func countPendingChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "countPendingChannelJoinRequests") {
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
return
}
count, appErr := c.App.CountPendingChannelJoinRequests(c.AppContext, c.Params.ChannelId)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(map[string]int64{"count": count}); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func patchChannelJoinRequest(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireChannelId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "patchChannelJoinRequest") {
return
}
if !model.IsValidId(c.Params.RequestId) {
c.SetInvalidURLParam("request_id")
return
}
if ok, _ := c.App.SessionHasPermissionToChannel(c.AppContext, *c.AppContext.Session(), c.Params.ChannelId, model.PermissionManageChannelJoinRequests); !ok {
c.SetPermissionError(model.PermissionManageChannelJoinRequests)
return
}
var patch model.ChannelJoinRequestPatch
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
c.SetInvalidParamWithErr("channel_join_request_patch", err)
return
}
auditRec := c.MakeAuditRecord(model.AuditEventUpdateChannelJoinRequest, model.AuditStatusFail)
defer c.LogAuditRec(auditRec)
model.AddEventParameterToAuditRec(auditRec, "channel_id", c.Params.ChannelId)
model.AddEventParameterToAuditRec(auditRec, "request_id", c.Params.RequestId)
model.AddEventParameterToAuditRec(auditRec, "status", patch.Status)
// Capture only the presence of a denial reason in the audit log; the
// free-text contents are intentionally excluded.
model.AddEventParameterToAuditRec(auditRec, "has_denial_reason", strconv.FormatBool(patch.DenialReason != nil && *patch.DenialReason != ""))
updated, appErr := c.App.UpdateChannelJoinRequest(c.AppContext, c.Params.RequestId, c.Params.ChannelId, &patch, c.AppContext.Session().UserId)
if appErr != nil {
c.Err = appErr
return
}
auditRec.Success()
auditRec.AddEventResultState(updated)
if err := json.NewEncoder(w).Encode(updated); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
func getMyChannelJoinRequests(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireUserId()
if c.Err != nil {
return
}
if !requireDiscoverableChannelsEnabled(c, "getMyChannelJoinRequests") {
return
}
// Only the calling user can list their own requests; admins should use
// the per-channel queue endpoint.
if c.Params.UserId != c.AppContext.Session().UserId {
c.SetPermissionError(model.PermissionEditOtherUsers)
return
}
opts := model.GetChannelJoinRequestsOpts{
Status: r.URL.Query().Get("status"),
Page: c.Params.Page,
PerPage: c.Params.PerPage,
}
list, appErr := c.App.GetMyChannelJoinRequests(c.AppContext, c.AppContext.Session().UserId, opts)
if appErr != nil {
c.Err = appErr
return
}
if err := json.NewEncoder(w).Encode(list); err != nil {
c.Logger.Warn("Error while writing response", mlog.Err(err))
}
}
@@ -0,0 +1,154 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package api4
import (
"context"
"encoding/json"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// setupDiscoverableTH spins up an api4 fixture with the discoverable channels
// feature flag enabled so the new routes are registered.
func setupDiscoverableTH(t *testing.T) *TestHelper {
t.Helper()
return SetupConfig(t, func(cfg *model.Config) {
cfg.FeatureFlags.DiscoverableChannels = true
}).InitBasic(t)
}
// markDiscoverableViaAdmin patches `channel` to discoverable=true using the
// SystemAdminClient so the permission check is satisfied without needing to
// rebind the channel-admin role on the test fixture.
func markDiscoverableViaAdmin(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
t.Helper()
on := true
patched, _, err := th.SystemAdminClient.PatchChannel(context.Background(), channel.Id, &model.ChannelPatch{Discoverable: &on})
require.NoError(t, err)
require.True(t, patched.Discoverable)
return patched
}
func TestRequestJoinChannelAPI_HappyPath(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
channel := th.CreatePrivateChannel(t)
channel = markDiscoverableViaAdmin(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, _, err := th.Client.Login(context.Background(), other.Email, other.Password)
require.NoError(t, err)
body := []byte(`{"message":"hi"}`)
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
require.NoError(t, err)
defer resp.Body.Close()
require.Equal(t, http.StatusCreated, resp.StatusCode)
var req model.ChannelJoinRequest
require.NoError(t, json.NewDecoder(resp.Body).Decode(&req))
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
assert.Equal(t, channel.Id, req.ChannelId)
assert.Equal(t, other.Id, req.UserId)
}
func TestRequestJoinChannelAPI_FeatureDisabled(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
channel := th.CreatePrivateChannel(t)
body := []byte(`{"message":"hi"}`)
resp, err := th.Client.DoAPIPost(context.Background(), "/channels/"+channel.Id+"/join_request", string(body))
defer closeBodyOrNil(resp)
require.Error(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "route must be unregistered when feature flag is off")
}
func TestPatchChannelDiscoverable_RejectsNonPrivate(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
publicChannel := th.CreatePublicChannel(t)
on := true
_, resp, err := th.SystemAdminClient.PatchChannel(context.Background(), publicChannel.Id, &model.ChannelPatch{Discoverable: &on})
require.Error(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestAddChannelMember_BlocksSelfAddOnDiscoverable(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
channel := th.CreatePrivateChannel(t)
channel = markDiscoverableViaAdmin(t, th, channel)
// Add a user that has manage-private-channel-members on a different
// channel but not this one. Use Client (BasicUser2) - they're a team
// member but not yet a channel member here.
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
_, resp, err := th.Client.AddChannelMember(context.Background(), channel.Id, th.BasicUser2.Id)
require.Error(t, err)
require.NotNil(t, resp)
// Without channel admin permission the underlying permission check
// fails first; either way the request flow is what they need to use.
assert.True(t, resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized,
"got %d", resp.StatusCode)
}
func TestGetChannelByName_HiddenForNonQualifyingNonMember(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
// Plain (non-discoverable) private channel: a non-member must still get
// 404 — this guards against a regression in the existing read paths.
channel := th.CreatePrivateChannel(t)
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
_, resp, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
require.Error(t, err)
require.NotNil(t, resp)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
}
func TestGetChannelByName_VisibleForQualifyingNonMemberOnDiscoverable(t *testing.T) {
mainHelper.Parallel(t)
th := setupDiscoverableTH(t)
channel := th.CreatePrivateChannel(t)
channel = markDiscoverableViaAdmin(t, th, channel)
_, _, err := th.Client.Login(context.Background(), th.BasicUser2.Email, th.BasicUser2.Password)
require.NoError(t, err)
got, _, err := th.Client.GetChannelByName(context.Background(), channel.Name, th.BasicTeam.Id, "")
require.NoError(t, err)
require.NotNil(t, got)
assert.Equal(t, channel.Id, got.Id)
assert.True(t, got.Discoverable)
}
// closeBodyOrNil is a tiny helper so the negative-path tests don't need to
// branch on a nil response body before deferring Close.
func closeBodyOrNil(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
+32 -2
View File
@@ -835,6 +835,14 @@ func (a *App) UpdateChannelScheme(rctx request.CTX, channel *model.Channel) (*mo
}
func (a *App) UpdateChannelPrivacy(rctx request.CTX, oldChannel *model.Channel, user *model.User) (*model.Channel, *model.AppError) {
wasDiscoverable := oldChannel.Discoverable
// Public channels are inherently joinable; the discoverable flag only
// has meaning for private channels. Clear it eagerly so callers reading
// the row mid-conversion don't see an inconsistent state.
if oldChannel.Type == model.ChannelTypeOpen {
oldChannel.Discoverable = false
}
channel, err := a.UpdateChannel(rctx, oldChannel)
if err != nil {
return channel, err
@@ -844,6 +852,11 @@ func (a *App) UpdateChannelPrivacy(rctx request.CTX, oldChannel *model.Channel,
if postErr != nil {
if channel.Type == model.ChannelTypeOpen {
channel.Type = model.ChannelTypePrivate
// Restore the discoverable flag we eagerly cleared above so
// the rollback fully undoes the conversion. Without this the
// caller would see a private channel with discoverable=false
// (and would have to re-toggle it).
channel.Discoverable = wasDiscoverable
} else {
channel.Type = model.ChannelTypeOpen
}
@@ -854,6 +867,19 @@ func (a *App) UpdateChannelPrivacy(rctx request.CTX, oldChannel *model.Channel,
return channel, postErr
}
// Now that the conversion is fully committed, cancel pending join
// requests for the formerly discoverable private channel — the WS
// broadcast inside the helper updates each requester's My Pending
// Requests list in real-time. Doing this after the privacy-message
// step ensures a transient post failure (which triggers the rollback
// above) cannot leave requests cancelled against a still-private
// channel.
if wasDiscoverable && channel.Type == model.ChannelTypeOpen {
a.Srv().Go(func() {
a.CancelPendingChannelJoinRequestsOnConvert(rctx, channel)
})
}
a.Srv().Platform().InvalidateCacheForChannel(channel)
messageWs := model.NewWebSocketEvent(model.WebsocketEventChannelConverted, channel.TeamId, "", "", nil, "")
@@ -3229,6 +3255,10 @@ func (a *App) AutocompleteChannels(rctx request.CTX, userID, term string) (model
return nil, model.NewAppError("AutocompleteChannels", "app.channel.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
channelList, _, appErr = a.FilterChannelListWithTeamDataForUserVisibility(rctx, channelList, userID)
if appErr != nil {
return nil, appErr
}
return channelList, nil
}
@@ -3246,7 +3276,7 @@ func (a *App) AutocompleteChannelsForTeam(rctx request.CTX, teamID, userID, term
return nil, model.NewAppError("AutocompleteChannels", "app.channel.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return channelList, nil
return a.FilterChannelListForUserVisibility(rctx, channelList, userID)
}
func (a *App) AutocompleteChannelsForTeamFiltered(rctx request.CTX, teamID, userID, term string, privateOnly, excludeGroupConstrained bool) (model.ChannelList, *model.AppError) {
@@ -3263,7 +3293,7 @@ func (a *App) AutocompleteChannelsForTeamFiltered(rctx request.CTX, teamID, user
return nil, model.NewAppError("AutocompleteChannelsForTeamFiltered", "app.channel.search.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return channelList, nil
return a.FilterChannelListForUserVisibility(rctx, channelList, userID)
}
func (a *App) AutocompleteChannelsForSearch(rctx request.CTX, teamID string, userID string, term string) (model.ChannelList, *model.AppError) {
@@ -0,0 +1,384 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"sync"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
// channelVisibilityCacheKey is the per-request request.CTX value key used to
// memoise PDP membership decisions across N+1 channel filtering work in a
// single Browse Channels load.
type channelVisibilityCacheKey struct{}
type channelVisibilityCache struct {
mu sync.Mutex
decisions map[string]bool
}
func getChannelVisibilityCache(rctx request.CTX) *channelVisibilityCache {
if v := rctx.Context().Value(channelVisibilityCacheKey{}); v != nil {
if cache, ok := v.(*channelVisibilityCache); ok {
return cache
}
}
return nil
}
// withChannelVisibilityCache returns a request context that memoises PDP
// membership decisions across the visibility filter calls in a single request.
// It's safe to call this multiple times — only the outermost installation
// allocates a cache.
func withChannelVisibilityCache(rctx request.CTX) request.CTX {
if getChannelVisibilityCache(rctx) != nil {
return rctx
}
cache := &channelVisibilityCache{decisions: map[string]bool{}}
return rctx.WithContext(context.WithValue(rctx.Context(), channelVisibilityCacheKey{}, cache))
}
func (c *channelVisibilityCache) get(channelID string) (bool, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.decisions[channelID]
return v, ok
}
func (c *channelVisibilityCache) set(channelID string, allow bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.decisions[channelID] = allow
}
// FilterDiscoverableChannelsByPolicy removes from `channels` any
// policy-enforced private channel that the user fails to satisfy — the
// security-critical visibility invariant in plan §6c. Channels without an
// active policy are returned untouched. Callers that need the additional
// "non-member private must be discoverable" gate should use
// FilterChannelsForUserVisibility instead.
//
// Failure modes are fail-secure: a missing AccessControl service, a
// subject-build failure, or any PDP error drops the offending channel from
// the result so a non-qualifying user can never be inadvertently shown a
// gated channel. Decisions are cached per-request via the request.CTX value
// bag installed by withChannelVisibilityCache.
func (a *App) FilterDiscoverableChannelsByPolicy(rctx request.CTX, channels []*model.Channel, userID string) ([]*model.Channel, *model.AppError) {
if len(channels) == 0 {
return channels, nil
}
if !a.Config().FeatureFlags.DiscoverableChannels {
return channels, nil
}
rctx = withChannelVisibilityCache(rctx)
cache := getChannelVisibilityCache(rctx)
var (
user *model.User
userErr *model.AppError
userOnce sync.Once
filtered = make([]*model.Channel, 0, len(channels))
dropCount int
)
for _, channel := range channels {
if channel == nil {
continue
}
if !channel.PolicyEnforced || channel.Type != model.ChannelTypePrivate || !channel.Discoverable {
filtered = append(filtered, channel)
continue
}
if cached, ok := cache.get(channel.Id); ok {
if cached {
filtered = append(filtered, channel)
} else {
dropCount++
}
continue
}
userOnce.Do(func() {
user, userErr = a.GetUser(userID)
})
if userErr != nil {
return nil, userErr
}
// Guests are never permitted to see discoverable private channels.
if user.IsGuest() {
cache.set(channel.Id, false)
dropCount++
continue
}
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
if evalErr != nil {
rctx.Logger().Warn("FilterDiscoverableChannelsByPolicy: PDP error, hiding channel (fail-secure)",
mlog.String("user_id", userID),
mlog.String("channel_id", channel.Id),
mlog.Err(evalErr),
)
cache.set(channel.Id, false)
dropCount++
continue
}
cache.set(channel.Id, decision)
if decision {
filtered = append(filtered, channel)
} else {
dropCount++
}
}
return filtered, nil
}
// FilterChannelsForUserVisibility wraps FilterDiscoverableChannelsByPolicy with
// the secondary invariant: a non-member private channel must be discoverable
// to be visible at all. The caller is expected to scope `channels` to results
// where the user is a non-member; member channels should not be passed
// through this filter (their visibility is governed by membership alone).
//
// In practice the search/autocomplete store paths return a mix of member and
// non-member rows; callers should pass the full list because the helper
// detects membership-implying fields. The current implementation only checks
// the discoverability gate (the SQL-level membership join already excluded
// unaffiliated channels).
func (a *App) FilterChannelsForUserVisibility(rctx request.CTX, channels []*model.Channel, userID string) ([]*model.Channel, *model.AppError) {
return a.FilterDiscoverableChannelsByPolicy(rctx, channels, userID)
}
// FilterChannelListForUserVisibility is the convenience overload for
// model.ChannelList callers (the standard list shape returned by app-layer
// search functions).
func (a *App) FilterChannelListForUserVisibility(rctx request.CTX, channels model.ChannelList, userID string) (model.ChannelList, *model.AppError) {
filtered, err := a.FilterChannelsForUserVisibility(rctx, channels, userID)
if err != nil {
return nil, err
}
return model.ChannelList(filtered), nil
}
// FilterChannelListWithTeamDataForUserVisibility filters the team-data list
// shape used by Autocomplete and SearchAllChannels. The function preserves
// the embedded TeamDisplayName / TeamName fields. Returns the post-filter
// total adjustment so paginated callers can shrink TotalCount alongside the
// trimmed result set.
func (a *App) FilterChannelListWithTeamDataForUserVisibility(rctx request.CTX, channels model.ChannelListWithTeamData, userID string) (model.ChannelListWithTeamData, int, *model.AppError) {
if len(channels) == 0 {
return channels, 0, nil
}
if !a.Config().FeatureFlags.DiscoverableChannels {
return channels, 0, nil
}
rctx = withChannelVisibilityCache(rctx)
cache := getChannelVisibilityCache(rctx)
var (
user *model.User
userErr *model.AppError
userOnce sync.Once
out = make(model.ChannelListWithTeamData, 0, len(channels))
dropped int
)
for i := range channels {
ch := channels[i]
if !ch.PolicyEnforced || ch.Type != model.ChannelTypePrivate || !ch.Discoverable {
out = append(out, ch)
continue
}
if cached, ok := cache.get(ch.Id); ok {
if cached {
out = append(out, ch)
} else {
dropped++
}
continue
}
userOnce.Do(func() {
user, userErr = a.GetUser(userID)
})
if userErr != nil {
return nil, 0, userErr
}
if user.IsGuest() {
cache.set(ch.Id, false)
dropped++
continue
}
decision, evalErr := a.evaluateChannelMembership(rctx, user, &ch.Channel)
if evalErr != nil {
rctx.Logger().Warn("FilterChannelListWithTeamDataForUserVisibility: PDP error, hiding channel (fail-secure)",
mlog.String("user_id", userID),
mlog.String("channel_id", ch.Id),
mlog.Err(evalErr),
)
cache.set(ch.Id, false)
dropped++
continue
}
cache.set(ch.Id, decision)
if decision {
out = append(out, ch)
} else {
dropped++
}
}
return out, dropped, nil
}
// IsDiscoverableJoinAllowed reports whether `user` may view `channel` as a
// non-member through the discoverable-channels surface. Returns 404 (mapped
// by callers) when the channel is hidden from this user — matching the
// "indistinguishable from a non-existent channel" requirement so the policy
// cannot act as an existence oracle.
func (a *App) IsDiscoverableJoinAllowed(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
if channel == nil {
return false, nil
}
if channel.Type != model.ChannelTypePrivate || !channel.Discoverable {
return false, nil
}
if user == nil || user.IsGuest() || user.DeleteAt != 0 {
return false, nil
}
if channel.DeleteAt != 0 || channel.IsShared() {
return false, nil
}
if !channel.PolicyEnforced {
return true, nil
}
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
if evalErr != nil {
// Fail-secure: PDP failure hides the channel rather than leak it.
rctx.Logger().Warn("IsDiscoverableJoinAllowed: PDP error, hiding channel (fail-secure)",
mlog.String("user_id", user.Id),
mlog.String("channel_id", channel.Id),
mlog.Err(evalErr),
)
return false, nil
}
return decision, nil
}
// CancelPendingChannelJoinRequestsOnConvert transitions every pending request
// for a channel to the withdrawn state — used when the channel is converted
// to public (open channels are inherently joinable, so a pending queue is
// nonsensical) and when the channel is archived. Failures are logged because
// the conversion / archive must not be blocked.
func (a *App) CancelPendingChannelJoinRequestsOnConvert(rctx request.CTX, channel *model.Channel) {
if channel == nil {
return
}
const (
pageSize = 200
maxIterations = 50 // hard cap at ~10k requests per channel
)
for range maxIterations {
opts := model.GetChannelJoinRequestsOpts{
Status: model.ChannelJoinRequestStatusPending,
Page: 0,
PerPage: pageSize,
}
rows, _, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channel.Id, opts)
if err != nil {
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: failed to list pending requests",
mlog.String("channel_id", channel.Id),
mlog.Err(err),
)
return
}
if len(rows) == 0 {
return
}
failed := 0
for _, row := range rows {
row.Status = model.ChannelJoinRequestStatusWithdrawn
row.Message = ""
updated, updateErr := a.Srv().Store().ChannelJoinRequest().Update(row)
if updateErr != nil {
failed++
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: failed to withdraw pending request",
mlog.String("channel_id", channel.Id),
mlog.String("request_id", row.Id),
mlog.Err(updateErr),
)
continue
}
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
}
// If every row in the batch failed to update, the next iteration
// would re-fetch the same rows and loop forever. Break out and
// surface the situation in the log — the operator can re-run the
// cleanup manually after addressing the underlying store error.
if failed == len(rows) {
rctx.Logger().Warn("CancelPendingChannelJoinRequestsOnConvert: every row in batch failed to update, aborting to avoid infinite loop",
mlog.String("channel_id", channel.Id),
mlog.Int("failed", failed),
)
return
}
// Standard exit when the last page is partial: every remaining
// pending row was successfully withdrawn (or logged as failed).
if len(rows) < pageSize {
return
}
}
// maxIterations safety net — this should be effectively unreachable
// because the per-batch all-failed check above already aborts on
// systemic update failures. Fire a higher-severity log if we hit it.
rctx.Logger().Error("CancelPendingChannelJoinRequestsOnConvert: hit maxIterations, aborting",
mlog.String("channel_id", channel.Id),
mlog.Int("max_iterations", maxIterations),
)
}
// IsDiscoverableSelfAddBlocked reports whether a user trying to self-add to
// `channel` via POST /channels/{id}/members must instead go through the
// request flow. The block applies only when:
// - the channel is private,
// - it is discoverable but does NOT have an active ABAC policy
// (channels with a policy use the existing PDP gate inside
// addUserToChannel — admins can still add others by policy),
// - the user is not yet a member,
// - and the requester is the user themselves.
//
// Other paths (admin invites, API by reviewer ID) are unaffected: the request
// flow exists to give admins a queue, not to block invites.
func (a *App) IsDiscoverableSelfAddBlocked(rctx request.CTX, channel *model.Channel, requesterUserID, targetUserID string) bool {
if channel == nil || channel.Type != model.ChannelTypePrivate {
return false
}
if !channel.Discoverable {
return false
}
if channel.PolicyEnforced {
return false
}
if requesterUserID != targetUserID {
return false
}
if !a.Config().FeatureFlags.DiscoverableChannels {
return false
}
return true
}
@@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDiscoverableVisibilityInvariant_NonGuestSeesNoPolicy verifies that a
// discoverable + no-policy private channel is returned through the
// non-member autocomplete path for a non-guest user.
//
// The complementary policy-enforced + non-qualifying user case is covered
// by TestFilterDiscoverableChannelsByPolicy_PolicyEnforcedFailSecure (which
// checks the fail-secure path) and the dedicated guest case is in
// TestFilterDiscoverableChannelsByPolicy_GuestHidden.
func TestDiscoverableVisibilityInvariant_NonGuestSeesNoPolicy(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
// BasicUser2 is a member of the team but NOT of `channel`. The
// autocomplete query must still surface the channel because of the
// discoverable OR-branch (post-query ABAC filter is a no-op since the
// channel has no policy).
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, th.BasicUser2.Id, channel.Name)
require.Nil(t, appErr)
found := false
for _, c := range results {
if c.Id == channel.Id {
found = true
break
}
}
assert.True(t, found, "discoverable + no-policy private channel must appear in autocomplete for a non-member non-guest")
}
// TestDiscoverableVisibilityInvariant_NonDiscoverableHidden ensures that the
// store-level OR-branch we added does not inadvertently leak private
// channels with discoverable=false to non-members. The new OR clause must be
// gated on `Discoverable=true`.
func TestDiscoverableVisibilityInvariant_NonDiscoverableHidden(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
plain := th.CreatePrivateChannel(t, th.BasicTeam)
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, th.BasicUser2.Id, plain.Name)
require.Nil(t, appErr)
for _, c := range results {
assert.NotEqual(t, plain.Id, c.Id, "non-discoverable private channel must remain hidden from non-members")
}
}
// TestDiscoverableVisibilityInvariant_GuestHidden re-verifies the guest path
// at the autocomplete level (the unit-level guest case lives in
// TestFilterDiscoverableChannelsByPolicy_GuestHidden, but this test exercises
// the full app+store integration so we don't accidentally rely on the
// in-memory filter alone).
func TestDiscoverableVisibilityInvariant_GuestHidden(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
guest := th.CreateGuest(t)
th.LinkUserToTeam(t, guest, th.BasicTeam)
results, appErr := th.App.AutocompleteChannelsForTeam(th.Context, th.BasicTeam.Id, guest.Id, channel.Name)
require.Nil(t, appErr)
for _, c := range results {
assert.NotEqual(t, channel.Id, c.Id, "guests must never see discoverable private channels in autocomplete")
}
}
+447
View File
@@ -0,0 +1,447 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"encoding/json"
"errors"
"net/http"
"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"
)
// channelJoinRequestPaginationDefaultPerPage matches the public /api/v4 default
// for paginated endpoints.
const channelJoinRequestPaginationDefaultPerPage = 60
// channelJoinRequestPaginationMaxPerPage caps a single page's size; mirrors the
// 200 cap shared by other public list endpoints.
const channelJoinRequestPaginationMaxPerPage = 200
// requestJoinChannelGuard validates that a user is allowed to express interest
// in joining `channel` and returns a sanitized result for `channel`. Callers
// are expected to look up `channel` via the store before calling this helper.
func (a *App) requestJoinChannelGuard(rctx request.CTX, user *model.User, channel *model.Channel) *model.AppError {
if channel == nil {
return model.NewAppError("RequestJoinChannel", "app.channel.get.existing.app_error", nil, "", http.StatusNotFound)
}
if channel.DeleteAt != 0 {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.archived.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
if channel.Type != model.ChannelTypePrivate {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_private.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
if !channel.Discoverable {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.not_discoverable.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
}
// Shared channels join through their own remote-cluster sync mechanism.
if channel.IsShared() {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.shared.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
if user.IsGuest() {
return model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.guest.app_error", nil, "user_id="+user.Id, http.StatusForbidden)
}
if user.DeleteAt != 0 {
return model.NewAppError("RequestJoinChannel", "app.channel.add_member.deleted_user.app_error", nil, "", http.StatusForbidden)
}
return nil
}
// RequestJoinChannel decides between an immediate ABAC-gated auto-join and an
// asynchronous request-to-join row.
//
// Returns the persisted ChannelJoinRequest when the user must wait for an
// admin review, or nil when the user was added directly to the channel (the
// caller can detect this via the `joined` return value).
func (a *App) RequestJoinChannel(rctx request.CTX, userID, channelID, message string) (joined bool, req *model.ChannelJoinRequest, appErr *model.AppError) {
user, appErr := a.GetUser(userID)
if appErr != nil {
return false, nil, appErr
}
channel, appErr := a.GetChannel(rctx, channelID)
if appErr != nil {
return false, nil, appErr
}
if guardErr := a.requestJoinChannelGuard(rctx, user, channel); guardErr != nil {
return false, nil, guardErr
}
_, memberErr := a.Srv().Store().Channel().GetMember(rctx, channel.Id, user.Id)
if memberErr == nil {
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.already_member.app_error", nil, "channel_id="+channel.Id, http.StatusBadRequest)
}
var nfErr *store.ErrNotFound
if !errors.As(memberErr, &nfErr) {
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.get_member.app_error", nil, "", http.StatusInternalServerError).Wrap(memberErr)
}
enforced, appErr := a.ChannelAccessControlled(rctx, channel.Id)
if appErr != nil {
return false, nil, appErr
}
// ABAC gate: when an active policy is attached and the user qualifies, add
// the member directly. AddChannelMember re-runs the PDP gate inside
// addUserToChannel, so a denial here is authoritative; a non-allow result
// falls through to the request-row path below ONLY when there is no policy.
if enforced {
decision, evalErr := a.evaluateChannelMembership(rctx, user, channel)
if evalErr != nil {
return false, nil, evalErr
}
if !decision {
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.policy_denied.app_error", nil, "channel_id="+channel.Id, http.StatusForbidden)
}
if _, err := a.AddChannelMember(rctx, user.Id, channel, ChannelMemberOpts{UserRequestorID: user.Id}); err != nil {
return false, nil, err
}
return true, nil, nil
}
pending := &model.ChannelJoinRequest{
ChannelId: channel.Id,
UserId: user.Id,
Message: message,
}
saved, err := a.Srv().Store().ChannelJoinRequest().Save(pending)
if err != nil {
var conflict *store.ErrConflict
if errors.As(err, &conflict) {
existing, getErr := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channel.Id, user.Id)
if getErr == nil {
return false, existing, nil
}
return false, nil, model.NewAppError("RequestJoinChannel", "api.channel.discoverable_join_request.duplicate.app_error", nil, "channel_id="+channel.Id, http.StatusConflict)
}
if appErr, ok := err.(*model.AppError); ok {
return false, nil, appErr
}
return false, nil, model.NewAppError("RequestJoinChannel", "app.channel.join_request.save.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.broadcastChannelJoinRequestCreated(rctx, channel, saved)
return false, saved, nil
}
// WithdrawChannelJoinRequest flips a pending request the calling user owns to
// the withdrawn state. Non-owners receive a 404 (no oracle on existence) and
// already-terminal rows return 409.
func (a *App) WithdrawChannelJoinRequest(rctx request.CTX, requestID, userID string) (*model.ChannelJoinRequest, *model.AppError) {
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
if err != nil {
var nfErr *store.ErrNotFound
if errors.As(err, &nfErr) {
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
if current.UserId != userID {
// Hide the row from non-owners by returning the same not-found
// response. The reviewer flow uses different endpoints.
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
if current.Status != model.ChannelJoinRequestStatusPending {
return nil, model.NewAppError("WithdrawChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
}
current.Status = model.ChannelJoinRequestStatusWithdrawn
current.Message = ""
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
if err != nil {
if appErr, ok := err.(*model.AppError); ok {
return nil, appErr
}
return nil, model.NewAppError("WithdrawChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
channel, channelErr := a.GetChannel(rctx, updated.ChannelId)
if channelErr != nil {
// Channel went away mid-flight — still report the update; we just
// can't broadcast to the admin queue.
rctx.Logger().Warn("WithdrawChannelJoinRequest: failed to load channel for broadcast", mlog.String("channel_id", updated.ChannelId), mlog.Err(channelErr))
return updated, nil
}
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
return updated, nil
}
// GetMyChannelJoinRequest returns the calling user's active pending request for
// `channelID`, or nil if none exists. It never returns an error for a missing
// row — that's the non-pending state and is expected.
func (a *App) GetMyChannelJoinRequest(rctx request.CTX, userID, channelID string) (*model.ChannelJoinRequest, *model.AppError) {
req, err := a.Srv().Store().ChannelJoinRequest().GetPendingForChannelAndUser(channelID, userID)
if err != nil {
var nfErr *store.ErrNotFound
if errors.As(err, &nfErr) {
return nil, nil
}
return nil, model.NewAppError("GetMyChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return req, nil
}
// GetMyChannelJoinRequests lists the calling user's join requests across all
// channels. The "My Pending Requests" tab filters by `Status="pending"` (the
// default when opts.Status is empty).
func (a *App) GetMyChannelJoinRequests(rctx request.CTX, userID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
opts = sanitizeJoinRequestListOpts(opts)
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForUser(userID, opts)
if err != nil {
return nil, model.NewAppError("GetMyChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
}
// GetChannelJoinRequests lists the join requests targeting `channelID` for the
// admin queue UI. The visibility check is performed by the API layer via the
// PermissionManageChannelJoinRequests permission.
func (a *App) GetChannelJoinRequests(rctx request.CTX, channelID string, opts model.GetChannelJoinRequestsOpts) (*model.ChannelJoinRequestList, *model.AppError) {
opts = sanitizeJoinRequestListOpts(opts)
rows, total, err := a.Srv().Store().ChannelJoinRequest().GetForChannel(channelID, opts)
if err != nil {
return nil, model.NewAppError("GetChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return &model.ChannelJoinRequestList{Requests: rows, TotalCount: total}, nil
}
// CountPendingChannelJoinRequests returns the number of pending join requests
// for `channelID`, used by the channel-header badge.
func (a *App) CountPendingChannelJoinRequests(rctx request.CTX, channelID string) (int64, *model.AppError) {
count, err := a.Srv().Store().ChannelJoinRequest().CountPending(channelID)
if err != nil {
return 0, model.NewAppError("CountPendingChannelJoinRequests", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
return count, nil
}
// UpdateChannelJoinRequest applies an admin review (approve / deny) to a
// pending request. When approving, the user is added via AddChannelMember so
// the existing PDP gate inside addUserToChannel re-runs — admins cannot bypass
// an active ABAC policy. The store row is only updated after a successful add
// to keep the audit trail consistent.
func (a *App) UpdateChannelJoinRequest(rctx request.CTX, requestID, channelID string, patch *model.ChannelJoinRequestPatch, reviewerID string) (*model.ChannelJoinRequest, *model.AppError) {
if patch == nil {
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "", http.StatusBadRequest)
}
switch patch.Status {
case model.ChannelJoinRequestStatusApproved, model.ChannelJoinRequestStatusDenied:
default:
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.invalid_patch.app_error", nil, "status="+patch.Status, http.StatusBadRequest)
}
current, err := a.Srv().Store().ChannelJoinRequest().Get(requestID)
if err != nil {
var nfErr *store.ErrNotFound
if errors.As(err, &nfErr) {
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.get.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
// Defense in depth: refuse cross-channel updates so a forged request id
// can't be reviewed against a channel the admin happens to own.
if current.ChannelId != channelID {
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.not_found.app_error", nil, "request_id="+requestID, http.StatusNotFound)
}
if current.Status != model.ChannelJoinRequestStatusPending {
return nil, model.NewAppError("UpdateChannelJoinRequest", "api.channel.discoverable_join_request.not_pending.app_error", nil, "request_id="+requestID, http.StatusConflict)
}
channel, appErr := a.GetChannel(rctx, current.ChannelId)
if appErr != nil {
return nil, appErr
}
if patch.Status == model.ChannelJoinRequestStatusApproved {
if _, err := a.AddChannelMember(rctx, current.UserId, channel, ChannelMemberOpts{UserRequestorID: reviewerID}); err != nil {
return nil, err
}
}
current.Status = patch.Status
current.ReviewedBy = reviewerID
current.ReviewedAt = model.GetMillis()
current.DenialReason = ""
if patch.Status == model.ChannelJoinRequestStatusDenied && patch.DenialReason != nil {
current.DenialReason = *patch.DenialReason
}
// Drop the original message from the response; it served its purpose
// during review and keeping it would leak free-text into the audit trail.
current.Message = ""
updated, err := a.Srv().Store().ChannelJoinRequest().Update(current)
if err != nil {
if appErr, ok := err.(*model.AppError); ok {
return nil, appErr
}
return nil, model.NewAppError("UpdateChannelJoinRequest", "app.channel.join_request.update.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
a.broadcastChannelJoinRequestUpdated(rctx, channel, updated)
return updated, nil
}
// sanitizeJoinRequestListOpts clamps user-provided pagination + status options
// so the store sees a normalized request.
func sanitizeJoinRequestListOpts(opts model.GetChannelJoinRequestsOpts) model.GetChannelJoinRequestsOpts {
if opts.Status == "" {
opts.Status = model.ChannelJoinRequestStatusPending
} else if !model.IsValidChannelJoinRequestStatus(opts.Status) {
opts.Status = model.ChannelJoinRequestStatusPending
}
if opts.Page < 0 {
opts.Page = 0
}
if opts.PerPage <= 0 {
opts.PerPage = channelJoinRequestPaginationDefaultPerPage
} else if opts.PerPage > channelJoinRequestPaginationMaxPerPage {
opts.PerPage = channelJoinRequestPaginationMaxPerPage
}
return opts
}
// evaluateChannelMembership runs the access-control PDP for `user` against the
// `membership` action on `channel`, returning the boolean decision. Errors
// from the PDP are returned to callers so they can choose between the
// "channel is invisible" (visibility filter) or "channel cannot be joined"
// (request flow) fail-secure semantics. Callers must have already verified
// that `channel.PolicyEnforced` is true before invoking the PDP.
func (a *App) evaluateChannelMembership(rctx request.CTX, user *model.User, channel *model.Channel) (bool, *model.AppError) {
acs := a.Srv().Channels().AccessControl
if acs == nil {
// No ABAC service → fail-secure. The channel acts as if the user did
// not satisfy the policy.
return false, nil
}
subject, appErr := a.BuildAccessControlSubject(rctx, user.Id, user.Roles)
if appErr != nil {
return false, appErr
}
decision, evalErr := acs.AccessEvaluation(rctx, model.AccessRequest{
Subject: *subject,
Resource: model.Resource{
Type: model.AccessControlPolicyTypeChannel,
ID: channel.Id,
},
Action: "membership",
})
if evalErr != nil {
return false, evalErr
}
return decision.Decision, nil
}
// channelAdminUserIDs returns the user ids of channel members with the
// scheme-admin role on `channelID`. Used to scope WS broadcasts of join-request
// events to the queue audience. Failures bubble up because broadcasting to no
// one would silently break the admin UI.
func (a *App) channelAdminUserIDs(rctx request.CTX, channelID string) ([]string, *model.AppError) {
const channelMembersPageSize = 200
admins := []string{}
page := 0
for {
members, err := a.Srv().Store().Channel().GetMembers(model.ChannelMembersGetOptions{
ChannelID: channelID,
Offset: page * channelMembersPageSize,
Limit: channelMembersPageSize,
})
if err != nil {
return nil, model.NewAppError("channelAdminUserIDs", "app.channel.get_members.app_error", nil, "", http.StatusInternalServerError).Wrap(err)
}
for _, m := range members {
if m.SchemeAdmin {
admins = append(admins, m.UserId)
}
}
if len(members) < channelMembersPageSize {
break
}
page++
}
return admins, nil
}
// broadcastChannelJoinRequestCreated fires a channel_join_request_created event
// scoped to the channel admin set, using the OnlyChannelAdmins broadcast hook
// to filter out non-admin members the channel-id broadcast would otherwise
// reach.
func (a *App) broadcastChannelJoinRequestCreated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestCreated, true /* adminsOnly */)
}
// broadcastChannelJoinRequestUpdated fires a channel_join_request_updated event
// to the channel admin set + the requesting user (so their My Pending Requests
// list reacts in real-time).
func (a *App) broadcastChannelJoinRequestUpdated(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest) {
// Send a dedicated copy to the requester so an offline-but-then-reconnected
// requester gets their own row update even when they are not a channel
// member yet (the channel-id broadcast wouldn't reach them otherwise).
if req.UserId != "" {
userMessage := model.NewWebSocketEvent(model.WebsocketEventChannelJoinRequestUpdated, "", "", req.UserId, nil, "")
userMessage.Add("request", marshalChannelJoinRequest(rctx, req))
userMessage.Add("channel_id", channel.Id)
a.Publish(userMessage)
}
a.publishChannelJoinRequestEvent(rctx, channel, req, model.WebsocketEventChannelJoinRequestUpdated, true /* adminsOnly */)
}
func (a *App) publishChannelJoinRequestEvent(rctx request.CTX, channel *model.Channel, req *model.ChannelJoinRequest, event model.WebsocketEventType, adminsOnly bool) {
message := model.NewWebSocketEvent(event, "", channel.Id, "", nil, "")
message.Add("request", marshalChannelJoinRequest(rctx, req))
message.Add("channel_id", channel.Id)
if adminsOnly {
admins, appErr := a.channelAdminUserIDs(rctx, channel.Id)
if appErr != nil {
rctx.Logger().Warn("Failed to compute channel admin set for join request broadcast",
mlog.String("channel_id", channel.Id),
mlog.Err(appErr),
)
return
}
useOnlyChannelAdminsHook(message, admins)
}
a.Publish(message)
}
// marshalChannelJoinRequest returns the request as a JSON string for the WS
// payload. JSON encoding errors are logged and the payload is delivered as an
// empty string so the event still arrives (clients can tolerate a missing
// request body and refetch).
func marshalChannelJoinRequest(rctx request.CTX, req *model.ChannelJoinRequest) string {
if req == nil {
return ""
}
buf, err := json.Marshal(req)
if err != nil {
rctx.Logger().Warn("Failed to marshal ChannelJoinRequest for WS broadcast",
mlog.String("request_id", req.Id),
mlog.Err(err),
)
return ""
}
return string(buf)
}
@@ -0,0 +1,379 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mattermost/mattermost/server/public/model"
)
// withDiscoverableChannelsFlag toggles the FeatureFlag for the duration of a
// test and restores it on cleanup. Feature flags are read-only by default in
// the test config store; flipping SetReadOnlyFF lets the UpdateConfig call
// land. We deliberately do NOT restore SetReadOnlyFF(true) afterward — the
// underlying store is per-test and disposed on cleanup.
func withDiscoverableChannelsFlag(t *testing.T, th *TestHelper, on bool) {
t.Helper()
th.ConfigStore.SetReadOnlyFF(false)
previous := th.App.Config().FeatureFlags.DiscoverableChannels
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.DiscoverableChannels = on })
t.Cleanup(func() {
th.App.UpdateConfig(func(cfg *model.Config) { cfg.FeatureFlags.DiscoverableChannels = previous })
})
}
// markDiscoverable flips the channel's discoverable flag in the store via
// PatchChannel so the model invariants run alongside the test scenario.
func markDiscoverable(t *testing.T, th *TestHelper, channel *model.Channel) *model.Channel {
t.Helper()
on := true
patched, err := th.App.PatchChannel(th.Context, channel, &model.ChannelPatch{Discoverable: &on}, th.BasicUser.Id)
require.Nil(t, err)
require.True(t, patched.Discoverable)
return patched
}
func TestRequestJoinChannel_RejectsNonDiscoverable(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
joined, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "please")
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Equal(t, "api.channel.discoverable_join_request.not_discoverable.app_error", appErr.Id)
assert.False(t, joined)
assert.Nil(t, req)
}
func TestRequestJoinChannel_RejectsExistingMember(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
// BasicUser is the channel creator → already a member.
_, _, appErr := th.App.RequestJoinChannel(th.Context, th.BasicUser.Id, channel.Id, "")
require.NotNil(t, appErr)
assert.Equal(t, http.StatusBadRequest, appErr.StatusCode)
assert.Equal(t, "api.channel.discoverable_join_request.already_member.app_error", appErr.Id)
}
func TestRequestJoinChannel_PendingHappyPath(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
joined, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "let me in")
require.Nil(t, appErr)
assert.False(t, joined, "should not auto-join when no policy is enforced")
require.NotNil(t, req)
assert.Equal(t, model.ChannelJoinRequestStatusPending, req.Status)
assert.Equal(t, channel.Id, req.ChannelId)
assert.Equal(t, other.Id, req.UserId)
assert.Equal(t, "let me in", req.Message)
// Submitting again returns the existing pending row (idempotent on
// partial-unique conflict).
joined, req2, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "again")
require.Nil(t, appErr)
assert.False(t, joined)
require.NotNil(t, req2)
assert.Equal(t, req.Id, req2.Id)
}
func TestRequestJoinChannel_RejectsGuest(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
guest := th.CreateGuest(t)
th.LinkUserToTeam(t, guest, th.BasicTeam)
_, _, appErr := th.App.RequestJoinChannel(th.Context, guest.Id, channel.Id, "")
require.NotNil(t, appErr)
assert.Equal(t, http.StatusForbidden, appErr.StatusCode)
assert.Equal(t, "api.channel.discoverable_join_request.guest.app_error", appErr.Id)
}
func TestUpdateChannelJoinRequest_ApproveAddsMember(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
patch := &model.ChannelJoinRequestPatch{Status: model.ChannelJoinRequestStatusApproved}
updated, appErr := th.App.UpdateChannelJoinRequest(th.Context, req.Id, channel.Id, patch, th.BasicUser.Id)
require.Nil(t, appErr)
assert.Equal(t, model.ChannelJoinRequestStatusApproved, updated.Status)
assert.Equal(t, th.BasicUser.Id, updated.ReviewedBy)
assert.NotZero(t, updated.ReviewedAt)
assert.Empty(t, updated.Message, "message should be redacted from the response after review")
member, mErr := th.App.GetChannelMember(th.Context, channel.Id, other.Id)
require.Nil(t, mErr)
require.NotNil(t, member)
assert.Equal(t, other.Id, member.UserId)
}
func TestUpdateChannelJoinRequest_DenyKeepsReason(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := th.CreatePrivateChannel(t, th.BasicTeam)
channel = markDiscoverable(t, th, channel)
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "please")
require.Nil(t, appErr)
require.NotNil(t, req)
reason := "team-internal channel"
patch := &model.ChannelJoinRequestPatch{
Status: model.ChannelJoinRequestStatusDenied,
DenialReason: &reason,
}
updated, appErr := th.App.UpdateChannelJoinRequest(th.Context, req.Id, channel.Id, patch, th.BasicUser.Id)
require.Nil(t, appErr)
assert.Equal(t, model.ChannelJoinRequestStatusDenied, updated.Status)
assert.Equal(t, reason, updated.DenialReason)
// Member must NOT have been added.
_, mErr := th.App.GetChannelMember(th.Context, channel.Id, other.Id)
require.NotNil(t, mErr)
assert.Equal(t, MissingChannelMemberError, mErr.Id)
}
func TestUpdateChannelJoinRequest_RejectsCrossChannel(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channelA := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
channelB := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channelA.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
patch := &model.ChannelJoinRequestPatch{Status: model.ChannelJoinRequestStatusApproved}
_, appErr = th.App.UpdateChannelJoinRequest(th.Context, req.Id, channelB.Id, patch, th.BasicUser.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
}
func TestWithdrawChannelJoinRequest_OwnerOnly(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
stranger := th.CreateUser(t)
_, appErr = th.App.WithdrawChannelJoinRequest(th.Context, req.Id, stranger.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusNotFound, appErr.StatusCode)
updated, appErr := th.App.WithdrawChannelJoinRequest(th.Context, req.Id, other.Id)
require.Nil(t, appErr)
assert.Equal(t, model.ChannelJoinRequestStatusWithdrawn, updated.Status)
// A second withdrawal is rejected with 409.
_, appErr = th.App.WithdrawChannelJoinRequest(th.Context, req.Id, other.Id)
require.NotNil(t, appErr)
assert.Equal(t, http.StatusConflict, appErr.StatusCode)
}
func TestGetMyChannelJoinRequests(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channelA := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
channelB := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, _, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channelA.Id, "")
require.Nil(t, appErr)
_, _, appErr = th.App.RequestJoinChannel(th.Context, other.Id, channelB.Id, "")
require.Nil(t, appErr)
list, appErr := th.App.GetMyChannelJoinRequests(th.Context, other.Id, model.GetChannelJoinRequestsOpts{})
require.Nil(t, appErr)
require.NotNil(t, list)
assert.EqualValues(t, 2, list.TotalCount)
assert.Len(t, list.Requests, 2)
}
func TestCountPendingChannelJoinRequests(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, _, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
count, appErr := th.App.CountPendingChannelJoinRequests(th.Context, channel.Id)
require.Nil(t, appErr)
assert.EqualValues(t, 1, count)
}
func TestUpdateChannelPrivacy_CancelsPendingRequestsOnConvertToPublic(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
th.LinkUserToTeam(t, other, th.BasicTeam)
_, req, appErr := th.App.RequestJoinChannel(th.Context, other.Id, channel.Id, "")
require.Nil(t, appErr)
require.NotNil(t, req)
channel.Type = model.ChannelTypeOpen
converted, appErr := th.App.UpdateChannelPrivacy(th.Context, channel, th.BasicUser)
require.Nil(t, appErr)
// Discoverable must be reset on convert-to-public — the model invariant
// (Channel.IsValid) rejects (type=O, discoverable=true), so leaving it
// true would also break the next channel save.
assert.False(t, converted.Discoverable, "Discoverable must be reset to false after convert-to-public")
persisted, getErr := th.App.GetChannel(th.Context, channel.Id)
require.Nil(t, getErr)
assert.False(t, persisted.Discoverable, "Discoverable must be persisted as false after convert-to-public")
// The cancellation side-effect is dispatched on a goroutine; poll for
// the withdrawn state instead of sleeping.
require.Eventually(t, func() bool {
row, err := th.App.Srv().Store().ChannelJoinRequest().Get(req.Id)
if err != nil {
return false
}
return row.Status == model.ChannelJoinRequestStatusWithdrawn
}, 2*time.Second, 50*time.Millisecond)
}
func TestIsDiscoverableSelfAddBlocked(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverable(t, th, th.CreatePrivateChannel(t, th.BasicTeam))
other := th.CreateUser(t)
assert.True(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, other.Id, other.Id), "self-add to discoverable + no-policy private must be blocked")
assert.False(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, th.BasicUser.Id, other.Id), "admin invite must not be blocked")
// Toggle off the flag → guard is inert.
withDiscoverableChannelsFlag(t, th, false)
assert.False(t, th.App.IsDiscoverableSelfAddBlocked(th.Context, channel, other.Id, other.Id))
}
func TestFilterDiscoverableChannelsByPolicy_FlagOff(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
// Flag off → filter is a no-op even when channels look discoverable.
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
channel.PolicyEnforced = true
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Len(t, out, 1)
}
func TestFilterDiscoverableChannelsByPolicy_NoPolicyPasses(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
require.Nil(t, appErr)
require.Len(t, out, 1, "no-policy discoverable channels are visible without ABAC evaluation")
}
func TestFilterDiscoverableChannelsByPolicy_PolicyEnforcedFailSecure(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
// PolicyEnforced + Discoverable + no AccessControl service wired ⇒ hidden.
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
channel.PolicyEnforced = true
require.Nil(t, th.App.Srv().Channels().AccessControl, "test fixture must not have ABAC wired")
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, th.BasicUser2.Id)
require.Nil(t, appErr)
assert.Len(t, out, 0, "fail-secure must hide policy-enforced channels when ABAC is unavailable")
}
func TestFilterDiscoverableChannelsByPolicy_GuestHidden(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
withDiscoverableChannelsFlag(t, th, true)
channel := markDiscoverableInMemory(t, th.CreatePrivateChannel(t, th.BasicTeam))
channel.PolicyEnforced = true
guest := th.CreateGuest(t)
out, appErr := th.App.FilterDiscoverableChannelsByPolicy(th.Context, []*model.Channel{channel}, guest.Id)
require.Nil(t, appErr)
assert.Empty(t, out, "guests must never see discoverable + policy-enforced channels")
}
// markDiscoverableInMemory is a no-DB helper for visibility filter tests that
// don't care about persistence — they only exercise the in-memory list filter.
func markDiscoverableInMemory(t *testing.T, channel *model.Channel) *model.Channel {
t.Helper()
channel.Discoverable = true
return channel
}
@@ -25,6 +25,7 @@ const (
broadcastBurnOnRead = "burn_on_read"
broadcastBurnOnReadReaction = "burn_on_read_reaction"
broadcastAbacFiles = "abac_files"
broadcastOnlyChannelAdmins = "only_channel_admins"
)
func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook {
@@ -37,6 +38,7 @@ func (s *Server) makeBroadcastHooks() map[string]platform.BroadcastHook {
broadcastBurnOnRead: &burnOnReadBroadcastHook{},
broadcastBurnOnReadReaction: &burnOnReadReactionBroadcastHook{},
broadcastAbacFiles: &abacFilesBroadcastHook{},
broadcastOnlyChannelAdmins: &onlyChannelAdminsBroadcastHook{},
}
}
@@ -505,6 +507,34 @@ func (h *abacFilesBroadcastHook) stripFilesFromMessage(msg *platform.HookedWebSo
return nil
}
// onlyChannelAdminsBroadcastHook narrows a channel-scoped broadcast to the
// channel-admin subset of the channel's members. The hook arg
// `channel_admin_user_ids` is the precomputed list of admin user ids at publish
// time; recipients not in that set have the event rejected.
//
// Pair with `Broadcast{ChannelId: channelId}` so the platform's existing
// channel-member fan-out is the outer bound and this hook simply filters
// non-admin members out.
type onlyChannelAdminsBroadcastHook struct{}
func useOnlyChannelAdminsHook(message *model.WebSocketEvent, channelAdminUserIds []string) {
message.GetBroadcast().AddHook(broadcastOnlyChannelAdmins, map[string]any{
"channel_admin_user_ids": model.StringArray(channelAdminUserIds),
})
}
func (h *onlyChannelAdminsBroadcastHook) Process(msg *platform.HookedWebSocketEvent, webConn *platform.WebConn, args map[string]any) error {
adminUserIDs, err := getTypedArg[model.StringArray](args, "channel_admin_user_ids")
if err != nil {
return errors.Wrap(err, "Invalid channel_admin_user_ids value passed to onlyChannelAdminsBroadcastHook")
}
if !slices.Contains(adminUserIDs, webConn.UserId) {
msg.Event().Reject()
}
return nil
}
func incrementWebsocketCounter(wc *platform.WebConn) {
if wc.Platform.Metrics() == nil {
return
@@ -3260,6 +3260,9 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc
From("ChannelMembers").
Where(sq.Eq{"UserId": userID})))
} else {
// Non-guests see public channels, private channels they're a member of, and
// discoverable private channels (subject to a post-query ABAC visibility filter
// applied at the app layer for policy-enforced channels).
query = query.Where(sq.Or{
sq.NotEq{"c.Type": model.ChannelTypePrivate},
sq.And{
@@ -3268,6 +3271,10 @@ func (s SqlChannelStore) Autocomplete(rctx request.CTX, userID, term string, inc
From("ChannelMembers").
Where(sq.Eq{"UserId": userID})),
},
sq.And{
sq.Eq{"c.Type": model.ChannelTypePrivate},
sq.Eq{"c.Discoverable": true},
},
})
}
@@ -3311,12 +3318,19 @@ func (s SqlChannelStore) buildAutocompleteInTeamQuery(teamID, userID, term strin
if isGuest {
query = query.Where(sq.Expr("c.Id IN (?)", memberSubQuery))
} else {
// Non-guests see public channels, private channels they're a member of, and
// discoverable private channels (subject to a post-query ABAC visibility filter
// applied at the app layer for policy-enforced channels).
query = query.Where(sq.Or{
sq.NotEq{"c.Type": model.ChannelTypePrivate},
sq.And{
sq.Eq{"c.Type": model.ChannelTypePrivate},
sq.Expr("c.Id IN (?)", memberSubQuery),
},
sq.And{
sq.Eq{"c.Type": model.ChannelTypePrivate},
sq.Eq{"c.Discoverable": true},
},
})
}
+4
View File
@@ -129,6 +129,9 @@ type Params struct {
GroupName string
ObjectType string
TargetId string
// Channel join requests
RequestId string
}
var getChannelMembersForUserRegex = regexp.MustCompile("/api/v4/users/[A-Za-z0-9]{26}/channel_members")
@@ -205,6 +208,7 @@ func ParamsFromRequest(r *http.Request) *Params {
params.GroupName = props["group_name"]
params.ObjectType = props["object_type"]
params.TargetId = props["target_id"]
params.RequestId = props["request_id"]
params.Scope = query.Get("scope")
if val, err := strconv.Atoi(query.Get("page")); err != nil || (val < 0 && params.UserId == "" && !getChannelMembersForUserRegex.MatchString(r.URL.Path)) {
+64
View File
@@ -427,6 +427,54 @@
"id": "api.channel.delete_channel.type.invalid",
"translation": "Unable to delete direct or group message channels"
},
{
"id": "api.channel.discoverable_join_request.already_member.app_error",
"translation": "You are already a member of this channel."
},
{
"id": "api.channel.discoverable_join_request.archived.app_error",
"translation": "Cannot request to join an archived channel."
},
{
"id": "api.channel.discoverable_join_request.discoverable_requires_approval.app_error",
"translation": "This channel requires admin approval to join. Please send a request from the Browse Channels modal."
},
{
"id": "api.channel.discoverable_join_request.duplicate.app_error",
"translation": "You already have a pending request to join this channel."
},
{
"id": "api.channel.discoverable_join_request.feature_disabled.app_error",
"translation": "Discoverable channels are not enabled on this server."
},
{
"id": "api.channel.discoverable_join_request.guest.app_error",
"translation": "Guests cannot request to join discoverable private channels."
},
{
"id": "api.channel.discoverable_join_request.invalid_patch.app_error",
"translation": "Invalid update for the channel join request."
},
{
"id": "api.channel.discoverable_join_request.not_discoverable.app_error",
"translation": "This channel is not discoverable."
},
{
"id": "api.channel.discoverable_join_request.not_pending.app_error",
"translation": "The join request is no longer pending."
},
{
"id": "api.channel.discoverable_join_request.not_private.app_error",
"translation": "Only private channels accept join requests."
},
{
"id": "api.channel.discoverable_join_request.policy_denied.app_error",
"translation": "You do not satisfy the access rules required to join this channel."
},
{
"id": "api.channel.discoverable_join_request.shared.app_error",
"translation": "Shared channels do not accept discoverable join requests."
},
{
"id": "api.channel.get_channel.flagged_post_mismatch.app_error",
"translation": "Channel ID does not match the channel ID of the flagged post."
@@ -5634,6 +5682,22 @@
"id": "app.channel.group_message_conversion.post_message.error",
"translation": "Failed to create group message to channel conversion post"
},
{
"id": "app.channel.join_request.get.app_error",
"translation": "Failed to load channel join request."
},
{
"id": "app.channel.join_request.not_found.app_error",
"translation": "Channel join request not found."
},
{
"id": "app.channel.join_request.save.app_error",
"translation": "Failed to save channel join request."
},
{
"id": "app.channel.join_request.update.app_error",
"translation": "Failed to update channel join request."
},
{
"id": "app.channel.migrate_channel_members.select.app_error",
"translation": "Failed to select the batch of channel members."
+3
View File
@@ -84,6 +84,9 @@ const (
AuditEventAddChannelMember = "addChannelMember" // add member to channel
AuditEventConvertGroupMessageToChannel = "convertGroupMessageToChannel" // convert group message to private channel
AuditEventCreateChannel = "createChannel" // create public or private channel
AuditEventCreateChannelJoinRequest = "createChannelJoinRequest" // request to join a discoverable private channel
AuditEventUpdateChannelJoinRequest = "updateChannelJoinRequest" // approve or deny a channel join request
AuditEventWithdrawChannelJoinRequest = "withdrawChannelJoinRequest" // requester cancels their channel join request
AuditEventCreateDirectChannel = "createDirectChannel" // create direct message channel between two users
AuditEventCreateGroupChannel = "createGroupChannel" // create group message channel with multiple users
AuditEventDeleteChannel = "deleteChannel" // delete channel