Files
team/server/channels/app/channel_discoverable_visibility.go
zeekay 8feb24ea1f One name for this fork, and it is ours
The server module named github.com/hanzoai/team/server/v8 — a path that
resolves to the retired workspace's repository, not this one — while
server/public still published github.com/mattermost/mattermost/server/public.
Both are github.com/hanzoteam/server/... now, which is where this code
actually lives.

That second half is what made the binary unbootable. Upstream's public path
stayed claimed by upstream, mattermost-plugin-ai imported it, and the build
linked public/model twice; public/plugin's gob.Register then ran twice under
one name and panicked at init, before main. Reverting the rename traded the
brand for a working binary. Rebranding the plugin alongside it needs no such
trade: the agents fork is github.com/hanzoteam/agents/v2 against our public
module, so no module named mattermost/mattermost is in the graph at all.

Measured: `go list -deps ./cmd/mattermost` names exactly one public/model, and
it is ours.
2026-08-28 23:35:36 -07:00

385 lines
12 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"context"
"sync"
"github.com/hanzoteam/server/server/public/model"
"github.com/hanzoteam/server/server/public/shared/mlog"
"github.com/hanzoteam/server/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
}