snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 1s
This commit is contained in:
283
server/channels/app/team_directory_visibility.go
Normal file
283
server/channels/app/team_directory_visibility.go
Normal file
@@ -0,0 +1,283 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// teamVisibilityCacheKey is the per-request request.CTX value key used to
|
||||
// memoise PDP membership decisions across the N+1 team filtering work in a
|
||||
// single directory/search load.
|
||||
type teamVisibilityCacheKey struct{}
|
||||
|
||||
type teamVisibilityCache struct {
|
||||
mu sync.Mutex
|
||||
decisions map[string]bool
|
||||
}
|
||||
|
||||
func getTeamVisibilityCache(rctx request.CTX) *teamVisibilityCache {
|
||||
if v := rctx.Context().Value(teamVisibilityCacheKey{}); v != nil {
|
||||
if cache, ok := v.(*teamVisibilityCache); ok {
|
||||
return cache
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withTeamVisibilityCache returns a request context that memoises PDP membership
|
||||
// decisions across the visibility filter calls in a single request. It's safe to
|
||||
// call multiple times — only the outermost installation allocates a cache.
|
||||
func withTeamVisibilityCache(rctx request.CTX) request.CTX {
|
||||
if getTeamVisibilityCache(rctx) != nil {
|
||||
return rctx
|
||||
}
|
||||
cache := &teamVisibilityCache{decisions: map[string]bool{}}
|
||||
return rctx.WithContext(context.WithValue(rctx.Context(), teamVisibilityCacheKey{}, cache))
|
||||
}
|
||||
|
||||
func (c *teamVisibilityCache) get(teamID string) (bool, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
v, ok := c.decisions[teamID]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
func (c *teamVisibilityCache) set(teamID string, allow bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.decisions[teamID] = allow
|
||||
}
|
||||
|
||||
// evaluateTeamMembership runs the ABAC membership decision for user against team.
|
||||
// Mirrors evaluateChannelMembership; the empty channelID means no channel-scoped
|
||||
// role is attached (team membership evaluates identity attributes only).
|
||||
// Fail-secure: a missing service denies.
|
||||
func (a *App) evaluateTeamMembership(rctx request.CTX, user *model.User, team *model.Team) (bool, *model.AppError) {
|
||||
acs := a.Srv().Channels().AccessControl
|
||||
if acs == nil {
|
||||
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.AccessControlPolicyTypeTeam,
|
||||
ID: team.Id,
|
||||
},
|
||||
Action: model.AccessControlPolicyActionMembership,
|
||||
})
|
||||
if evalErr != nil {
|
||||
return false, evalErr
|
||||
}
|
||||
return decision.Decision, nil
|
||||
}
|
||||
|
||||
// FilterNonQualifyingTeamsForUser removes from teams any policy-enforced team that
|
||||
// userID is neither a member of nor qualifies to join — the directory
|
||||
// non-disclosure invariant. Teams without an active policy are returned untouched,
|
||||
// and existing members always retain visibility regardless of the policy decision.
|
||||
// Hiding applies only to non-public teams: on a public team the policy is advisory,
|
||||
// so the team stays visible to everyone. Type and AllowOpenInvite are read to decide
|
||||
// public vs. non-public, but never mutated.
|
||||
//
|
||||
// Failure modes are fail-secure: a missing AccessControl service, a subject-build
|
||||
// failure, or any PDP error drops the offending team so a non-qualifying user can
|
||||
// never be shown a governed team. Decisions are memoised per-request. Returns the
|
||||
// trimmed list and the number of teams dropped so paginated callers can shrink the
|
||||
// reported total.
|
||||
func (a *App) FilterNonQualifyingTeamsForUser(rctx request.CTX, teams []*model.Team, userID string) ([]*model.Team, int, *model.AppError) {
|
||||
if len(teams) == 0 {
|
||||
return teams, 0, nil
|
||||
}
|
||||
|
||||
if !a.TeamMembershipAccessControlEnabled() {
|
||||
return teams, 0, nil
|
||||
}
|
||||
|
||||
rctx = withTeamVisibilityCache(rctx)
|
||||
cache := getTeamVisibilityCache(rctx)
|
||||
|
||||
var (
|
||||
user *model.User
|
||||
userErr *model.AppError
|
||||
userOnce sync.Once
|
||||
memberOf map[string]bool
|
||||
memberErr *model.AppError
|
||||
memberOnce sync.Once
|
||||
filtered = make([]*model.Team, 0, len(teams))
|
||||
dropCount int
|
||||
)
|
||||
|
||||
for _, team := range teams {
|
||||
if team == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !team.PolicyEnforced {
|
||||
filtered = append(filtered, team)
|
||||
continue
|
||||
}
|
||||
|
||||
// Public teams enforce the policy in advisory mode: it never hides the team.
|
||||
// Privacy follows master's open-directory model, which keys on
|
||||
// AllowOpenInvite alone.
|
||||
if team.AllowOpenInvite {
|
||||
filtered = append(filtered, team)
|
||||
continue
|
||||
}
|
||||
|
||||
// Existing members keep visibility even if they would no longer qualify —
|
||||
// directory hiding governs discovery, not retention.
|
||||
memberOnce.Do(func() {
|
||||
members, err := a.GetTeamMembersForUser(rctx, userID, "", false)
|
||||
if err != nil {
|
||||
memberErr = err
|
||||
return
|
||||
}
|
||||
memberOf = make(map[string]bool, len(members))
|
||||
for _, m := range members {
|
||||
memberOf[m.TeamId] = true
|
||||
}
|
||||
})
|
||||
if memberErr != nil {
|
||||
return nil, 0, memberErr
|
||||
}
|
||||
if memberOf[team.Id] {
|
||||
filtered = append(filtered, team)
|
||||
continue
|
||||
}
|
||||
|
||||
if cached, ok := cache.get(team.Id); ok {
|
||||
if cached {
|
||||
filtered = append(filtered, team)
|
||||
} else {
|
||||
dropCount++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
userOnce.Do(func() {
|
||||
user, userErr = a.GetUser(rctx, userID)
|
||||
})
|
||||
if userErr != nil {
|
||||
return nil, 0, userErr
|
||||
}
|
||||
|
||||
decision, evalErr := a.evaluateTeamMembership(rctx, user, team)
|
||||
if evalErr != nil {
|
||||
rctx.Logger().Warn("FilterNonQualifyingTeamsForUser: PDP error, hiding team (fail-secure)",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("team_id", team.Id),
|
||||
mlog.Err(evalErr),
|
||||
)
|
||||
cache.set(team.Id, false)
|
||||
dropCount++
|
||||
continue
|
||||
}
|
||||
cache.set(team.Id, decision)
|
||||
if decision {
|
||||
filtered = append(filtered, team)
|
||||
} else {
|
||||
dropCount++
|
||||
}
|
||||
}
|
||||
|
||||
return filtered, dropCount, nil
|
||||
}
|
||||
|
||||
// AnnotateRecommendedTeamsForUser sets Team.Recommended on public, policy-enforced
|
||||
// teams that userID qualifies to join and is not already a member of. The flag is a
|
||||
// transient per-viewer hint, never persisted and carrying no policy detail. Private
|
||||
// teams are never annotated — they are hidden by the directory filter, not
|
||||
// recommended. Decisions are memoised per-request, sharing the cache with the filter.
|
||||
//
|
||||
// Fail-secure: a missing AccessControl service, a member-lookup failure, a
|
||||
// subject-build failure, or any PDP error leaves Recommended false, so a
|
||||
// non-qualifying user is never tagged as recommended.
|
||||
func (a *App) AnnotateRecommendedTeamsForUser(rctx request.CTX, teams []*model.Team, userID string) {
|
||||
if len(teams) == 0 || !a.TeamMembershipAccessControlEnabled() {
|
||||
return
|
||||
}
|
||||
|
||||
rctx = withTeamVisibilityCache(rctx)
|
||||
cache := getTeamVisibilityCache(rctx)
|
||||
|
||||
var (
|
||||
user *model.User
|
||||
userErr *model.AppError
|
||||
userOnce sync.Once
|
||||
memberOf map[string]bool
|
||||
memberErr *model.AppError
|
||||
memberOnce sync.Once
|
||||
)
|
||||
|
||||
for _, team := range teams {
|
||||
if team == nil || !team.PolicyEnforced {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only public (advisory) governed teams carry the tag; private governed
|
||||
// teams are hidden, never recommended. Privacy keys on AllowOpenInvite,
|
||||
// matching master's open-directory model.
|
||||
if !team.AllowOpenInvite {
|
||||
continue
|
||||
}
|
||||
|
||||
// Suppress for current members — this also covers auto-added users, who
|
||||
// are members by the time they would be tagged.
|
||||
memberOnce.Do(func() {
|
||||
members, err := a.GetTeamMembersForUser(rctx, userID, "", false)
|
||||
if err != nil {
|
||||
memberErr = err
|
||||
return
|
||||
}
|
||||
memberOf = make(map[string]bool, len(members))
|
||||
for _, m := range members {
|
||||
memberOf[m.TeamId] = true
|
||||
}
|
||||
})
|
||||
if memberErr != nil {
|
||||
return
|
||||
}
|
||||
if memberOf[team.Id] {
|
||||
continue
|
||||
}
|
||||
|
||||
if cached, ok := cache.get(team.Id); ok {
|
||||
team.Recommended = cached
|
||||
continue
|
||||
}
|
||||
|
||||
userOnce.Do(func() {
|
||||
user, userErr = a.GetUser(rctx, userID)
|
||||
})
|
||||
if userErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
decision, evalErr := a.evaluateTeamMembership(rctx, user, team)
|
||||
if evalErr != nil {
|
||||
rctx.Logger().Warn("AnnotateRecommendedTeamsForUser: PDP error, not recommending (fail-secure)",
|
||||
mlog.String("user_id", userID),
|
||||
mlog.String("team_id", team.Id),
|
||||
mlog.Err(evalErr),
|
||||
)
|
||||
cache.set(team.Id, false)
|
||||
continue
|
||||
}
|
||||
cache.set(team.Id, decision)
|
||||
team.Recommended = decision
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user