mirror of
https://github.com/hanzoai/team.git
synced 2026-10-30 14:49:35 +00:00
* 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 * Audit-log team ABAC policy removal on team archive and delete --------- Co-authored-by: Mattermost Build <[email protected]>
199 lines
5.7 KiB
Go
199 lines
5.7 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.
|
|
// Mirrors isPublicTeam in the API layer; any half-configured team falls
|
|
// through to strict hiding.
|
|
if team.AllowOpenInvite && team.Type == model.TeamOpen {
|
|
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
|
|
}
|