Files
team/server/channels/app/team_access_control.go
07e56271fd Feature mm 64509 team admin abac channels (#36061)
* MM-67592 - be changes for team admin abac channels (#35353)

* MM-67592 - be changes for team admin abac channels

* Revert team-scoped API routes, keep app layer business logic

* move from config to permission; Add cluster-aware LRU cache for policy team scope lookup

* remove unnecessary references to config value

* local/remote cache invalidation consistency for policy scope

* Replace policy scope cache with store-level team scope query

* rename functions and add comments to query

---------

Co-authored-by: Mattermost Build <[email protected]>

* MM 67594 - policies CUD operations to team settings modal channels ABAC (#35590)

* MM-67592 - be changes for team admin abac channels

* Revert team-scoped API routes, keep app layer business logic

* move from config to permission; Add cluster-aware LRU cache for policy team scope lookup

* remove unnecessary references to config value

* local/remote cache invalidation consistency for policy scope

* Replace policy scope cache with store-level team scope query

* format files correctly

* fix mock expectations for store-query approach in tests

* rename functions and add comments to query

* revert error ids to original to prevent break tests

* adjust translations

* MM-67669 - add tab to team settings modal and basic listing

* adjust tests and fix linter

* use existing search api logic

* fix style and adjust flaky test to clean up and restore orinals

* address ai corabbit feedback and fix linter

* fix unit tests

* MM-67592 - be changes for team admin abac channels (#35353)

* MM-67592 - be changes for team admin abac channels

* fix linter

* fix ts linter for playwright

* Revert team-scoped API routes, keep app layer business logic

* move from config to permission; Add cluster-aware LRU cache for policy team scope lookup

* remove unnecessary references to config value

* local/remote cache invalidation consistency for policy scope

* Replace policy scope cache with store-level team scope query

* format files correctly

* fix mock expectations for store-query approach in tests

* rename functions and add comments to query

* revert error ids to original to prevent break tests

* adjust translations

---------

Co-authored-by: Mattermost Build <[email protected]>

* MM-67594 - support cud operations for team abac BE changes

* create the team settings policy edit section, reuse most components, add basic e2e

* move optional refresh policy list button to list component

* temp get team admins cud policies and sync job

* enhance validation and adjust e2e

* Fix testExpression permission; fix pagination of team policies; add isValidId validation

* adjust styles, handling renaming and add permission migrations

* update the permissions names, use the simple confirmation modal, define the delete modal

* fix policy deletion flow

* fix some linter issues and adjust helper tests

* remove delete from list and fix e2e

* code comments clean up

* remove CEL editor for now, clean styles, enhance e2e

* fix linter, adjust unit test

* fix linter and add missing translation

* fix policy deletion ownership and sanitize test expression

* fixed e2e tests

* rollback orphaned policy on failed channel assignment

* enforce channelless check before last_team_id fallback

* enforce channelless guard on assign fallback too

* add translations missing

* add teamId to audit payload when present

* fix refresh button pagination reset

* fix null safety in channel selector loadChannels

* use responsive width cap for team settings modal and adjust header size

* remove redundant raw term from channel search URL, add showRefreshButton prop to PolicyList component

* handle error when stamping last team ID on channelless policy

* replace Props-based ownership with in-memory LRU cache, disable save on zero channels

* make e2e tests more reliable in CI

* test skip if no license valid found

* add childCount guard to cache-hit paths and reduce TTL to 5s

* fix e2e, adjust translation

* address review feedback: flatten permission checks and separate error types

- Flatten nested permission branching in deleteAccessControlPolicy using
  early returns to reduce indentation (review: isacikgoz)
- Validate teamID as input (400) before using it for permission checks (403)
  in testExpression and validateExpressionAgainstRequester handlers
- Remove redundant hasSystemPermission check in searchAccessControlPolicies
  since system_admin role already includes manage_team_access_rules
- Refactor ValidateTeamAdminPolicyOwnership to return (bool, *model.AppError)
  separating "not owned" from "internal error" across all 8 call sites
- Update tests to assert on both return values

* add persistent team scope to access control policies, replace in-memory cache

* fix translation

* fix case-insensitive policy search and sanitize search term input

* make policies tests have a unique name

* decouple scope/scopeID filter from TeamID in policy store

* Fix authZ bypass searchChannelsForAccessControlPolicy by forcing TeamIds to authorized team

* show unsaved changes on navigator back, and list all private channels on load

* filter already applied channels to a policy

* adjust the styles to dark mode; do not show added channels to the policy in the add channels modal

* fix linter

* MM-67967 add sync status footer to team settings (#35729)

* MM-67967 add sync status footer to team settings

* remove magic numbers and strings and polish the code

* fix linter

* fix linter: replace interface{} with any per gofmt rewrite rule

* refine getJobsByType team-scoped filtering and permissions

* fix sync footer stuck in syncing state on job creation error

* fix team-scoped job pagination in getJobsByType

* Fix authZ bypass searchChannelsForAccessControlPolicy by forcing TeamIds to authorized team

* implement ux feedback, change titles font, fix marging and scroll view jump

* MM-68135 - migrate add channels to policy modal to generic modal (#35907)

* MM-67920 unify e2e team settings tests (#35867)

* MM-67920 - extract duplicated policy editor helpers

* remove duplicate team icon test file

* rename Access Control to Membership Policies in e2e

* replace networkidle with explicit element waits

* fix attribute loading issue

---------

---------

Co-authored-by: Mattermost Build <[email protected]>

* fix playwright feedback issues and persist filters to the store layer in the no systemconsole path

* Improve policy scope validation and team admin security checks

* Renamed public channels to "AAA Public Channel %03d" and private ones to "ZZZ Private..." so the 55 public channels now fill the 50-result cap

* fix e2e tests and add new unit tests to improve coverage

* Improve e2e test stability: race condition handling and timeout adjustments

* Improve team-scoped ABAC policies: scope preservation, input validation, shared exclusion

* Add comprehensive ABAC test coverage: team admin ops and security validation to reduce flakyness

* Fix team policy editor back button: preserve navigation intent through Undo

* style: format import statements for better readability

* Enhance access control policy creation for team admins: enforce scope stamping from query parameters to prevent unauthorized team assignments

---------

Co-authored-by: Mattermost Build <[email protected]>

Co-authored-by: Hanzo Dev <[email protected]>
2026-04-16 00:48:43 +02:00

303 lines
10 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package app
import (
"net/http"
"sort"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/mlog"
"github.com/mattermost/mattermost/server/public/shared/request"
)
const (
teamPoliciesDefaultPerPage = 10
teamPoliciesMaxFetch = 1000
)
// SearchTeamAccessPolicies returns team-scoped parent policies that the requester
// satisfies. Fetches all matching policies first, applies self-inclusion filtering,
// then paginates the result to keep totals accurate.
func (a *App) SearchTeamAccessPolicies(rctx request.CTX, teamID, requesterID string, opts model.AccessControlPolicySearch) ([]*model.AccessControlPolicy, int64, *model.AppError) {
requestedCursor := opts.Cursor
requestedLimit := opts.Limit
if requestedLimit <= 0 {
requestedLimit = teamPoliciesDefaultPerPage
}
// Fetch policies matching via channel-inference (TeamID filter)
channelOpts := opts
channelOpts.TeamID = teamID
channelOpts.Type = model.AccessControlPolicyTypeParent
channelOpts.IncludeChildren = true
channelOpts.Cursor = model.AccessControlPolicyCursor{}
channelOpts.Limit = teamPoliciesMaxFetch
policies, _, appErr := a.SearchAccessControlPolicies(rctx, channelOpts)
if appErr != nil {
return nil, 0, appErr
}
// Also fetch policies matching via explicit scope (for channelless policies)
scopeOpts := opts
scopeOpts.Type = model.AccessControlPolicyTypeParent
scopeOpts.Scope = model.AccessControlPolicyScopeTeam
scopeOpts.ScopeID = teamID
scopeOpts.IncludeChildren = true
scopeOpts.Cursor = model.AccessControlPolicyCursor{}
scopeOpts.Limit = teamPoliciesMaxFetch
scopePolicies, _, appErr := a.SearchAccessControlPolicies(rctx, scopeOpts)
if appErr != nil {
return nil, 0, appErr
}
// Merge, deduplicating by ID, then sort by ID for stable cursor-based pagination.
seen := make(map[string]bool, len(policies))
for _, p := range policies {
seen[p.ID] = true
}
for _, p := range scopePolicies {
if !seen[p.ID] {
policies = append(policies, p)
seen[p.ID] = true
}
}
sort.Slice(policies, func(i, j int) bool {
return policies[i].ID < policies[j].ID
})
// Filter by self-inclusion.
filtered := make([]*model.AccessControlPolicy, 0, len(policies))
for _, policy := range policies {
if len(policy.Rules) > 0 {
expression := policy.Rules[0].Expression
matches, matchErr := a.ValidateExpressionAgainstRequester(rctx, expression, requesterID)
if matchErr != nil {
rctx.Logger().Warn("Failed to validate self-inclusion for policy",
mlog.String("policy_id", policy.ID), mlog.Err(matchErr))
continue
}
if !matches {
continue
}
}
filtered = append(filtered, policy)
}
total := int64(len(filtered))
// Paginate the filtered results. If the cursor ID was removed by
// self-inclusion filtering, startIdx stays 0 (resets to first page).
startIdx := 0
if !requestedCursor.IsEmpty() {
for i, p := range filtered {
if p.ID == requestedCursor.ID {
startIdx = i + 1
break
}
}
}
endIdx := min(startIdx+requestedLimit, len(filtered))
return filtered[startIdx:endIdx], total, nil
}
// ValidateTeamAdminPolicyOwnership checks whether a policy is scoped to a given team.
// Returns (true, nil) if the policy belongs to the team, (false, nil) if it does not,
// or (false, err) if an internal error occurred during the check.
//
// Checks two mechanisms:
// 1. Explicit scope: policy has scope=team and scope_id=teamID in Data JSONB
// 2. Channel inference: all assigned channels belong to this team (pre-scope policies)
func (a *App) ValidateTeamAdminPolicyOwnership(rctx request.CTX, teamID, policyID string) (bool, *model.AppError) {
// Check explicit scope first
scopeResults, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
IDs: []string{policyID},
Type: model.AccessControlPolicyTypeParent,
Scope: model.AccessControlPolicyScopeTeam,
ScopeID: teamID,
Limit: 1,
})
if err != nil {
return false, model.NewAppError("ValidateTeamAdminPolicyOwnership",
"app.team.access_policies.ownership_check.app_error",
nil, "", http.StatusInternalServerError).Wrap(err)
}
if len(scopeResults) > 0 {
return true, nil
}
// Fall back to channel-inference for pre-scope policies
channelResults, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
IDs: []string{policyID},
Type: model.AccessControlPolicyTypeParent,
TeamID: teamID,
Limit: 1,
})
if err != nil {
return false, model.NewAppError("ValidateTeamAdminPolicyOwnership",
"app.team.access_policies.ownership_check.app_error",
nil, "", http.StatusInternalServerError).Wrap(err)
}
return len(channelResults) > 0, nil
}
// ReconcilePolicyTeamScope evaluates a parent policy's assigned channels and
// sets or clears the team scope accordingly:
// - All channels belong to a single team → scope = "team", scope_id = that team
// - Channels span multiple teams → scope cleared
// - No channels remaining → scope unchanged (preserves the team set at creation)
//
// This runs after every assign/unassign regardless of the caller's role, so that
// system admins adding cross-team channels correctly clears the team scope.
func (a *App) ReconcilePolicyTeamScope(rctx request.CTX, policyID string) *model.AppError {
// Find all child channel policies for this parent.
// Limit of 1000 is ok, since In practice a single policy will not have 1000+ channel
// private children.
children, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
ParentID: policyID,
Type: model.AccessControlPolicyTypeChannel,
Limit: 1000,
})
if err != nil {
return model.NewAppError("ReconcilePolicyTeamScope",
"app.team.access_policies.reconcile_scope.app_error",
nil, "", http.StatusInternalServerError).Wrap(err)
}
// No channels — keep existing scope unchanged
if len(children) == 0 {
return nil
}
// Collect channel IDs (child policy ID = channel ID)
channelIDs := make([]string, len(children))
for i, child := range children {
channelIDs[i] = child.ID
}
// Resolve channels to determine their teams.
// If some channels were deleted after being assigned, GetChannels returns
// fewer results — skip reconciliation to avoid stamping a stale scope.
channels, appErr := a.GetChannels(rctx, channelIDs)
if appErr != nil {
return appErr
}
if len(channels) != len(channelIDs) {
return nil
}
// Check if all channels belong to a single team
teamIDs := make(map[string]struct{})
for _, ch := range channels {
teamIDs[ch.TeamId] = struct{}{}
}
// Fetch the parent policy directly from the store (not the enterprise layer)
// to avoid unnecessary CEL expression normalization — we only need scope fields.
policy, storeErr := a.Srv().Store().AccessControlPolicy().Get(rctx, policyID)
if storeErr != nil {
return model.NewAppError("ReconcilePolicyTeamScope",
"app.team.access_policies.reconcile_scope.app_error",
nil, "", http.StatusInternalServerError).Wrap(storeErr)
}
var newScope, newScopeID string
if len(teamIDs) == 1 {
// All channels belong to one team — set scope
for tid := range teamIDs {
newScope = model.AccessControlPolicyScopeTeam
newScopeID = tid
}
}
// len(teamIDs) > 1: multiple teams — clear scope (newScope/newScopeID stay "")
// Skip save if nothing changed
if policy.Scope == newScope && policy.ScopeID == newScopeID {
return nil
}
policy.Scope = newScope
policy.ScopeID = newScopeID
// Save directly via the store — scope is metadata that doesn't require
// enterprise-layer CEL normalization. This avoids re-processing expressions
// and works consistently regardless of the enterprise layer state.
if _, err := a.Srv().Store().AccessControlPolicy().Save(rctx, policy); err != nil {
return model.NewAppError("ReconcilePolicyTeamScope",
"app.team.access_policies.reconcile_scope.app_error",
nil, "", http.StatusInternalServerError).Wrap(err)
}
return nil
}
// ValidateTeamScopePolicyChannelAssignment validates that all channels are eligible
// for team policy assignment, is the gate that run before channels are attached to a 'team' policy:
// - At least one channel provided
// - All channels exist
// - All channels belong to the given team
// - All channels are private
// - No group-constrained channels
// - No shared channels
func (a *App) ValidateTeamScopePolicyChannelAssignment(rctx request.CTX, teamID string, channelIDs []string) *model.AppError {
if len(channelIDs) == 0 {
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
"app.team.access_policies.channels_required.app_error",
nil, "at least one channel is required", http.StatusBadRequest)
}
channels, appErr := a.GetChannels(rctx, channelIDs)
if appErr != nil {
return appErr
}
if len(channels) != len(channelIDs) {
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
"app.team.access_policies.channel_not_found.app_error",
nil, "one or more channels not found", http.StatusBadRequest)
}
for _, channel := range channels {
if channel.TeamId != teamID {
return model.NewAppError("ValidateTeamScopePolicyChannelAssignment",
"app.team.access_policies.channel_wrong_team.app_error",
map[string]any{"ChannelId": channel.Id},
"channel does not belong to this team", http.StatusBadRequest)
}
if appErr := ValidateChannelEligibilityForAccessControl(channel); appErr != nil {
return appErr
}
}
return nil
}
// ValidateTeamAdminSelfInclusion ensures the requesting admin satisfies the given
// CEL expression. Returns an error if the admin would be excluded by the expression.
func (a *App) ValidateTeamAdminSelfInclusion(rctx request.CTX, userID, expression string) *model.AppError {
if expression == "" {
return nil
}
matches, appErr := a.ValidateExpressionAgainstRequester(rctx, expression, userID)
if appErr != nil {
return model.NewAppError("ValidateTeamAdminSelfInclusion",
"app.team.access_policies.validation_error.app_error",
nil, "", http.StatusInternalServerError).Wrap(appErr)
}
if !matches {
return model.NewAppError("ValidateTeamAdminSelfInclusion",
"app.team.access_policies.self_exclusion.app_error",
nil, "policy rules would exclude the requesting admin", http.StatusBadRequest)
}
return nil
}