Files
team/server/channels/app/team_directory_visibility.go
85dc729756 MM 69100 - Team ABAC Membership - members sync and end user surfaces (#37054) (#37548)
* MM-69063 - Add team ABAC model and constants foundation

* Add team ABAC store EXISTS, channel Type retrofit, policy count split, and index migration

* Add team ABAC app layer: access gate, hydrators, assign/unassign, cleanup, and  GetTeamMembersToRemove store

* Enforce team membership ABAC on join and hide policy governed teams from  non-qualifying users in the directory

* Add team_ids to access policy assign/unassign, expose per-team policy GET,  and support abac_match_only for not_in_team user listing

* Add team ABAC client methods, websocket handler, per-team System Console policy UI, and hide policy-governed teams from non-qualifying users

* Make team ABAC mode-aware: advisory on public teams, strict on private, and surface governed private teams to qualifying users in directory listings

* Flag-gate team ABAC mutation/read APIs and fix policy-save error handling, member-removal limit, team-id  validation, export, and audit cleanup

* coderabbit feedback; Broadcast team policy enforcement updates on policy create/update and activation, not only on delete

* Update team access control policy schema to allow nullable policies and enhance test cases with channel counts

* Enhance access control policy tests to include team policy search alongside channel policy search

* Add team membership access control feature flag to docker-compose generation

* Implement team access control policy checks and refactor related components

* MM-69100 - add team membership ABAC sync worker with mode-aware removal and auto-add

* Add team ABAC removal/auto-add notifications, cascade audit, and team custom-rules save backend

* Add Team Settings team membership tab with custom-rules editor, auto-add toggle, save confirmation, and mode-flip  sync trigger

* Add team access control policy panels, job details team list, and e2e coverage for membership tab and discoverability cards

* ix save double-submit with loading state, team privacy via updateTeamPrivacy, mode-flip count accuracy, sync trigger logic, i18n keys, and e2e test cleanup

* Add team membership policy notices to invite and add-to-team flows, team access control system messages, and a team policy attributes endpoint

* Add team membership recommended tag for qualifying users and team requirements notice in the members modal

* temp

* adjust styling, add new e2e, fix team admin job permissions issues

* e2e clean items created

* fix directory and team integration e2e tests

* Surface team sync results in the Sync Job Details modal via a Teams tab linked from the chained channel job

* code clean up, adjust styling

* Add access control attributes endpoint and enhance E2E tests for team membership

* Enhance team access control by updating permission checks and syncing jobs

* Add API-level enforcement gate tests for ABAC team membership

* Fix formatting in team channel settings component by adding missing semicolon

* Fix formatting in Client4 class by adjusting type annotations for clarity

* revert unwanted changes in package-lock

* Refactor job permission test and simplify error handling in team details component

* Update test assertions and enhance auto-add functionality in team membership tab

* Update onboarding tests to use 'Public Team' card for team access settings

* Refactor AccessSettings and TeamPolicyEditor components to replace allowOpenInviteCheckbox with public and private team buttons, and update access control job dispatching logic.

* Remove redundant state updates for channel assignments in access control policy reducer

* Implement email suppression for team membership notifications and add related tests

* Authorize ABAC team self-join by attribute match instead of the join_private_teams role

* comments clean up

* Gate team privacy type normalization behind active ABAC, keep legacy allow_open_invite-only path  otherwise

* adjust job details styles

* Add team membership policy disconnect confirmation, fix linked-policy affected-member count, and polish job details modal

* Combine custom rules with system policy expressions for accurate confirm counts

* Implement child resource count stamping and deletion gating for access control policies

* Enhance team sync job triggering logic for membership changes without auto-add

* Refactor team privacy handling to align with open-directory model; update comments for clarity on allow_open_invite logic.

* Deduplicate team ABAC sync jobs per policy to prevent concurrent runs emitting duplicate DMs and audit records on HA clusters

* fix linter warnings

* Add confirmation for policy removal in TeamDetails tests

* Update delete policy message to include Teams in the warning

* Add linked teams warning to policy deletion and update translations

* Add Auto-add feature for policy selection and team management

- Enhanced PolicySelectionModal to include an Auto-add checkbox for each policy.
- Updated PolicyList to manage Auto-add state and reflect changes in the UI.
- Modified TeamAccessControl to handle Auto-add functionality and persist changes.
- Added tests to ensure Auto-add behavior works as expected in various scenarios.
- Updated translations for Auto-add related strings.

* Add team membership sync footer and enhance job fetching with policy ID

* Refactor team membership policy handling and sync footer implementation

* Add team sync channel cascade test for private channel membership

---------


(cherry picked from commit a582f03570)

Co-authored-by: Pablo Vélez <[email protected]>
2026-07-17 10:39:37 +03:00

284 lines
8.0 KiB
Go

// 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(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(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
}
}